@hachej/boring-ui-kit 0.4.25 → 0.4.27

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 (101) hide show
  1. package/README.md +40 -3
  2. package/dist/chat-artifacts.d.ts +157 -0
  3. package/dist/chat-artifacts.js +274 -0
  4. package/dist/chat-dictation.d.ts +27 -0
  5. package/dist/chat-dictation.js +71 -0
  6. package/dist/chat-react-controller.d.ts +25 -0
  7. package/dist/chat-react-controller.js +72 -0
  8. package/dist/chat-react-conversations.d.ts +40 -0
  9. package/dist/chat-react-conversations.js +68 -0
  10. package/dist/chat-react-draft.d.ts +11 -0
  11. package/dist/chat-react-draft.js +91 -0
  12. package/dist/chat-react-remote.d.ts +33 -0
  13. package/dist/chat-react-remote.js +72 -0
  14. package/dist/chat-react-session.d.ts +170 -0
  15. package/dist/chat-react-session.js +367 -0
  16. package/dist/chat-react-transcript.d.ts +39 -0
  17. package/dist/chat-react-transcript.js +123 -0
  18. package/dist/chat-react.d.ts +62 -0
  19. package/dist/chat-react.js +95 -0
  20. package/dist/chat-text.d.ts +131 -0
  21. package/dist/chat-text.js +220 -0
  22. package/dist/chat-view.d.ts +2 -17
  23. package/dist/chat-view.js +1 -0
  24. package/dist/chat-voice-message.d.ts +65 -0
  25. package/dist/chat-voice-message.js +235 -0
  26. package/dist/chat-voice.d.ts +7 -0
  27. package/dist/chat-voice.js +4 -0
  28. package/dist/experience-catalog.d.ts +2 -2
  29. package/dist/experience-region-tree.d.ts +1 -1
  30. package/dist/markdown-editor-labels.d.ts +83 -0
  31. package/dist/markdown-editor-labels.js +75 -0
  32. package/dist/markdown-editor.d.ts +7 -1
  33. package/dist/markdown-editor.js +28 -18
  34. package/dist/markdown-extensions.d.ts +4 -2
  35. package/dist/markdown-extensions.js +2 -2
  36. package/dist/markdown-preserved.d.ts +5 -1
  37. package/dist/markdown-preserved.js +15 -4
  38. package/dist/markdown-proposal-diff.d.ts +3 -1
  39. package/dist/markdown-proposal-diff.js +6 -5
  40. package/dist/run-notifications.d.ts +42 -0
  41. package/dist/run-notifications.js +67 -0
  42. package/dist/shared-chat/index.d.ts +1 -1
  43. package/dist/shared-chat/index.js +5 -4
  44. package/dist/text-draft-controls.d.ts +5 -1
  45. package/dist/text-draft-controls.js +6 -5
  46. package/dist/text-draft-labels.d.ts +26 -0
  47. package/dist/text-draft-labels.js +47 -0
  48. package/dist/voice-attempt.d.ts +33 -0
  49. package/dist/voice-attempt.js +122 -0
  50. package/dist/voice-capture.d.ts +12 -0
  51. package/dist/voice-capture.js +17 -2
  52. package/package.json +14 -2
  53. package/r/agent-target.json +5 -9
  54. package/r/app-shell.json +19 -28
  55. package/r/board-viewer.json +8 -11
  56. package/r/button.json +2 -9
  57. package/r/diagram-viewer.json +0 -5
  58. package/r/dock.json +2 -2
  59. package/r/documents.json +13 -18
  60. package/r/feedback-preview.json +1 -7
  61. package/r/feedback.json +12 -19
  62. package/r/file-tree.json +4 -14
  63. package/r/highlights.json +4 -9
  64. package/r/html-viewer.json +3 -3
  65. package/r/looks-viewer.json +3 -6
  66. package/r/maquette-viewer.json +2 -20
  67. package/r/markdown-editor.json +4 -7
  68. package/r/mockup-viewer.json +2 -5
  69. package/r/pi-ambient.json +11 -19
  70. package/r/pi-app-kinds.json +10 -10
  71. package/r/pi-app.json +17 -27
  72. package/r/pi-artifacts.json +3 -4
  73. package/r/pi-chat-approval.json +2 -5
  74. package/r/pi-chat-attachments.json +2 -4
  75. package/r/pi-chat-markdown.json +3 -5
  76. package/r/pi-chat-mentions.json +2 -3
  77. package/r/pi-chat-pickers.json +2 -4
  78. package/r/pi-chat-plugins.json +11 -11
  79. package/r/pi-chat-question.json +1 -7
  80. package/r/pi-chat-slash.json +3 -6
  81. package/r/pi-chat-subagent.json +3 -8
  82. package/r/pi-chat-voice.json +7 -10
  83. package/r/pi-chat.json +25 -53
  84. package/r/pi-workspace.json +4 -7
  85. package/r/placed-agent.json +4 -5
  86. package/r/provider-setup.json +2 -8
  87. package/r/record-viewer.json +0 -9
  88. package/r/registry.json +182 -403
  89. package/r/requests-board.json +2 -10
  90. package/r/shared-chat.json +10 -24
  91. package/r/task-list-viewer.json +3 -9
  92. package/r/utils.json +14 -2
  93. package/r/version-history.json +3 -5
  94. package/r/viewer-code.json +2 -3
  95. package/r/viewer-frame.json +9 -17
  96. package/r/viewer-html.json +4 -5
  97. package/r/viewer-image.json +3 -4
  98. package/r/viewer-markdown.json +6 -8
  99. package/r/viewer-pdf.json +5 -7
  100. package/r/viewers.json +6 -6
  101. package/r/windows.json +3 -8
@@ -0,0 +1,367 @@
1
+ 'use client';
2
+ import { useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
3
+ import { derive, isFileBlock, object, queuedMessages, waitingForAnswer as waitsForAnswer } from './chat-view.js';
4
+ import { appendToDraft, displayName, kindMentionDraft, looksLikeMarkdown, mentionKind, mentionToken, pieces, prependToDraft, withoutKindMentions, writeKindMentionDraft } from './chat-text.js';
5
+ /** Headless session over the public native controller snapshot/commands. Borrows it; cleanup only aborts this hook's uploads. */
6
+ export function useChatSession(options) {
7
+ const { controller, activeController: active, mode, presentation, actions, renderEntry, hideLine, renderTool: catchAll, toolRenderers, groupTool, subagent, onOpenImage, onFiles, mentions, slash, attachments, model, effort, artifacts, afterSend, feedback, onOpenFile, onPickFiles, commandMentions, onFocus, labels } = options;
8
+ const state = useSyncExternalStore(controller.subscribe, controller.getSnapshot, controller.getSnapshot);
9
+ const [error, setError] = useState(null);
10
+ const [uploading, setUploading] = useState(0);
11
+ const [picked, setPicked] = useState([]);
12
+ const [pending, setPending] = useState([]);
13
+ const [changing, setChanging] = useState(null);
14
+ const [changeError, setChangeError] = useState(null);
15
+ const [optimistic, setOptimistic] = useState({});
16
+ const uploadIds = useRef(0);
17
+ const uploads = useRef(new Set()), mounted = useRef(false);
18
+ const current = () => mounted.current && active.current === controller && !controller.getSnapshot().disposed;
19
+ // The latest text, for a connection failure that lands after the labels changed.
20
+ const latestLabels = useRef(labels);
21
+ useLayoutEffect(() => { latestLabels.current = labels; });
22
+ useEffect(() => {
23
+ mounted.current = true;
24
+ const live = uploads.current;
25
+ // A controller disposed before mount throws synchronously; the chat then renders its disposed state.
26
+ if (!controller.getSnapshot().disposed)
27
+ void controller.connect().catch(cause => {
28
+ if (mounted.current && active.current === controller && !controller.getSnapshot().disposed)
29
+ setError(cause instanceof Error ? cause.message : latestLabels.current.connectionFailed);
30
+ });
31
+ return () => { mounted.current = false; for (const upload of live)
32
+ upload.abort(); live.clear(); };
33
+ }, [controller, active]);
34
+ const natural = presentation === 'natural';
35
+ // A tool's own renderer comes first; `renderTool` stays the catch-all.
36
+ const renderTool = useMemo(() => toolRenderers ? (call, result) => (Object.hasOwn(toolRenderers, call.name) ? toolRenderers[call.name]?.(call, result) : undefined) ?? catchAll?.(call, result) : catchAll, [toolRenderers, catchAll]);
37
+ const developer = mode === 'developer' && !natural;
38
+ // Only the recogniser takes part in deriving rows; which card is open changes without rebuilding them.
39
+ const recognise = artifacts?.recognise, detect = artifacts?.detect;
40
+ const artifactDetect = useMemo(() => recognise ? { recognise, detect } : undefined, [recognise, detect]);
41
+ const derived = useMemo(() => derive(state.view, { mode, presentation, hideLine, renderEntry, renderTool, groupTool, artifacts: artifactDetect, subagent }), [state.view, mode, presentation, hideLine, renderEntry, renderTool, groupTool, artifactDetect, subagent]);
42
+ const queued = useMemo(() => queuedMessages(state.view), [state.view]);
43
+ const { run } = derived;
44
+ const earlier = useMemo(() => {
45
+ if (!state.view || !('entries' in state.history))
46
+ return [];
47
+ const activeIds = new Set(state.view.entries.map(entry => entry.id));
48
+ const entries = state.history.entries.filter(entry => !activeIds.has(entry.id));
49
+ return derive({ ...state.view, entries, docs: {} }, { mode, presentation, hideLine, renderEntry: undefined, renderTool: undefined, groupTool: undefined, artifacts: artifactDetect, subagent }).rows
50
+ .map(row => ({ ...row, key: `history:${row.key}`, historical: true }));
51
+ }, [state.view, state.history, mode, presentation, hideLine, artifactDetect, subagent]);
52
+ const rows = useMemo(() => [...earlier, ...derived.rows], [earlier, derived.rows]);
53
+ const files = useMemo(() => {
54
+ const found = new Map();
55
+ const open = onOpenFile ?? mentions?.open;
56
+ const add = (path) => {
57
+ if (mentionKind(path, mentions))
58
+ return;
59
+ const normalized = path.replace(/^\.\//, '');
60
+ const id = `file:${normalized}`;
61
+ if (!found.has(id))
62
+ found.set(id, { id, name: displayName(normalized), open: open ? () => open(normalized) : undefined });
63
+ };
64
+ for (const path of picked)
65
+ add(path);
66
+ for (const item of state.draft.attachments)
67
+ found.set(`draft:${item.id}`, { id: `draft:${item.id}`, name: item.name, open: onOpenImage ? () => onOpenImage(item.content) : undefined });
68
+ for (const row of derived.rows) {
69
+ if (row.type === 'user') {
70
+ const content = typeof row.message.content === 'string' ? [{ type: 'text', text: row.message.content }] : row.message.content;
71
+ for (const [index, part] of content.entries()) {
72
+ if (part.type === 'text' && !isFileBlock(part.text))
73
+ for (const piece of pieces(part.text, { mentions: true, skills: [] })) {
74
+ if (piece.kind === 'mention' && piece.value)
75
+ add(piece.value);
76
+ }
77
+ if (part.type === 'image') {
78
+ const id = `image:${row.key}:${index}`;
79
+ found.set(id, { id, name: labels.imageAttachment, open: onOpenImage ? () => onOpenImage(part) : undefined });
80
+ }
81
+ }
82
+ }
83
+ if (row.type !== 'assistant')
84
+ continue;
85
+ for (const part of row.parts) {
86
+ if (part.kind === 'artifact' && part.artifact.state === 'ready' && part.artifact.path !== undefined) {
87
+ const descriptor = part.artifact.descriptor, id = `file:${part.artifact.path.replace(/^\.\//, '')}`;
88
+ found.set(id, { id, name: displayName(part.artifact.path), open: artifacts?.open ? () => artifacts.open?.(descriptor) : undefined });
89
+ }
90
+ if (!natural && part.kind === 'tool' && part.entry.result && !part.entry.result.isError && ['read', 'write', 'edit'].includes(part.entry.call.name)) {
91
+ const path = part.entry.call.arguments['path'];
92
+ if (typeof path === 'string')
93
+ add(path);
94
+ }
95
+ }
96
+ }
97
+ return [...found.values()];
98
+ }, [derived.rows, natural, picked, state.draft.attachments, artifacts, onOpenFile, mentions, onOpenImage, labels.imageAttachment]);
99
+ const report = (cause) => { if (current())
100
+ setError(cause instanceof Error ? cause.message : labels.actionFailedShort); };
101
+ const act = (action) => {
102
+ setError(null);
103
+ try {
104
+ void action().catch(report);
105
+ }
106
+ catch (cause) {
107
+ report(cause);
108
+ }
109
+ };
110
+ // A send being confirmed does not block the next one: the controller empties the composer at once and queues the next message behind it.
111
+ // Only a submission whose outcome is unknown blocks, until it is checked or retried.
112
+ const unconfirmed = state.send.kind === 'unknown';
113
+ const connected = state.connection.kind === 'connected';
114
+ const sendBlocked = uploading > 0 || state.disposed || !connected || unconfirmed;
115
+ /** Puts a message back into the composer ahead of whatever is there now (nothing is dropped). */
116
+ const putBack = (text, images) => {
117
+ if (controller.getSnapshot().disposed)
118
+ return;
119
+ const draft = controller.getSnapshot().draft;
120
+ controller.setText(prependToDraft(draft.text, withoutKindMentions(text, mentions)));
121
+ if (images.length)
122
+ controller.setAttachments([...images, ...draft.attachments.filter(item => !images.some(image => image.id === item.id))]);
123
+ };
124
+ const working = Boolean(run);
125
+ const waitingForAnswer = waitsForAnswer(derived.rows);
126
+ const send = () => {
127
+ if (sendBlocked || !current())
128
+ return;
129
+ const attach = feedback && feedback.count > 0 ? feedback.attach : undefined;
130
+ if (attach) {
131
+ // The pending feedback goes with this message: the host attaches it to the text, then the native send reads the draft.
132
+ act(async () => {
133
+ // Like any send, the composer empties at once; the person's own text comes back if the feedback or the message is refused.
134
+ const typed = controller.getSnapshot().draft;
135
+ controller.setText('');
136
+ controller.setAttachments([]);
137
+ let attached;
138
+ try {
139
+ attached = await attach(typed.text);
140
+ }
141
+ catch (cause) {
142
+ putBack(typed.text, typed.attachments);
143
+ throw cause;
144
+ }
145
+ if (attached.kind === 'refused') {
146
+ putBack(typed.text, typed.attachments);
147
+ throw new Error(attached.reason);
148
+ }
149
+ if (!current()) {
150
+ putBack(typed.text, typed.attachments);
151
+ return;
152
+ }
153
+ try {
154
+ const submission = await controller.send('followUp', { text: attached.text, attachments: typed.attachments, restore: { text: withoutKindMentions(typed.text, mentions), attachments: typed.attachments } });
155
+ if (submission)
156
+ feedback?.sent();
157
+ }
158
+ catch (cause) {
159
+ putBack(typed.text, typed.attachments);
160
+ throw cause;
161
+ }
162
+ });
163
+ afterSend?.();
164
+ onFocus?.();
165
+ return;
166
+ }
167
+ const typed = controller.getSnapshot().draft;
168
+ const words = withoutKindMentions(typed.text, mentions);
169
+ if (words !== typed.text) {
170
+ controller.setText('');
171
+ controller.setAttachments([]);
172
+ act(async () => {
173
+ try {
174
+ await controller.send('followUp', { text: typed.text, attachments: typed.attachments, restore: { text: words, attachments: typed.attachments } });
175
+ }
176
+ catch (cause) {
177
+ putBack(words, typed.attachments);
178
+ throw cause;
179
+ }
180
+ });
181
+ }
182
+ else
183
+ act(() => controller.send('followUp'));
184
+ afterSend?.();
185
+ onFocus?.();
186
+ };
187
+ // A queued message is taken back (withdrawn) before it is edited or steered; if Pi already placed it, that is reported and nothing is lost.
188
+ async function takeBack(item) {
189
+ if (!actions?.withdraw)
190
+ throw new Error(labels.queueLocked);
191
+ const outcome = await actions.withdraw(item.id);
192
+ if (outcome === 'already_placed' || outcome === 'settled')
193
+ throw new Error(labels.tooLate);
194
+ }
195
+ const queuedContent = (item) => {
196
+ const parts = Array.isArray(item.content) ? item.content : [];
197
+ const text = typeof item.content === 'string' ? item.content : parts.flatMap(part => part?.type === 'text' && typeof part.text === 'string' && !isFileBlock(part.text) ? [part.text] : []).join('\n');
198
+ const images = parts.flatMap((part, index) => part?.type === 'image' && typeof part.data === 'string' && typeof part.mimeType === 'string'
199
+ ? [{ id: `queued-${String(item.id)}-${index}`, name: `image-${index + 1}`, content: { type: 'image', data: part.data, mimeType: part.mimeType } }] : []);
200
+ return { text, images };
201
+ };
202
+ const queueActions = actions?.withdraw ? {
203
+ edit: async (item) => {
204
+ await takeBack(item);
205
+ const { text, images } = queuedContent(item), draft = controller.getSnapshot().draft;
206
+ controller.setText(prependToDraft(draft.text, text));
207
+ if (images.length)
208
+ controller.setAttachments([...draft.attachments, ...images]);
209
+ onFocus?.();
210
+ },
211
+ steer: async (item) => {
212
+ if (sendBlocked)
213
+ throw new Error(labels.waitToSteer);
214
+ await takeBack(item);
215
+ const { text, images } = queuedContent(item);
216
+ // Sent as explicit content, so the person's own draft is never touched; a refused steer comes back into the composer (the controller
217
+ // puts it back), an unknown one is held for checking.
218
+ try {
219
+ const submission = await controller.send('steer', { text, attachments: images });
220
+ if (!submission && current() && controller.getSnapshot().send.kind === 'blocked')
221
+ setError(labels.steerRefused);
222
+ }
223
+ catch (cause) {
224
+ putBack(text, images);
225
+ if (current())
226
+ setError(cause instanceof Error ? cause.message : labels.steerFailed);
227
+ }
228
+ },
229
+ } : undefined;
230
+ const addMentions = (paths) => {
231
+ const text = controller.getSnapshot().draft.text;
232
+ controller.setText(`${appendToDraft(text, paths.map(mentionToken).join(' '))} `);
233
+ setPicked(value => [...new Set([...value, ...paths])]);
234
+ };
235
+ /** Uploads files; `restore` (a pasted text) is what their pending cards undo: it abandons the upload and puts the text back. */
236
+ async function upload(files, paste) {
237
+ if (!files.length || !current())
238
+ return;
239
+ if (onPickFiles) {
240
+ onPickFiles(files);
241
+ return;
242
+ }
243
+ if (!onFiles && !attachments)
244
+ return;
245
+ const abort = new AbortController();
246
+ uploads.current.add(abort);
247
+ setUploading(value => value + 1);
248
+ const ids = files.map(() => `upload-${++uploadIds.current}`);
249
+ const settle = (failure) => setPending(items => failure === undefined ? items.filter(item => !ids.includes(item.id))
250
+ : items.map(item => ids.includes(item.id) ? { ...item, state: 'failed', error: failure } : item));
251
+ const cancel = paste && (() => { abort.abort(); setPending(items => items.filter(item => !ids.includes(item.id))); paste.restore(); });
252
+ setPending(items => [...items, ...files.map((file, index) => ({ id: ids[index], name: file.name, state: 'uploading',
253
+ ...(cancel && paste ? { cancel, restoreWhile: paste.after } : {}) }))]);
254
+ try {
255
+ let added = [];
256
+ let paths = [];
257
+ if (attachments) {
258
+ for (const result of await attachments.upload([...files], abort.signal)) {
259
+ if (result.image)
260
+ added = [...added, { id: `image-${Date.now()}-${++uploadIds.current}`, name: result.name, content: { type: 'image', data: result.image.data, mimeType: result.image.mimeType } }];
261
+ else if (result.path)
262
+ paths = [...paths, result.path];
263
+ }
264
+ }
265
+ else
266
+ added = await onFiles(files, { identity: state.identity, conversationId: state.conversationId, signal: abort.signal });
267
+ if (current() && !abort.signal.aborted) {
268
+ if (added.length)
269
+ controller.setAttachments([...controller.getSnapshot().draft.attachments, ...added]);
270
+ if (paths.length)
271
+ addMentions(paths);
272
+ settle();
273
+ }
274
+ }
275
+ catch (cause) {
276
+ if (!abort.signal.aborted) {
277
+ settle(cause instanceof Error ? cause.message : labels.uploadFailedShort);
278
+ if (!attachments)
279
+ report(cause);
280
+ }
281
+ }
282
+ finally {
283
+ uploads.current.delete(abort);
284
+ if (current())
285
+ setUploading(value => value - 1);
286
+ }
287
+ }
288
+ /** A paste the composer took: saved as a file under `uploads/` (Send then references it). Cancelling puts the text back where it was pasted. */
289
+ const pasteText = (paste) => {
290
+ if (!attachments || !current())
291
+ return;
292
+ const at = new Date(), two = (value) => String(value).padStart(2, '0');
293
+ const stamp = `${at.getFullYear()}-${two(at.getMonth() + 1)}-${two(at.getDate())} ${two(at.getHours())}-${two(at.getMinutes())}-${two(at.getSeconds())}`;
294
+ const markdown = looksLikeMarkdown(paste.text);
295
+ const file = new File([paste.text], `${labels.pastedFileName(stamp)}${markdown ? '.md' : '.txt'}`, { type: markdown ? 'text/markdown' : 'text/plain' });
296
+ // The pasted text replaces its selection when the paste is accepted, so the draft shows what is left. Restoring inserts the text at
297
+ // the same place and deletes nothing: whatever was typed since stays.
298
+ const draft = kindMentionDraft(controller.getSnapshot().draft.text, mentions);
299
+ const start = Math.max(0, Math.min(paste.start, draft.text.length)), end = Math.min(Math.max(paste.end, start), draft.text.length);
300
+ const after = writeKindMentionDraft(`${draft.text.slice(0, start)}${draft.text.slice(end)}`, draft.tokens, slash?.skills?.map(skill => skill.name));
301
+ controller.setText(after);
302
+ const restore = () => {
303
+ if (!current())
304
+ return;
305
+ const now = kindMentionDraft(controller.getSnapshot().draft.text, mentions);
306
+ const at = Math.min(start, now.text.length);
307
+ controller.setText(writeKindMentionDraft(`${now.text.slice(0, at)}${paste.text}${now.text.slice(at)}`, now.tokens, slash?.skills?.map(skill => skill.name)));
308
+ onFocus?.();
309
+ };
310
+ void upload([file], { restore, after });
311
+ };
312
+ const agentDoc = object(state.view?.docs['pi.agent']);
313
+ const currentModel = object(agentDoc?.['model']);
314
+ const viewModel = typeof currentModel?.['provider'] === 'string' && typeof currentModel['modelId'] === 'string' ? { provider: currentModel['provider'], modelId: currentModel['modelId'] } : undefined;
315
+ const viewEffort = typeof agentDoc?.['thinkingLevel'] === 'string' ? agentDoc['thinkingLevel'] : undefined;
316
+ // Show the accepted choice until the watch delivers it, so the label does not flicker back.
317
+ useEffect(() => {
318
+ const same = optimistic.model && viewModel && optimistic.model.provider === viewModel.provider && optimistic.model.modelId === viewModel.modelId;
319
+ // eslint-disable-next-line react-hooks/set-state-in-effect -- drops the optimistic choice once the watch delivers it; runs only when it matches, so it settles
320
+ if (same || (optimistic.effort !== undefined && optimistic.effort === viewEffort))
321
+ setOptimistic(value => ({ ...(same ? {} : value.model ? { model: value.model } : {}), ...(optimistic.effort === viewEffort ? {} : value.effort ? { effort: value.effort } : {}) }));
322
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- viewModel is rebuilt every render; its provider and modelId are what it compares
323
+ }, [optimistic, viewModel?.provider, viewModel?.modelId, viewEffort]);
324
+ // eslint-disable-next-line react-hooks/set-state-in-effect -- cleared text drops the picked mentions once, after commit; the updater keeps an empty list as is
325
+ useEffect(() => { if (!state.draft.text)
326
+ setPicked(value => value.length ? [] : value); }, [state.draft.text]);
327
+ async function change(kind, apply, accepted, rejected) {
328
+ if (changing || !current())
329
+ return;
330
+ setChanging(kind);
331
+ setChangeError(null);
332
+ try {
333
+ await apply();
334
+ if (current())
335
+ accepted();
336
+ }
337
+ catch (cause) {
338
+ if (current()) {
339
+ rejected();
340
+ setChangeError(cause instanceof Error && cause.message ? cause.message : labels.changeRefused);
341
+ }
342
+ }
343
+ finally {
344
+ if (current())
345
+ setChanging(null);
346
+ }
347
+ }
348
+ const activateMention = (command) => {
349
+ if (!current() || controller.getSnapshot().connection.kind !== 'connected')
350
+ return;
351
+ const catalog = commandMentions;
352
+ const selected = catalog?.commands.find(item => item.name === command.name && item.behavior === command.behavior);
353
+ if (catalog && selected)
354
+ act(async () => catalog.onActivate({ name: selected.name, behavior: selected.behavior }));
355
+ };
356
+ const empty = connected && !state.view?.entries.length && !rows.length;
357
+ const loading = (state.connection.kind === 'idle' || state.connection.kind === 'connecting') && !state.view;
358
+ return { state, derived, rows, queued, queueActions, working, waitingForAnswer, connected, sendBlocked, error, setError, report, act, current,
359
+ files, empty, loading, developer, natural, send, putBack, upload, pasteText, picked, pending, uploading, activateMention,
360
+ pickMention: (path) => setPicked(value => value.includes(path) ? value : [...value, path]),
361
+ dismissUpload: (id) => setPending(items => items.filter(item => item.id !== id)),
362
+ currentModel: optimistic.model ?? viewModel, currentEffort: optimistic.effort ?? viewEffort,
363
+ changing, changeError, dismissChangeError: () => setChangeError(null),
364
+ changeModel: (next) => change('model', () => model.change(next), () => setOptimistic(value => ({ ...value, model: next })), () => { }),
365
+ changeEffort: (next) => change('effort', () => effort.change(next), () => setOptimistic(value => ({ ...value, effort: next })), () => { }),
366
+ };
367
+ }
@@ -0,0 +1,39 @@
1
+ import type { ChatHistoryState } from './native-chat.js';
2
+ export interface ChatPaging {
3
+ readonly first?: number;
4
+ readonly more?: number;
5
+ }
6
+ export declare const PAGE_FIRST = 60, PAGE_MORE = 40, CHUNK = 20;
7
+ /**
8
+ * Rows grouped into runs of at most `size` keys, kept stable across renders. Rows already placed never change run: rows revealed
9
+ * above form new runs (the run nearest the old top is the full one), rows added below fill the last run, then start new ones.
10
+ */
11
+ export declare function chunkKeys(previous: readonly (readonly string[])[], keys: readonly string[], size?: number): string[][];
12
+ /**
13
+ * The scroller measures every one of its items on each scroll event and frame (@shadcn/react 0.3.1), so a long transcript gives it
14
+ * runs of `CHUNK` rows instead of single rows: 500 messages are 25 measurements, not 500. A run keeps its element while rows are
15
+ * revealed above it, so the scroller's prepend anchor still holds; a run's array is reused while its rows are unchanged.
16
+ */
17
+ export declare function useRowChunks<T extends {
18
+ readonly key: string;
19
+ }>(rows: readonly T[], size?: number): readonly (readonly T[])[];
20
+ /** One scroll root for current and archived rows. Native paging stays in the borrowed controller. */
21
+ export declare function useTranscriptWindow(rows: readonly {
22
+ readonly key: string;
23
+ }[], page?: number, more?: number, history?: {
24
+ readonly state: ChatHistoryState;
25
+ readonly connected: boolean;
26
+ readonly load: () => Promise<void>;
27
+ }, automatic?: boolean): {
28
+ scrollRef: import("react").Dispatch<import("react").SetStateAction<HTMLDivElement | null>>;
29
+ hidden: number;
30
+ reveal: () => void;
31
+ revealTo: (key: string) => void;
32
+ sentinelRef: import("react").Dispatch<import("react").SetStateAction<HTMLDivElement | null>>;
33
+ loaded: number;
34
+ visible: <T>(items: readonly T[]) => readonly T[];
35
+ reset: () => void;
36
+ };
37
+ /** Compatibility spelling for headless consumers; presentation supplies the actual scroll owner. */
38
+ export declare const useTranscript: typeof useTranscriptWindow;
39
+ //# sourceMappingURL=chat-react-transcript.d.ts.map
@@ -0,0 +1,123 @@
1
+ 'use client';
2
+ import { startTransition, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
3
+ export const PAGE_FIRST = 60, PAGE_MORE = 40, CHUNK = 20;
4
+ /**
5
+ * Rows grouped into runs of at most `size` keys, kept stable across renders. Rows already placed never change run: rows revealed
6
+ * above form new runs (the run nearest the old top is the full one), rows added below fill the last run, then start new ones.
7
+ */
8
+ export function chunkKeys(previous, keys, size = CHUNK) {
9
+ const owner = new Map();
10
+ previous.forEach((run, index) => { for (const key of run)
11
+ owner.set(key, index); });
12
+ const known = keys.findIndex(key => owner.has(key));
13
+ const lead = known < 0 ? keys.length : known;
14
+ const runs = [];
15
+ for (let end = lead; end > 0; end -= size)
16
+ runs.unshift(keys.slice(Math.max(0, end - size), end));
17
+ let current = [], from;
18
+ for (const key of keys.slice(lead)) {
19
+ const placed = owner.get(key);
20
+ if (!current.length || (placed !== undefined ? placed !== from : current.length >= size)) {
21
+ current = [];
22
+ runs.push(current);
23
+ from = placed;
24
+ }
25
+ current.push(key);
26
+ }
27
+ return runs;
28
+ }
29
+ /**
30
+ * The scroller measures every one of its items on each scroll event and frame (@shadcn/react 0.3.1), so a long transcript gives it
31
+ * runs of `CHUNK` rows instead of single rows: 500 messages are 25 measurements, not 500. A run keeps its element while rows are
32
+ * revealed above it, so the scroller's prepend anchor still holds; a run's array is reused while its rows are unchanged.
33
+ */
34
+ export function useRowChunks(rows, size = CHUNK) {
35
+ // A derived cache of the last runs, read and replaced inside the memo only: the same rows always give the same runs.
36
+ const previous = useRef({ keys: [], runs: new Map() });
37
+ return useMemo(() => {
38
+ const byKey = new Map(rows.map(row => [row.key, row]));
39
+ // eslint-disable-next-line react-hooks/refs -- reads the run cache to keep run boundaries and arrays stable; no setState, no render loop
40
+ const keys = chunkKeys(previous.current.keys, rows.map(row => row.key), size);
41
+ // eslint-disable-next-line react-hooks/refs -- the mapper reuses the cached array of an unchanged run (see above)
42
+ const runs = keys.map(run => {
43
+ const items = run.map(key => byKey.get(key));
44
+ const before = previous.current.runs.get(run[0]);
45
+ return before && before.length === items.length && before.every((item, index) => item === items[index]) ? before : items;
46
+ });
47
+ // eslint-disable-next-line react-hooks/refs -- stores the runs for the next rows; a cache write, not state, so it triggers no render
48
+ previous.current = { keys, runs: new Map(runs.map(run => [run[0].key, run])) };
49
+ return runs;
50
+ }, [rows, size]);
51
+ }
52
+ /** One scroll root for current and archived rows. Native paging stays in the borrowed controller. */
53
+ export function useTranscriptWindow(rows, page = PAGE_FIRST, more = PAGE_MORE, history, automatic = true) {
54
+ if (!Number.isSafeInteger(page) || page < 1)
55
+ throw new TypeError('Chat displayLimit must be a positive integer');
56
+ if (!Number.isSafeInteger(more) || more < 1)
57
+ throw new TypeError('Chat paging.more must be a positive integer');
58
+ const [limit, setLimit] = useState(page);
59
+ const [sentinel, setSentinel] = useState(null);
60
+ const [loaded, setLoaded] = useState(0);
61
+ const pending = useRef(false);
62
+ const anchor = useRef(undefined);
63
+ const [scroller, setScroller] = useState(null);
64
+ const hidden = Math.max(0, rows.length - limit);
65
+ const historyState = history?.state, historyConnected = history?.connected, historyLoad = history?.load;
66
+ const reveal = useCallback(() => {
67
+ if (pending.current || anchor.current)
68
+ return;
69
+ const state = historyState;
70
+ if (hidden === 0 && (!historyConnected || !state || state.kind === 'disabled' || state.kind === 'loading' || state.kind === 'ready' && !state.hasMore))
71
+ return;
72
+ anchor.current = { length: rows.length, limit };
73
+ // A revealed page renders as a transition: React mounts it in small slices, so scrolling stays responsive meanwhile.
74
+ if (hidden > 0)
75
+ startTransition(() => setLimit(value => value + more));
76
+ else if (historyLoad) {
77
+ pending.current = true;
78
+ void Promise.resolve().then(historyLoad).catch(() => { anchor.current = undefined; }).finally(() => { pending.current = false; });
79
+ }
80
+ }, [hidden, historyState, historyConnected, historyLoad, rows.length, limit, more]);
81
+ useLayoutEffect(() => {
82
+ const saved = anchor.current;
83
+ if (!scroller || !saved || history?.state.kind === 'loading')
84
+ return;
85
+ const added = Math.max(0, rows.length - saved.length);
86
+ if (limit < saved.limit + added) {
87
+ startTransition(() => setLimit(saved.limit + added));
88
+ return;
89
+ }
90
+ anchor.current = undefined;
91
+ setLoaded(count => count + Math.max(0, Math.min(rows.length, limit) - Math.min(saved.length, saved.limit)));
92
+ }, [rows, limit, scroller, history?.state]);
93
+ // Automatic paging: the sentinel near the top, once the reader has been away from the top (so the mount, which starts at the
94
+ // top before the scroller settles at the end, loads nothing). A fast scroll lands on 0 before the sentinel is seen: the scroll
95
+ // listener and every new observer (after each page) check again, instead of the reader's distance at that one moment.
96
+ const near = useRef(false), away = useRef(false), revealNow = useRef(reveal);
97
+ useLayoutEffect(() => { revealNow.current = reveal; });
98
+ useEffect(() => {
99
+ if (!automatic || !scroller)
100
+ return;
101
+ const onScroll = () => { if (scroller.scrollTop > 8)
102
+ away.current = true; if (near.current && away.current)
103
+ revealNow.current(); };
104
+ scroller.addEventListener('scroll', onScroll, { passive: true });
105
+ return () => scroller.removeEventListener('scroll', onScroll);
106
+ }, [automatic, scroller]);
107
+ useEffect(() => {
108
+ if (!automatic || !scroller || !sentinel || typeof IntersectionObserver === 'undefined' || history?.state.kind === 'loading' || history?.state.kind === 'error')
109
+ return;
110
+ const observer = new IntersectionObserver(entries => {
111
+ near.current = entries.at(-1)?.isIntersecting ?? near.current;
112
+ if (near.current && (scroller.scrollHeight <= scroller.clientHeight || away.current))
113
+ reveal();
114
+ }, { root: scroller, rootMargin: '240px 0px 0px' });
115
+ observer.observe(sentinel);
116
+ return () => { observer.disconnect(); near.current = false; };
117
+ }, [automatic, scroller, sentinel, reveal, history?.state]);
118
+ const revealTo = (key) => { const index = rows.findIndex(row => row.key === key); if (index >= 0)
119
+ setLimit(value => Math.max(value, rows.length - index)); };
120
+ return { scrollRef: setScroller, hidden, reveal, revealTo, sentinelRef: setSentinel, loaded, visible: (items) => hidden ? items.slice(hidden) : items, reset: () => { anchor.current = undefined; setLimit(page); } };
121
+ }
122
+ /** Compatibility spelling for headless consumers; presentation supplies the actual scroll owner. */
123
+ export const useTranscript = useTranscriptWindow;
@@ -0,0 +1,62 @@
1
+ import type { ChatController } from './native-chat.js';
2
+ import type { ArtifactDescriptor, ArtifactDetect, PresentAcksHost } from './chat-view.js';
3
+ export { useChatDraft } from './chat-react-draft.js';
4
+ export { useControllerPerConversation } from './chat-react-controller.js';
5
+ export type { ControllerPerConversationOptions, ConversationControllerState } from './chat-react-controller.js';
6
+ export { chunkKeys, useRowChunks, useTranscript, useTranscriptWindow, PAGE_FIRST, PAGE_MORE, CHUNK } from './chat-react-transcript.js';
7
+ export type { ChatPaging } from './chat-react-transcript.js';
8
+ export { useChatPlacement, readChatPlacement, useRemoteChat } from './chat-react-remote.js';
9
+ export type { ChatPlacement, RemoteChatState, RemoteChatHookOptions } from './chat-react-remote.js';
10
+ export { useConversationsClient, useConversations } from './chat-react-conversations.js';
11
+ export type { ConversationItem, ConversationsClient, ConversationsClientOptions } from './chat-react-conversations.js';
12
+ import type { NotificationStore, RunNotificationOptions, RunNotificationSource } from './run-notifications.js';
13
+ export { createNotificationStore, watchConversation } from './run-notifications.js';
14
+ export type { AgentNotification, NewNotification, NotificationKind, NotificationStore, RunNotificationOptions, RunNotificationSource } from './run-notifications.js';
15
+ /** The foreground adapter of watchConversation, with the same connected-only transition rules and borrowed lifecycle. */
16
+ export declare function useRunNotifications(controller: RunNotificationSource, store: NotificationStore, options: RunNotificationOptions): void;
17
+ /** Selects the authorized native view using the controller's own cached snapshot and subscription. Borrows its lifetime. */
18
+ export declare function useChatView<T>(controller: ChatController | undefined, select: (view: ReturnType<ChatController['getSnapshot']>['view']) => T): T;
19
+ export interface ArtifactSelection {
20
+ readonly descriptor: ArtifactDescriptor;
21
+ readonly follow: boolean;
22
+ }
23
+ export interface ArtifactShown {
24
+ readonly path: string;
25
+ readonly revision: string;
26
+ readonly hidden?: true;
27
+ }
28
+ export interface ArtifactFailed {
29
+ readonly path: string;
30
+ readonly revision: string;
31
+ readonly reason: string;
32
+ }
33
+ export interface ArtifactFollowOptions {
34
+ readonly controller: ChatController | undefined;
35
+ readonly conversationId?: string | undefined;
36
+ readonly detect?: ArtifactDetect | undefined;
37
+ readonly active: ArtifactSelection | null;
38
+ readonly onOpen: (selection: ArtifactSelection) => void;
39
+ readonly autoOpen?: boolean | undefined;
40
+ /** Host presentation guards are read when an effect or action considers opening, never during render. */
41
+ readonly unsaved?: (() => boolean) | undefined;
42
+ readonly narrow?: (() => boolean) | undefined;
43
+ readonly showing: (path: string) => boolean;
44
+ readonly openPresent: (path: string) => boolean;
45
+ readonly answer?: PresentAcksHost['answer'] | undefined;
46
+ }
47
+ /** One version/follow/auto-open and present-ack rule for every chat surface. Mount evidence must come from the viewer. */
48
+ export declare function useArtifactFollow(options: ArtifactFollowOptions): {
49
+ versions: ArtifactDescriptor[];
50
+ turn: number;
51
+ known: ArtifactDescriptor[];
52
+ shown: ArtifactDescriptor | undefined;
53
+ open: (descriptor: ArtifactDescriptor) => void;
54
+ isOpen: (descriptor: ArtifactDescriptor) => boolean;
55
+ select: (version: ArtifactDescriptor | "latest") => void;
56
+ recordClosed: () => void;
57
+ onShown: (shown: ArtifactShown) => void;
58
+ onFailed: (failed: ArtifactFailed) => void;
59
+ };
60
+ export { useChatSession } from './chat-react-session.js';
61
+ export type { ChatSession, ChatSessionOptions, ChatSessionLabels, ChatSessionFeedback, ConversationFile, ModelRef, PastedText, UploadResult, PendingUpload, FilesHandler, QueueActions } from './chat-react-session.js';
62
+ //# sourceMappingURL=chat-react.d.ts.map