@particle-academy/react-fancy 5.70.1 → 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.
Files changed (46) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +1 -0
  3. package/dist/autocomplete.cjs +3 -3
  4. package/dist/autocomplete.js +2 -2
  5. package/dist/chat-thread.cjs +27 -0
  6. package/dist/chat-thread.cjs.map +1 -0
  7. package/dist/chat-thread.d.cts +260 -0
  8. package/dist/chat-thread.d.ts +260 -0
  9. package/dist/chat-thread.js +14 -0
  10. package/dist/chat-thread.js.map +1 -0
  11. package/dist/chunk-5IE65DIH.cjs +623 -0
  12. package/dist/chunk-5IE65DIH.cjs.map +1 -0
  13. package/dist/{chunk-6QAF35AJ.cjs → chunk-7JLKOVHX.cjs} +3 -3
  14. package/dist/{chunk-6QAF35AJ.cjs.map → chunk-7JLKOVHX.cjs.map} +1 -1
  15. package/dist/{chunk-TCMOD3WW.js → chunk-A6NYZ3F5.js} +3 -3
  16. package/dist/{chunk-TCMOD3WW.js.map → chunk-A6NYZ3F5.js.map} +1 -1
  17. package/dist/{chunk-6EA3DSPT.cjs → chunk-FGOFG2UZ.cjs} +5 -5
  18. package/dist/{chunk-6EA3DSPT.cjs.map → chunk-FGOFG2UZ.cjs.map} +1 -1
  19. package/dist/{chunk-FHRVSYJU.cjs → chunk-MP55W37V.cjs} +3 -3
  20. package/dist/{chunk-FHRVSYJU.cjs.map → chunk-MP55W37V.cjs.map} +1 -1
  21. package/dist/{chunk-EZL5DILH.js → chunk-OPDHSOUN.js} +3 -3
  22. package/dist/{chunk-EZL5DILH.js.map → chunk-OPDHSOUN.js.map} +1 -1
  23. package/dist/chunk-UWYSJARZ.js +620 -0
  24. package/dist/chunk-UWYSJARZ.js.map +1 -0
  25. package/dist/{chunk-M6UAVA2I.js → chunk-XIA2UAVO.js} +3 -3
  26. package/dist/{chunk-M6UAVA2I.js.map → chunk-XIA2UAVO.js.map} +1 -1
  27. package/dist/editor.cjs +3 -3
  28. package/dist/editor.js +3 -3
  29. package/dist/index.cjs +170 -161
  30. package/dist/index.cjs.map +1 -1
  31. package/dist/index.d.cts +1 -0
  32. package/dist/index.d.ts +1 -0
  33. package/dist/index.js +26 -25
  34. package/dist/index.js.map +1 -1
  35. package/dist/input.cjs +1 -1
  36. package/dist/input.js +1 -1
  37. package/dist/inputs.cjs +7 -7
  38. package/dist/inputs.js +3 -3
  39. package/dist/json-editor.cjs +16 -16
  40. package/dist/json-editor.js +5 -5
  41. package/dist/prompt-input.cjs +1 -1
  42. package/dist/prompt-input.js +1 -1
  43. package/dist/select.cjs +3 -3
  44. package/dist/select.js +2 -2
  45. package/docs/ChatThread.md +318 -0
  46. package/package.json +11 -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@particle-academy/react-fancy",
3
- "version": "5.70.1",
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",