@particle-academy/react-fancy 5.70.0 → 5.71.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/CHANGELOG.md +49 -0
- package/README.md +1 -0
- package/dist/autocomplete.cjs +3 -3
- package/dist/autocomplete.js +2 -2
- package/dist/chat-thread.cjs +27 -0
- package/dist/chat-thread.cjs.map +1 -0
- package/dist/chat-thread.d.cts +260 -0
- package/dist/chat-thread.d.ts +260 -0
- package/dist/chat-thread.js +14 -0
- package/dist/chat-thread.js.map +1 -0
- package/dist/chunk-5IE65DIH.cjs +623 -0
- package/dist/chunk-5IE65DIH.cjs.map +1 -0
- package/dist/{chunk-6QAF35AJ.cjs → chunk-7JLKOVHX.cjs} +3 -3
- package/dist/{chunk-6QAF35AJ.cjs.map → chunk-7JLKOVHX.cjs.map} +1 -1
- package/dist/{chunk-TCMOD3WW.js → chunk-A6NYZ3F5.js} +3 -3
- package/dist/{chunk-TCMOD3WW.js.map → chunk-A6NYZ3F5.js.map} +1 -1
- package/dist/{chunk-6EA3DSPT.cjs → chunk-FGOFG2UZ.cjs} +5 -5
- package/dist/{chunk-6EA3DSPT.cjs.map → chunk-FGOFG2UZ.cjs.map} +1 -1
- package/dist/{chunk-J7S42MJP.js → chunk-FYUEDPST.js} +39 -20
- package/dist/chunk-FYUEDPST.js.map +1 -0
- package/dist/{chunk-HN5J7TK6.js → chunk-GDH43QH6.js} +21 -8
- package/dist/chunk-GDH43QH6.js.map +1 -0
- package/dist/{chunk-AC3W3TIV.js → chunk-J6IQHJET.js} +14 -5
- package/dist/chunk-J6IQHJET.js.map +1 -0
- package/dist/{chunk-XNHAJQRF.cjs → chunk-JKD6CFIP.cjs} +14 -5
- package/dist/chunk-JKD6CFIP.cjs.map +1 -0
- package/dist/{chunk-FHRVSYJU.cjs → chunk-MP55W37V.cjs} +3 -3
- package/dist/{chunk-FHRVSYJU.cjs.map → chunk-MP55W37V.cjs.map} +1 -1
- package/dist/{chunk-LBSAWRFT.cjs → chunk-NPSKZ7ZB.cjs} +22 -9
- package/dist/chunk-NPSKZ7ZB.cjs.map +1 -0
- package/dist/{chunk-EZL5DILH.js → chunk-OPDHSOUN.js} +3 -3
- package/dist/{chunk-EZL5DILH.js.map → chunk-OPDHSOUN.js.map} +1 -1
- package/dist/{chunk-XAKZ6VBP.cjs → chunk-PS7C6GKI.cjs} +40 -21
- package/dist/chunk-PS7C6GKI.cjs.map +1 -0
- package/dist/chunk-UWYSJARZ.js +620 -0
- package/dist/chunk-UWYSJARZ.js.map +1 -0
- package/dist/{chunk-M6UAVA2I.js → chunk-XIA2UAVO.js} +3 -3
- package/dist/{chunk-M6UAVA2I.js.map → chunk-XIA2UAVO.js.map} +1 -1
- package/dist/composer.cjs +3 -3
- package/dist/composer.d.cts +3 -2
- package/dist/composer.d.ts +3 -2
- package/dist/composer.js +2 -2
- package/dist/editor.cjs +3 -3
- package/dist/editor.js +3 -3
- package/dist/{index-BAtF94lw.d.ts → index-B8OkuYSt.d.ts} +3 -2
- package/dist/{index-xwL2MACB.d.cts → index-BMVQnKTH.d.cts} +3 -2
- package/dist/index.cjs +170 -161
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +2 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +26 -25
- package/dist/index.js.map +1 -1
- package/dist/input.cjs +1 -1
- package/dist/input.js +1 -1
- package/dist/inputs.cjs +7 -7
- package/dist/inputs.js +3 -3
- package/dist/json-editor.cjs +16 -16
- package/dist/json-editor.js +5 -5
- package/dist/prompt-input.cjs +6 -6
- package/dist/prompt-input.d.cts +1 -1
- package/dist/prompt-input.d.ts +1 -1
- package/dist/prompt-input.js +3 -3
- package/dist/select.cjs +3 -3
- package/dist/select.js +2 -2
- package/docs/ChatThread.md +318 -0
- package/docs/Composer.md +5 -2
- package/docs/PromptInput.md +8 -2
- package/package.json +11 -1
- package/dist/chunk-AC3W3TIV.js.map +0 -1
- package/dist/chunk-HN5J7TK6.js.map +0 -1
- package/dist/chunk-J7S42MJP.js.map +0 -1
- package/dist/chunk-LBSAWRFT.cjs.map +0 -1
- package/dist/chunk-XAKZ6VBP.cjs.map +0 -1
- package/dist/chunk-XNHAJQRF.cjs.map +0 -1
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
# ChatThread
|
|
2
|
+
|
|
3
|
+
A controlled message list for agent and support conversations: chat with an
|
|
4
|
+
agent in a side panel, a ticket thread with internal notes, an admin chat whose
|
|
5
|
+
agent drafts proposals for a human to approve. `ChatMessage` is the single
|
|
6
|
+
message, usable on its own.
|
|
7
|
+
|
|
8
|
+
Added in `5.71.0`.
|
|
9
|
+
|
|
10
|
+
## Import
|
|
11
|
+
|
|
12
|
+
```tsx
|
|
13
|
+
import { ChatThread, ChatMessage } from "@particle-academy/react-fancy";
|
|
14
|
+
// or the subpath
|
|
15
|
+
import { ChatThread } from "@particle-academy/react-fancy/chat-thread";
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Basic usage
|
|
19
|
+
|
|
20
|
+
```tsx
|
|
21
|
+
const [messages, setMessages] = useState<ChatMessageData[]>([]);
|
|
22
|
+
|
|
23
|
+
<ChatThread
|
|
24
|
+
className="h-full"
|
|
25
|
+
messages={messages}
|
|
26
|
+
composer={
|
|
27
|
+
<Composer
|
|
28
|
+
onSubmit={async (text) => {
|
|
29
|
+
const res = await send(text); // the server appends the message
|
|
30
|
+
if (!res.ok) return { ok: false, error: res.error };
|
|
31
|
+
}}
|
|
32
|
+
/>
|
|
33
|
+
}
|
|
34
|
+
/>
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
The thread renders exactly `messages`, oldest first. It owns no message state.
|
|
38
|
+
It does not ship an input: put `Composer` or `PromptInput` in the `composer`
|
|
39
|
+
slot. Both have acknowledged send (5.70.0), so the draft stays until your
|
|
40
|
+
`onSubmit` Promise resolves.
|
|
41
|
+
|
|
42
|
+
## Messages are JSON
|
|
43
|
+
|
|
44
|
+
```ts
|
|
45
|
+
interface ChatMessageData {
|
|
46
|
+
id: string; // data-chat-message
|
|
47
|
+
role: "user" | "assistant" | "system" | "agent" | "staff" | "customer" | string;
|
|
48
|
+
author?: { name: string; avatar?: string };
|
|
49
|
+
content: string; // markdown, always sanitised
|
|
50
|
+
format?: "markdown" | "text"; // "text" renders literally
|
|
51
|
+
createdAt: string | number; // ISO 8601 or epoch ms
|
|
52
|
+
status?: "pending" | "streaming" | "sent" | "failed"; // default "sent"
|
|
53
|
+
visibility?: "public" | "internal"; // default "public"
|
|
54
|
+
attachments?: { id: string; name: string; url?: string; mimeType?: string; size?: number }[];
|
|
55
|
+
proposal?: { id: string; title?: string; state?: "pending" | "accepted" | "rejected" | "withdrawn";
|
|
56
|
+
acceptLabel?: string; rejectLabel?: string };
|
|
57
|
+
own?: boolean; // default: ownRoles.includes(role)
|
|
58
|
+
meta?: Record<string, unknown>; // carried, never rendered
|
|
59
|
+
}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
- **Content** goes through `ContentRenderer` with sanitisation on. There is no
|
|
63
|
+
`unsafe` prop on the thread, on purpose: message bodies are text the host did
|
|
64
|
+
not author. `contentOptions` passes `extensions`, `copyButtons`, `onCopy`,
|
|
65
|
+
`clipboardWriter` and `lineSpacing` through.
|
|
66
|
+
- **Streaming.** A message with `status: "streaming"` renders with
|
|
67
|
+
`ContentRenderer`'s `streaming`: by default an unfinished code fence or tag is
|
|
68
|
+
held back until it closes. `streaming="progressive"` on the thread shows it
|
|
69
|
+
as it fills. The message is `aria-busy` while it streams, so screen readers
|
|
70
|
+
are not read every token. Set the status to `"sent"` when the stream ends.
|
|
71
|
+
- **Roles.** The six named roles get default labels. Any other string is
|
|
72
|
+
carried verbatim on `data-chat-role` and rendered neutrally. `system` renders
|
|
73
|
+
as a centred line.
|
|
74
|
+
- **Own messages** align to the end. `ownRoles` (default `["user"]`) decides
|
|
75
|
+
which; `own` on a message overrides it. A support staff view passes
|
|
76
|
+
`ownRoles={["staff"]}`, or sets `own` per message to distinguish colleagues.
|
|
77
|
+
- **Attachments** render as links (unsafe schemes render the name without a
|
|
78
|
+
link). They are never loaded inline. `renderAttachment` replaces one.
|
|
79
|
+
|
|
80
|
+
## Internal notes
|
|
81
|
+
|
|
82
|
+
`visibility: "internal"` renders a dashed amber bubble with a visible
|
|
83
|
+
"Internal note" label (`[data-chat-internal-label]`) on every internal message.
|
|
84
|
+
|
|
85
|
+
**The thread never decides visibility.** It renders exactly what it is given.
|
|
86
|
+
Filter on the server (and in the transformer) and pass only what this viewer
|
|
87
|
+
may see. A component that hid internal notes itself would be a second policy
|
|
88
|
+
nobody tests.
|
|
89
|
+
|
|
90
|
+
## Proposals (approval cards)
|
|
91
|
+
|
|
92
|
+
A message with a `proposal` is an agent's suggested action awaiting a human: a
|
|
93
|
+
drafted reply, a field change, a page edit. Its `content` is the draft.
|
|
94
|
+
|
|
95
|
+
```tsx
|
|
96
|
+
<ChatThread
|
|
97
|
+
messages={messages}
|
|
98
|
+
onProposalAccept={(m) => api.decide(m.proposal!.id, "accept", m.meta?.version)}
|
|
99
|
+
onProposalReject={(m) => openRejectReasonDialog(m)}
|
|
100
|
+
/>
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
- Stable handle `[data-chat-proposal="<proposal id>"]` with
|
|
104
|
+
`data-chat-proposal-state`; buttons are
|
|
105
|
+
`[data-chat-proposal-action="accept" | "reject"]`.
|
|
106
|
+
- **It never acts on its own.** Nothing is called on render. The state is the
|
|
107
|
+
host's: buttons show only while `state` is `"pending"` (the default) and only
|
|
108
|
+
for the callbacks you pass. When the host moves the card to `accepted`,
|
|
109
|
+
`rejected` or `withdrawn`, the buttons are replaced by a status badge.
|
|
110
|
+
- **Acknowledged.** Return a Promise and both buttons are disabled
|
|
111
|
+
(`aria-busy`) until it settles; a second decision is refused even within the
|
|
112
|
+
same event batch. A rejection goes to `onActionError(error, action)`; without
|
|
113
|
+
it the rejection is left unhandled so it still reaches the console.
|
|
114
|
+
- "Edit then approve" and "reject with a reason" are host UI: open your editor
|
|
115
|
+
or dialog from the callback, or from the actions slot.
|
|
116
|
+
|
|
117
|
+
## Scrolling: stick to the bottom without yanking
|
|
118
|
+
|
|
119
|
+
- The thread starts at the bottom and follows new messages, and a streaming
|
|
120
|
+
message as it grows, **only while the reader is at the bottom**
|
|
121
|
+
(`bottomThreshold`, default 48px).
|
|
122
|
+
- A reader who scrolled up stays exactly where they are. An
|
|
123
|
+
"N new messages" control (`[data-react-fancy-chat-thread-jump]`,
|
|
124
|
+
`data-unseen`) appears; it jumps to the latest and moves focus to the log.
|
|
125
|
+
- The viewer's own new message (see `ownRoles`) always scrolls into view.
|
|
126
|
+
- Prepending earlier messages keeps the message under the reader in place.
|
|
127
|
+
- **Only the log's own `scrollTop` is ever set.** The thread never calls
|
|
128
|
+
`scrollIntoView`, and it focuses with `{ preventScroll: true }`, so no
|
|
129
|
+
ancestor and never the page scrolls (the rule `PromptInput` adopted in 5.69.0
|
|
130
|
+
for genie#1290).
|
|
131
|
+
- `stickToBottom={false}` turns following off entirely.
|
|
132
|
+
- `onAtBottomChange(atBottom)` reports the reader arriving at or leaving the
|
|
133
|
+
bottom.
|
|
134
|
+
|
|
135
|
+
### Imperative handle
|
|
136
|
+
|
|
137
|
+
```tsx
|
|
138
|
+
const thread = useRef<ChatThreadHandle>(null);
|
|
139
|
+
<ChatThread ref={thread} messages={messages} />;
|
|
140
|
+
|
|
141
|
+
thread.current?.scrollToLatest();
|
|
142
|
+
thread.current?.scrollToMessage("msg_42"); // false if not rendered
|
|
143
|
+
thread.current?.element; // the log element
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
## Load earlier
|
|
147
|
+
|
|
148
|
+
```tsx
|
|
149
|
+
<ChatThread
|
|
150
|
+
messages={messages}
|
|
151
|
+
hasEarlier={cursor !== null}
|
|
152
|
+
onLoadEarlier={async () => {
|
|
153
|
+
const page = await api.history(cursor);
|
|
154
|
+
setMessages((m) => [...page.items, ...m]);
|
|
155
|
+
setCursor(page.next);
|
|
156
|
+
}}
|
|
157
|
+
/>
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
The control shows while `onLoadEarlier` is set and `hasEarlier` is not
|
|
161
|
+
`false`. A returned Promise disables it until it settles; `loadingEarlier`
|
|
162
|
+
drives the same state from the host.
|
|
163
|
+
|
|
164
|
+
## Day separators and grouping
|
|
165
|
+
|
|
166
|
+
```tsx
|
|
167
|
+
<ChatThread messages={messages} separators="day" groupConsecutive />
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
- `separators="day"` inserts `[data-chat-day-separator="YYYY-MM-DD"]` at each
|
|
171
|
+
local calendar-day change. `formatDay` formats the label.
|
|
172
|
+
- `groupConsecutive` merges consecutive messages from the same role and author
|
|
173
|
+
within 5 minutes (or pass a window in ms): the header and avatar show once
|
|
174
|
+
and later messages carry `data-chat-grouped`. Internal and public messages
|
|
175
|
+
never merge, and neither do proposals or system lines.
|
|
176
|
+
|
|
177
|
+
## Per-message actions
|
|
178
|
+
|
|
179
|
+
```tsx
|
|
180
|
+
<ChatThread
|
|
181
|
+
messages={messages}
|
|
182
|
+
renderActions={(m) =>
|
|
183
|
+
m.role === "assistant" ? (
|
|
184
|
+
<Button size="xs" variant="ghost" icon="copy" onClick={() => copy(m.content)}>Copy</Button>
|
|
185
|
+
) : m.status === "failed" ? (
|
|
186
|
+
<Button size="xs" variant="ghost" onClick={() => retry(m)}>Retry</Button>
|
|
187
|
+
) : null
|
|
188
|
+
}
|
|
189
|
+
/>
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
The slot renders in `[data-chat-message-actions]`; return `null` for none.
|
|
193
|
+
|
|
194
|
+
## Human+
|
|
195
|
+
|
|
196
|
+
- **Controlled.** `messages` is the state. Loading history and deciding a
|
|
197
|
+
proposal are callbacks; the thread holds only view state (scroll position,
|
|
198
|
+
the unseen count).
|
|
199
|
+
- **Stable handles.** `data-react-fancy-chat-thread`,
|
|
200
|
+
`data-chat-message="<id>"` with `data-chat-role`, `data-chat-status`,
|
|
201
|
+
`data-chat-visibility`, and `data-chat-proposal="<id>"`.
|
|
202
|
+
- **Observable activity.** `onActivity` receives every interaction:
|
|
203
|
+
|
|
204
|
+
```ts
|
|
205
|
+
type ChatThreadActivity =
|
|
206
|
+
| { type: "load-earlier" }
|
|
207
|
+
| { type: "proposal-accept"; messageId: string; proposalId: string }
|
|
208
|
+
| { type: "proposal-reject"; messageId: string; proposalId: string }
|
|
209
|
+
| { type: "jump-to-latest"; unseen: number };
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
Map it onto fancy-auto-common's `AutoActivityEvent` in the host, as with
|
|
213
|
+
`JsonEditor`'s `onActivity`.
|
|
214
|
+
- **Trust but verify.** Proposals are the staged-write affordance: the agent
|
|
215
|
+
proposes, the human decides.
|
|
216
|
+
- **Bridgeable.** A bridge reads and writes the same JSON the host renders
|
|
217
|
+
(`messages`, proposal decisions). The thread itself adds nothing an agent
|
|
218
|
+
has to scrape.
|
|
219
|
+
|
|
220
|
+
## Accessibility
|
|
221
|
+
|
|
222
|
+
- The log is `role="log"`, `aria-live="polite"`, `aria-relevant="additions"`,
|
|
223
|
+
focusable (`tabIndex=0`) so it can be scrolled from the keyboard.
|
|
224
|
+
- It is labelled by `heading` (rendered as `h2`, or `headingLevel`), or by
|
|
225
|
+
`labels.log` ("Conversation") when there is no heading.
|
|
226
|
+
- Every string is in `labels` for translation, including role names and the
|
|
227
|
+
"N new messages" text.
|
|
228
|
+
|
|
229
|
+
## Layouts
|
|
230
|
+
|
|
231
|
+
### Side panel (Charlotte)
|
|
232
|
+
|
|
233
|
+
```tsx
|
|
234
|
+
<ResizablePanels defaultValue={68} label="Resize the chat panel">
|
|
235
|
+
<Editor value={session.state.doc} onOp={session.applyHuman} />
|
|
236
|
+
<ChatThread
|
|
237
|
+
className="h-full"
|
|
238
|
+
heading="Charlotte"
|
|
239
|
+
headingLevel={2}
|
|
240
|
+
messages={session.messages} // reduced from the event log
|
|
241
|
+
streaming="progressive"
|
|
242
|
+
ownRoles={["user"]}
|
|
243
|
+
groupConsecutive
|
|
244
|
+
onProposalAccept={(m) => session.accept(m.proposal!.id)}
|
|
245
|
+
onProposalReject={(m) => session.reject(m.proposal!.id)}
|
|
246
|
+
onActivity={(e) => activity.emit(toAutoActivity(e))}
|
|
247
|
+
renderActions={(m) => m.role === "assistant" && m.status === "sent" ? <CopyButton text={m.content} /> : null}
|
|
248
|
+
composer={
|
|
249
|
+
<PromptInput
|
|
250
|
+
placeholder="Ask Charlotte to change the page..."
|
|
251
|
+
sendDisabled={session.running}
|
|
252
|
+
onSubmit={(text) => session.send(text)}
|
|
253
|
+
/>
|
|
254
|
+
}
|
|
255
|
+
/>
|
|
256
|
+
</ResizablePanels>
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
### Ticket thread (support)
|
|
260
|
+
|
|
261
|
+
```tsx
|
|
262
|
+
<ChatThread
|
|
263
|
+
className="h-full"
|
|
264
|
+
heading={`${ticket.reference}: ${ticket.subject}`}
|
|
265
|
+
messages={ticket.messages} // the server already filtered internal notes for customers
|
|
266
|
+
ownRoles={["staff"]}
|
|
267
|
+
separators="day"
|
|
268
|
+
hasEarlier={ticket.hasEarlier}
|
|
269
|
+
onLoadEarlier={() => loadEarlier(ticket.reference)}
|
|
270
|
+
onProposalAccept={(m) => cards.approve(m.proposal!.id, m.meta?.version as number)}
|
|
271
|
+
onProposalReject={(m) => cards.openReject(m.proposal!.id)}
|
|
272
|
+
onActionError={(err) => toast.error(String(err))}
|
|
273
|
+
composer={
|
|
274
|
+
<Composer
|
|
275
|
+
actions={<MultiSwitch value={mode} onValueChange={setMode} list={[{ value: "public", label: "Reply" }, { value: "internal", label: "Internal note" }]} />}
|
|
276
|
+
onSubmit={(text) => postMessage(ticket.reference, { text, visibility: mode })}
|
|
277
|
+
/>
|
|
278
|
+
}
|
|
279
|
+
/>
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
## Props
|
|
283
|
+
|
|
284
|
+
| Prop | Type | Default | Description |
|
|
285
|
+
|------|------|---------|-------------|
|
|
286
|
+
| messages | `ChatMessageData[]` | - | The messages, oldest first. Required |
|
|
287
|
+
| heading | `ReactNode` | - | Visible title; the log is labelled by it |
|
|
288
|
+
| headingLevel | `2 \| 3 \| 4 \| 5 \| 6` | `2` | Heading element |
|
|
289
|
+
| composer | `ReactNode` | - | Rendered below the log (`Composer` / `PromptInput`) |
|
|
290
|
+
| renderActions | `(m) => ReactNode` | - | Per-message actions slot |
|
|
291
|
+
| onLoadEarlier | `() => void \| Promise` | - | Shows the load-earlier control |
|
|
292
|
+
| hasEarlier | `boolean` | `true` with `onLoadEarlier` | Hide the control when there is nothing earlier |
|
|
293
|
+
| loadingEarlier | `boolean` | `false` | Host-driven busy state for the control |
|
|
294
|
+
| onProposalAccept / onProposalReject | `(m) => void \| Promise` | - | Proposal decisions |
|
|
295
|
+
| onActionError | `(error, action) => void` | - | A returned Promise rejected |
|
|
296
|
+
| onActivity | `(e: ChatThreadActivity) => void` | - | Every interaction |
|
|
297
|
+
| stickToBottom | `boolean` | `true` | Follow new messages while at the bottom |
|
|
298
|
+
| bottomThreshold | `number` | `48` | Pixels from the bottom that count as "at the bottom" |
|
|
299
|
+
| onAtBottomChange | `(atBottom) => void` | - | The reader arrived at / left the bottom |
|
|
300
|
+
| separators | `"none" \| "day"` | `"none"` | Day separators |
|
|
301
|
+
| formatDay / formatTime | `(d: Date) => string` | locale | Date and time labels |
|
|
302
|
+
| groupConsecutive | `boolean \| number` | `false` | Merge consecutive messages (5 min, or ms) |
|
|
303
|
+
| ownRoles | `string[]` | `["user"]` | Roles aligned as the viewer's own |
|
|
304
|
+
| streaming | `true \| "progressive"` | `true` | How a streaming message renders its tail |
|
|
305
|
+
| contentOptions | `ChatContentOptions` | - | Passed to every `ContentRenderer` (no `unsafe`) |
|
|
306
|
+
| renderAttachment | `(a, m) => ReactNode` | - | Replace one attachment's rendering |
|
|
307
|
+
| empty | `ReactNode` | - | Shown when there are no messages |
|
|
308
|
+
| labels | `Partial<ChatThreadLabels>` | English | Every visible string |
|
|
309
|
+
| classNames | `ChatThreadClassNames` | - | `root`, `heading`, `log`, `composer`, `separator`, `jump`, `message`, `bubble`, `header`, `actions`, `proposal` |
|
|
310
|
+
| ref | `Ref<ChatThreadHandle>` | - | `scrollToLatest`, `scrollToMessage`, `element` |
|
|
311
|
+
|
|
312
|
+
Other HTML attributes (including `data-*`) go on the root; `onScroll` is
|
|
313
|
+
forwarded from the log, which is the element that scrolls.
|
|
314
|
+
|
|
315
|
+
`ChatMessage` takes `message`, `actions`, `grouped` and the same display props
|
|
316
|
+
(`ownRoles`, `streaming`, `contentOptions`, `labels`, `formatTime`,
|
|
317
|
+
`renderAttachment`, the proposal callbacks, `onActivity`, `onActionError`,
|
|
318
|
+
`classNames`).
|
package/docs/Composer.md
CHANGED
|
@@ -42,8 +42,11 @@ acknowledged. While it is pending the send button is disabled and
|
|
|
42
42
|
`data-submit-state="submitting"`. The text stays editable.
|
|
43
43
|
|
|
44
44
|
- **Resolves** (to anything but `{ ok: false }`): the message clears, exactly
|
|
45
|
-
as a void `onSubmit` clears it
|
|
46
|
-
|
|
45
|
+
as a void `onSubmit` clears it, but only if the message is still the revision
|
|
46
|
+
that was sent. If the text, a held paste or a held image changed while the
|
|
47
|
+
send was in flight (the person typing, even typing and then deleting; a
|
|
48
|
+
paste; a host moving a controlled `value` / `heldPastes` / `heldImages`), the
|
|
49
|
+
newer message is kept whole rather than discarded.
|
|
47
50
|
- **Rejects, or resolves `{ ok: false, error? }`**: the text and held pastes
|
|
48
51
|
are kept, `onSubmitError` is called, the root carries
|
|
49
52
|
`data-submit-state="error"`, and a visually hidden `role="alert"`
|
package/docs/PromptInput.md
CHANGED
|
@@ -68,8 +68,14 @@ disabled, shows its spinner and is `aria-busy`, a second send is refused, and
|
|
|
68
68
|
the root carries `data-submit-state="submitting"`. The text stays editable.
|
|
69
69
|
|
|
70
70
|
- **Resolves** (to anything but `{ ok: false }`): the draft empties in one
|
|
71
|
-
commit, exactly as a void `onSubmit` empties it
|
|
72
|
-
|
|
71
|
+
commit, exactly as a void `onSubmit` empties it, but only if the draft is
|
|
72
|
+
still the revision that was sent. Anything that changed it while the send was
|
|
73
|
+
in flight makes it newer, and a newer draft is kept whole, even if it now
|
|
74
|
+
reads the same: the person typing (including typing and then deleting),
|
|
75
|
+
pasting a pill, attaching or removing a file; the host calling `setDraft`
|
|
76
|
+
(even with the same draft); or a controlled `draft` / `value` that now says
|
|
77
|
+
something different. A `ref` arriving from `persistAttachment` does not count:
|
|
78
|
+
that is storage catching up, not a change to the message.
|
|
73
79
|
- **Rejects, or resolves `{ ok: false, error? }`**: the draft is kept,
|
|
74
80
|
`onSubmitError` is called, the root carries `data-submit-state="error"`, and
|
|
75
81
|
a visually hidden `role="alert"` (`[data-react-fancy-prompt-input-error]`)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@particle-academy/react-fancy",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.71.0",
|
|
4
4
|
"description": "React UI component library for Human+ UX — controlled, agent-bridgeable primitives",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -173,6 +173,16 @@
|
|
|
173
173
|
"default": "./dist/chat-drawer.cjs"
|
|
174
174
|
}
|
|
175
175
|
},
|
|
176
|
+
"./chat-thread": {
|
|
177
|
+
"import": {
|
|
178
|
+
"types": "./dist/chat-thread.d.ts",
|
|
179
|
+
"default": "./dist/chat-thread.js"
|
|
180
|
+
},
|
|
181
|
+
"require": {
|
|
182
|
+
"types": "./dist/chat-thread.d.cts",
|
|
183
|
+
"default": "./dist/chat-thread.cjs"
|
|
184
|
+
}
|
|
185
|
+
},
|
|
176
186
|
"./checkbox": {
|
|
177
187
|
"import": {
|
|
178
188
|
"types": "./dist/checkbox.d.ts",
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/hooks/use-acknowledged-submit.ts"],"names":[],"mappings":";;;AAiBA,SAAS,WAAW,KAAA,EAA+C;AACjE,EAAA,OACE,KAAA,KAAU,IAAA,KACT,OAAO,KAAA,KAAU,QAAA,IAAY,OAAO,KAAA,KAAU,UAAA,CAAA,IAC/C,OAAQ,KAAA,CAA6B,IAAA,KAAS,UAAA;AAElD;AAEA,SAAS,YAAY,KAAA,EAA0C;AAC7D,EAAA,OACE,UAAU,IAAA,IACV,OAAO,KAAA,KAAU,QAAA,IAChB,MAA2B,EAAA,KAAO,KAAA;AAEvC;AAiBO,SAAS,sBAAsB,aAAA,EAA0C;AAC9E,EAAA,MAAM,CAAC,MAAA,EAAQ,SAAS,CAAA,GAAI,SAAuB,MAAM,CAAA;AACzD,EAAA,MAAM,CAAC,OAAA,EAAS,UAAU,CAAA,GAAI,SAAS,EAAE,CAAA;AACzC,EAAA,MAAM,QAAA,GAAW,OAAO,KAAK,CAAA;AAC7B,EAAA,MAAM,KAAA,GAAQ,OAAO,IAAI,CAAA;AACzB,EAAA,MAAM,UAAA,GAAa,OAAO,aAAa,CAAA;AACvC,EAAA,UAAA,CAAW,OAAA,GAAU,aAAA;AAErB,EAAA,SAAA,CAAU,MAAM;AACd,IAAA,KAAA,CAAM,OAAA,GAAU,IAAA;AAChB,IAAA,OAAO,MAAM;AACX,MAAA,KAAA,CAAM,OAAA,GAAU,KAAA;AAAA,IAClB,CAAA;AAAA,EACF,CAAA,EAAG,EAAE,CAAA;AAEL,EAAA,MAAM,IAAA,GAAO,WAAA,CAAY,CAAC,KAAA,EAAgB,MAAA,KAA+B;AACvE,IAAA,IAAI,MAAM,OAAA,EAAS;AACjB,MAAA,SAAA,CAAU,OAAO,CAAA;AACjB,MAAA,UAAA,CAAW,MAAA,GAAS,CAAA,kBAAA,EAAqB,MAAM,CAAA,CAAA,GAAK,+CAA+C,CAAA;AAAA,IACrG;AACA,IAAA,UAAA,CAAW,UAAU,KAAK,CAAA;AAAA,EAC5B,CAAA,EAAG,EAAE,CAAA;AAEL,EAAA,MAAM,KAAA,GAAQ,WAAA;AAAA,IACZ,CAAC,QAAiB,MAAA,KAAuB;AACvC,MAAA,IAAI,CAAC,UAAA,CAAW,MAAM,CAAA,EAAG;AACvB,QAAA,MAAA,EAAO;AACP,QAAA,SAAA,CAAU,MAAM,CAAA;AAChB,QAAA,UAAA,CAAW,EAAE,CAAA;AACb,QAAA;AAAA,MACF;AAEA,MAAA,QAAA,CAAS,OAAA,GAAU,IAAA;AACnB,MAAA,SAAA,CAAU,YAAY,CAAA;AACtB,MAAA,UAAA,CAAW,EAAE,CAAA;AACb,MAAA,MAAA,CAAO,IAAA;AAAA,QACL,CAAC,KAAA,KAAU;AACT,UAAA,QAAA,CAAS,OAAA,GAAU,KAAA;AACnB,UAAA,IAAI,WAAA,CAAY,KAAK,CAAA,EAAG;AACtB,YAAA,IAAA,CAAK,KAAA,CAAM,KAAA,EAAO,KAAA,CAAM,KAAK,CAAA;AAC7B,YAAA;AAAA,UACF;AACA,UAAA,IAAI,MAAM,OAAA,EAAS;AACjB,YAAA,SAAA,CAAU,MAAM,CAAA;AAChB,YAAA,MAAA,EAAO;AAAA,UACT;AAAA,QACF,CAAA;AAAA,QACA,CAAC,MAAA,KAAoB;AACnB,UAAA,QAAA,CAAS,OAAA,GAAU,KAAA;AACnB,UAAA,IAAA,CAAK,QAAQ,MAAS,CAAA;AAAA,QACxB;AAAA,OACF;AAAA,IACF,CAAA;AAAA,IACA,CAAC,IAAI;AAAA,GACP;AAEA,EAAA,MAAM,OAAO,WAAA,CAAY,MAAM,QAAA,CAAS,OAAA,EAAS,EAAE,CAAA;AAEnD,EAAA,OAAO,EAAE,MAAA,EAAQ,OAAA,EAAS,KAAA,EAAO,IAAA,EAAK;AACxC","file":"chunk-AC3W3TIV.js","sourcesContent":["import { useCallback, useEffect, useRef, useState } from \"react\";\n\n/** What an `onSubmit` Promise resolves to when the host refuses the send. */\nexport type SubmitRejection = { ok: false; error?: string };\n\n/**\n * What an `onSubmit` Promise may resolve to. Anything other than a\n * `SubmitRejection` (including `undefined`) means the send was accepted.\n */\nexport type SubmitResult = void | SubmitRejection;\n\n/**\n * `\"idle\"` — nothing in flight. `\"submitting\"` — an `onSubmit` Promise is\n * pending. `\"error\"` — the last one was refused or rejected; the draft is kept.\n */\nexport type SubmitStatus = \"idle\" | \"submitting\" | \"error\";\n\nfunction isThenable(value: unknown): value is PromiseLike<unknown> {\n return (\n value !== null &&\n (typeof value === \"object\" || typeof value === \"function\") &&\n typeof (value as { then?: unknown }).then === \"function\"\n );\n}\n\nfunction isRejection(value: unknown): value is SubmitRejection {\n return (\n value !== null &&\n typeof value === \"object\" &&\n (value as { ok?: unknown }).ok === false\n );\n}\n\n/**\n * The acknowledged-send state machine `Composer` and `PromptInput` share, so the\n * two cannot disagree about what a Promise from `onSubmit` means.\n *\n * `track(result, accept)` is handed whatever `onSubmit` returned:\n *\n * - not a Promise → `accept()` runs NOW, which is exactly the old behaviour;\n * - a Promise → `\"submitting\"` until it settles; `accept()` runs on a resolve\n * that is not `{ ok: false }`; a rejection or `{ ok: false }` leaves the draft\n * alone, moves to `\"error\"`, calls `onSubmitError` and sets `message` for the\n * live region.\n *\n * `busy()` reads a ref, not state, so two sends in the same tick (a double\n * Enter, Enter then a click) are refused before React has re-rendered.\n */\nexport function useAcknowledgedSubmit(onSubmitError?: (error: unknown) => void) {\n const [status, setStatus] = useState<SubmitStatus>(\"idle\");\n const [message, setMessage] = useState(\"\");\n const inFlight = useRef(false);\n const alive = useRef(true);\n const onErrorRef = useRef(onSubmitError);\n onErrorRef.current = onSubmitError;\n\n useEffect(() => {\n alive.current = true;\n return () => {\n alive.current = false;\n };\n }, []);\n\n const fail = useCallback((error: unknown, detail: string | undefined) => {\n if (alive.current) {\n setStatus(\"error\");\n setMessage(detail ? `Message not sent: ${detail}` : \"Message not sent. Your message has been kept.\");\n }\n onErrorRef.current?.(error);\n }, []);\n\n const track = useCallback(\n (result: unknown, accept: () => void) => {\n if (!isThenable(result)) {\n accept();\n setStatus(\"idle\");\n setMessage(\"\");\n return;\n }\n\n inFlight.current = true;\n setStatus(\"submitting\");\n setMessage(\"\");\n result.then(\n (value) => {\n inFlight.current = false;\n if (isRejection(value)) {\n fail(value.error, value.error);\n return;\n }\n if (alive.current) {\n setStatus(\"idle\");\n accept();\n }\n },\n (reason: unknown) => {\n inFlight.current = false;\n fail(reason, undefined);\n },\n );\n },\n [fail],\n );\n\n const busy = useCallback(() => inFlight.current, []);\n\n return { status, message, track, busy };\n}\n"]}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/components/Composer/Composer.tsx"],"names":["Composer","paste"],"mappings":"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","file":"chunk-HN5J7TK6.js","sourcesContent":["import { forwardRef, useCallback, useMemo, useRef } from \"react\";\nimport { cn } from \"../../utils/cn\";\nimport { isImeComposing } from \"../../utils/ime\";\nimport { visuallyHidden } from \"../../utils/visually-hidden\";\nimport { useAcknowledgedSubmit } from \"../../hooks/use-acknowledged-submit\";\nimport { useControllableState } from \"../../hooks/use-controllable-state\";\nimport {\n endsOf,\n InlinePasteEditor,\n PastedFilesInput,\n useInlinePaste,\n withSeparators,\n type HeldPaste,\n type InlinePasteEditorHandle,\n} from \"../PastePills\";\nimport type { ComposerProps } from \"./Composer.types\";\n\n/** Characters of a held paste shown at each end of the hover preview, unless `previewChars` says otherwise. */\nconst PREVIEW_CHARS = 20;\n\nexport const Composer = forwardRef<HTMLDivElement, ComposerProps>(\n function Composer(\n {\n value: controlledValue,\n defaultValue = \"\",\n onChange,\n onSubmit,\n onSubmitError,\n placeholder = \"Type a message...\",\n actions,\n disabled = false,\n sendDisabled = false,\n name,\n inputId,\n className,\n pasteThreshold,\n pastePlacement = \"above\",\n pasteImages = false,\n previewChars = PREVIEW_CHARS,\n renderPastePreview,\n classNames,\n heldPastes: controlledHeldPastes,\n onHeldPastesChange,\n heldImages: controlledHeldImages,\n onHeldImagesChange,\n pasteFilesName,\n },\n ref,\n ) {\n const inline = pastePlacement === \"inline\";\n const [value, setValue] = useControllableState(\n controlledValue,\n defaultValue,\n onChange,\n );\n const textareaRef = useRef<HTMLTextAreaElement>(null);\n const editorRef = useRef<InlinePasteEditorHandle>(null);\n /*\n * The holding logic lives in `usePastePills`, wrapped by `useInlinePaste` for\n * the inline placement — which `PromptInput` shares, so the two components\n * cannot disagree about what a pill means. The rule that must not drift is\n * the one below: a send with pills still closed carries the text.\n */\n const paste = useInlinePaste({\n pasteThreshold,\n pasteImages,\n previewChars,\n renderPastePreview,\n classNames,\n disabled,\n heldPastes: controlledHeldPastes,\n onHeldPastesChange,\n heldImages: controlledHeldImages,\n onHeldImagesChange,\n editorRef,\n });\n const pills = paste.pills;\n const heldPastes = paste.heldPastes;\n const pastedFiles = useMemo(\n () => (inline && pasteImages ? paste.imagesIn(value).map((i) => i.file) : []),\n [inline, pasteImages, paste, value],\n );\n\n const handlePaste = useCallback(\n (e: React.ClipboardEvent<HTMLTextAreaElement>) => {\n // Composable by design, so this reads as a question rather than a handover.\n // Nothing else here wants the paste, so the answer is simply ignored.\n pills.handlePaste(e);\n },\n [pills],\n );\n\n const handleInlineChange = useCallback(\n (next: string) => {\n setValue(next);\n paste.prune(next);\n },\n [setValue, paste],\n );\n\n /** Everything the person believes they are sending, in the order they built it. */\n const composeOutgoing = useCallback(() => {\n if (inline) return paste.expand(value);\n\n return [value, ...heldPastes.map((p) => p.text)].filter(Boolean).join(\"\\n\\n\");\n }, [inline, value, heldPastes, paste]);\n\n const hasContent = value.trim().length > 0 || (!inline && heldPastes.length > 0);\n\n const focusInput = useCallback(() => {\n if (inline) editorRef.current?.focus();\n else textareaRef.current?.focus();\n }, [inline]);\n\n const ack = useAcknowledgedSubmit(onSubmitError);\n const submitting = ack.status === \"submitting\";\n\n /** What a send would carry right now — read when an async send settles, never from a stale closure. */\n const draftKey = JSON.stringify([value, heldPastes.map((p) => p.id)]);\n const draftKeyRef = useRef(draftKey);\n draftKeyRef.current = draftKey;\n\n const handleSubmit = useCallback(() => {\n if (!hasContent || disabled || sendDisabled || ack.busy()) return;\n\n /*\n * THE DANGEROUS STATE, and the reason this feature needed a spec rather than\n * a guess. A pill reads as \"your paste is attached\" — so a send that drops it\n * means asking a question about a document without the document, and nobody\n * can tell: the sender believes it went, the receiver sees a question about\n * nothing.\n *\n * Above the input, held pastes go out in arrival order after what was typed,\n * because people type the question BEFORE pasting the thing it is about.\n * Inline, every pill goes out exactly where it sits.\n */\n const images = inline ? paste.imagesIn(value) : [];\n\n const result = onSubmit?.(composeOutgoing(), { images });\n const sent = draftKeyRef.current;\n ack.track(result, () => {\n /*\n * A void `onSubmit` reaches here synchronously, so `sent` is still the\n * current draft and this clears exactly as it always has. An async one\n * reaches here when it resolves: if the person edited the message while\n * it was in flight, what they typed is newer than the send, and throwing\n * it away is the one outcome they cannot undo. So only the draft that was\n * sent is cleared.\n */\n if (draftKeyRef.current !== sent) return;\n setValue(\"\");\n paste.clear();\n });\n // Focus goes back to the text at once, as before: it stays editable while\n // a send is in flight, and the send button the person may have clicked is\n // about to be disabled.\n focusInput();\n }, [hasContent, disabled, sendDisabled, ack, value, inline, paste, onSubmit, composeOutgoing, setValue, focusInput]);\n\n const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {\n // Enter that confirms an IME composition finishes a word; it is not a send.\n if (e.key === \"Enter\" && !e.shiftKey && !isImeComposing(e)) {\n e.preventDefault();\n handleSubmit();\n }\n };\n\n /** Above the input: click a pill to dump it in, APPENDED to what is already typed. */\n const insertPaste = useCallback(\n (held: HeldPaste) => {\n // `expand` removes it and hands back the text; inserting is this\n // component's job because the hook owns no DOM.\n const text = pills.expand(held.id);\n\n if (text === undefined) return;\n\n setValue((current) => (current ? `${current}\\n\\n${text}` : text));\n textareaRef.current?.focus();\n },\n [pills, setValue],\n );\n\n return (\n <div\n data-react-fancy-composer=\"\"\n // Absent while idle; \"submitting\" while an onSubmit Promise is pending;\n // \"error\" after one was refused, until the next send.\n data-submit-state={ack.status === \"idle\" ? undefined : ack.status}\n ref={ref}\n className={cn(\n \"rounded-xl border border-[color:var(--fancy-border,var(--color-zinc-200))] bg-[color:var(--fancy-surface,var(--color-white))] dark:border-[color:var(--fancy-border-dark,var(--color-zinc-700))] dark:bg-[color:var(--fancy-surface-dark,var(--color-zinc-900))]\",\n disabled && \"opacity-50\",\n className,\n )}\n >\n {!inline && heldPastes.length > 0 && (\n <div\n data-react-fancy-composer-paste-pills=\"\"\n className=\"flex flex-wrap gap-1.5 px-3 pt-3\"\n >\n {heldPastes.map((paste) => (\n // `group` + `group-hover` rather than hover state: the preview is\n // presentation, and a useState for it would be one more thing an\n // agent could desynchronise.\n <span key={paste.id} className=\"group relative inline-flex\">\n <button\n type=\"button\"\n data-react-fancy-composer-paste-pill={paste.id}\n onClick={() => insertPaste(paste)}\n disabled={disabled}\n // No `title`. A native tooltip plus the hover preview below\n // renders TWO tooltips on the same hover, at different delays.\n // The label says what clicking does; the preview says what the\n // text is, and it is linked rather than merely adjacent so a\n // screen reader reaches it too.\n aria-label={`Insert this pasted text, ${withSeparators(paste.text.length)} characters`}\n aria-describedby={`${paste.id}-preview`}\n // `pr-7` leaves room for the absolutely-positioned × that sits\n // over this button's right edge. With a smaller right padding the\n // × lands on top of the label.\n className={cn(\n \"inline-flex items-center gap-1.5 rounded-full border border-[color:var(--fancy-border,var(--color-zinc-200))] bg-zinc-50 py-1 pl-2.5 pr-7 text-xs text-zinc-700 transition-colors hover:bg-zinc-100 dark:border-[color:var(--fancy-border-dark,var(--color-zinc-700))] dark:bg-zinc-800 dark:text-zinc-200 dark:hover:bg-zinc-700\",\n classNames?.pill,\n classNames?.textPill,\n )}\n >\n <span>\n Pasted text · {withSeparators(paste.text.length)} characters\n </span>\n </button>\n\n <button\n type=\"button\"\n data-react-fancy-composer-paste-pill-remove={paste.id}\n onClick={() => pills.remove(paste.id)}\n disabled={disabled}\n aria-label=\"Discard this pasted text\"\n className={cn(\n \"absolute right-1 top-1/2 -translate-y-1/2 rounded-full px-1 text-xs text-zinc-400 transition-colors hover:text-zinc-700 dark:hover:text-zinc-100\",\n classNames?.remove,\n )}\n >\n ×\n </button>\n\n <span\n id={`${paste.id}-preview`}\n data-react-fancy-composer-paste-pill-preview={paste.id}\n role=\"tooltip\"\n className={cn(\n \"pointer-events-none absolute bottom-full left-0 z-10 mb-1 hidden max-w-xs whitespace-pre-wrap break-all rounded-md bg-zinc-900 px-2 py-1 text-xs text-white group-hover:block dark:bg-zinc-100 dark:text-zinc-900\",\n classNames?.preview,\n )}\n >\n {renderPastePreview?.({ kind: \"text\", paste }) ??\n endsOf(paste.text, previewChars)}\n </span>\n </span>\n ))}\n </div>\n )}\n\n {inline ? (\n <>\n <InlinePasteEditor\n ref={editorRef}\n value={value}\n known={paste.known}\n onChange={handleInlineChange}\n onSubmit={handleSubmit}\n onPaste={paste.decidePaste}\n onHostsChange={paste.onHostsChange}\n placeholder={placeholder}\n disabled={disabled}\n id={inputId}\n className={classNames?.input}\n />\n {paste.portals}\n {/* A contenteditable is no form field, so `name` rides on a hidden\n one carrying what a send would carry: the expanded text. */}\n {name && <input type=\"hidden\" name={name} value={composeOutgoing()} />}\n {pasteImages && pasteFilesName && (\n <PastedFilesInput name={pasteFilesName} files={pastedFiles} />\n )}\n </>\n ) : (\n <textarea\n ref={textareaRef}\n // Its own handle, NOT the inline editor's `data-react-fancy-composer-input`:\n // that one already means \"the contenteditable is on\", and a host that\n // tests for it to pick a write path must not be handed a textarea.\n data-react-fancy-composer-textarea=\"\"\n id={inputId}\n name={name}\n value={value}\n onChange={(e) => setValue(e.target.value)}\n onKeyDown={handleKeyDown}\n onPaste={handlePaste}\n placeholder={placeholder}\n disabled={disabled}\n rows={3}\n className={cn(\n \"w-full resize-none bg-transparent px-4 pt-3 text-sm outline-none placeholder:text-zinc-400\",\n classNames?.input,\n )}\n />\n )}\n <div className=\"relative flex items-center justify-between border-t border-zinc-100 px-3 py-2 dark:border-zinc-800\">\n <div className=\"flex items-center gap-1\">{actions}</div>\n {ack.status === \"error\" && (\n // Mounted with its text, as `role=\"alert\"` is announced on insertion.\n <div\n role=\"alert\"\n data-react-fancy-composer-error=\"\"\n className=\"sr-only\"\n style={visuallyHidden}\n >\n {ack.message}\n </div>\n )}\n <button\n type=\"button\"\n data-react-fancy-composer-send=\"\"\n onClick={handleSubmit}\n disabled={disabled || sendDisabled || submitting || !hasContent}\n aria-busy={submitting || undefined}\n className=\"rounded-lg bg-zinc-900 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-zinc-800 disabled:opacity-50 dark:bg-white dark:text-zinc-900 dark:hover:bg-zinc-100\"\n >\n Send\n </button>\n </div>\n </div>\n );\n },\n);\n\nComposer.displayName = \"Composer\";\n"]}
|