@hachej/boring-ui-kit 0.4.26 → 0.4.28

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 (80) hide show
  1. package/README.md +15 -2
  2. package/bin/boring-ui-blocks.mjs +1 -1
  3. package/dist/chat-dictation.d.ts +27 -0
  4. package/dist/chat-dictation.js +71 -0
  5. package/dist/chat-react-session.d.ts +170 -0
  6. package/dist/chat-react-session.js +367 -0
  7. package/dist/chat-react.d.ts +2 -0
  8. package/dist/chat-react.js +1 -0
  9. package/dist/chat-text.d.ts +9 -0
  10. package/dist/chat-text.js +4 -0
  11. package/dist/chat-voice-message.d.ts +65 -0
  12. package/dist/chat-voice-message.js +235 -0
  13. package/dist/chat-voice.d.ts +7 -0
  14. package/dist/chat-voice.js +4 -0
  15. package/dist/experience-catalog.d.ts +2 -2
  16. package/dist/experience-region-tree.d.ts +1 -1
  17. package/dist/markdown-editor-labels.d.ts +83 -0
  18. package/dist/markdown-editor-labels.js +75 -0
  19. package/dist/markdown-editor.d.ts +7 -1
  20. package/dist/markdown-editor.js +28 -18
  21. package/dist/markdown-extensions.d.ts +4 -2
  22. package/dist/markdown-extensions.js +2 -2
  23. package/dist/markdown-preserved.d.ts +5 -1
  24. package/dist/markdown-preserved.js +15 -4
  25. package/dist/markdown-proposal-diff.d.ts +3 -1
  26. package/dist/markdown-proposal-diff.js +6 -5
  27. package/dist/text-draft-controls.d.ts +5 -1
  28. package/dist/text-draft-controls.js +6 -5
  29. package/dist/text-draft-labels.d.ts +26 -0
  30. package/dist/text-draft-labels.js +47 -0
  31. package/dist/voice-attempt.d.ts +33 -0
  32. package/dist/voice-attempt.js +122 -0
  33. package/package.json +9 -5
  34. package/r/agent-target.json +5 -5
  35. package/r/app-shell.json +14 -14
  36. package/r/board-viewer.json +7 -6
  37. package/r/button.json +1 -1
  38. package/r/dock.json +2 -2
  39. package/r/documents.json +12 -12
  40. package/r/feedback-preview.json +2 -2
  41. package/r/feedback.json +7 -7
  42. package/r/file-tree.json +4 -4
  43. package/r/highlights.json +3 -3
  44. package/r/html-viewer.json +3 -3
  45. package/r/looks-viewer.json +3 -2
  46. package/r/maquette-viewer.json +1 -1
  47. package/r/markdown-editor.json +3 -3
  48. package/r/mockup-viewer.json +1 -1
  49. package/r/pi-ambient.json +11 -11
  50. package/r/pi-app-kinds.json +10 -10
  51. package/r/pi-app.json +13 -13
  52. package/r/pi-artifacts.json +4 -4
  53. package/r/pi-chat-approval.json +3 -3
  54. package/r/pi-chat-attachments.json +1 -1
  55. package/r/pi-chat-markdown.json +2 -2
  56. package/r/pi-chat-mentions.json +1 -1
  57. package/r/pi-chat-pickers.json +1 -1
  58. package/r/pi-chat-plugins.json +11 -11
  59. package/r/pi-chat-question.json +2 -2
  60. package/r/pi-chat-slash.json +2 -2
  61. package/r/pi-chat-subagent.json +2 -2
  62. package/r/pi-chat-voice.json +5 -5
  63. package/r/pi-chat.json +22 -22
  64. package/r/pi-workspace.json +4 -4
  65. package/r/placed-agent.json +4 -4
  66. package/r/provider-setup.json +1 -1
  67. package/r/registry.json +193 -181
  68. package/r/requests-board.json +1 -1
  69. package/r/shared-chat.json +9 -9
  70. package/r/task-list-viewer.json +2 -2
  71. package/r/utils.json +14 -2
  72. package/r/version-history.json +2 -2
  73. package/r/viewer-code.json +2 -2
  74. package/r/viewer-frame.json +7 -7
  75. package/r/viewer-html.json +4 -4
  76. package/r/viewer-image.json +2 -2
  77. package/r/viewer-markdown.json +5 -5
  78. package/r/viewer-pdf.json +4 -4
  79. package/r/viewers.json +6 -6
  80. package/r/windows.json +3 -3
@@ -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
+ }
@@ -57,4 +57,6 @@ export declare function useArtifactFollow(options: ArtifactFollowOptions): {
57
57
  onShown: (shown: ArtifactShown) => void;
58
58
  onFailed: (failed: ArtifactFailed) => void;
59
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';
60
62
  //# sourceMappingURL=chat-react.d.ts.map
@@ -92,3 +92,4 @@ export function useArtifactFollow(options) {
92
92
  }, [active, known]);
93
93
  return { versions, turn, known, shown, open, isOpen, select, recordClosed, onShown, onFailed };
94
94
  }
95
+ export { useChatSession } from './chat-react-session.js';
@@ -119,4 +119,13 @@ export declare function pieces(text: string, options: {
119
119
  readonly skills: readonly string[];
120
120
  readonly uploads?: boolean;
121
121
  }): Piece[];
122
+ /** The presentation label key for a microphone refusal; labels remain host-owned. */
123
+ export declare const voiceRefusalLabel: {
124
+ readonly denied: "voiceDenied";
125
+ readonly 'no-microphone': "voiceNoMicrophone";
126
+ readonly busy: "voiceBusy";
127
+ readonly insecure: "voiceInsecure";
128
+ readonly unsupported: "voiceUnsupported";
129
+ readonly failed: "voiceStartFailed";
130
+ };
122
131
  //# sourceMappingURL=chat-text.d.ts.map
package/dist/chat-text.js CHANGED
@@ -214,3 +214,7 @@ export function pieces(text, options) {
214
214
  out.push({ text: text.slice(cursor) });
215
215
  return out;
216
216
  }
217
+ /** The presentation label key for a microphone refusal; labels remain host-owned. */
218
+ export const voiceRefusalLabel = {
219
+ denied: 'voiceDenied', 'no-microphone': 'voiceNoMicrophone', busy: 'voiceBusy', insecure: 'voiceInsecure', unsupported: 'voiceUnsupported', failed: 'voiceStartFailed',
220
+ };
@@ -0,0 +1,65 @@
1
+ import type { ChatController } from './native-chat.js';
2
+ import type { Dictation, DictationLabels } from './chat-dictation.js';
3
+ export type VoicePhase = 'starting' | 'recording' | 'processing' | 'transcribe-failed' | 'upload-failed' | 'sending' | 'unconfirmed' | 'blocked';
4
+ export interface PendingVoice {
5
+ readonly id: number;
6
+ readonly path: string | undefined;
7
+ readonly phase: VoicePhase;
8
+ readonly seconds: number;
9
+ readonly retry: () => void;
10
+ readonly audioOnly: (() => void) | undefined;
11
+ readonly discard: () => void;
12
+ }
13
+ export interface VoiceMessageHandle {
14
+ readonly canStart: boolean;
15
+ readonly phase: VoicePhase | 'idle';
16
+ readonly seconds: number;
17
+ readonly captureSequence: number;
18
+ readonly levels?: readonly number[];
19
+ readonly paused?: boolean;
20
+ readonly pause?: (() => void) | undefined;
21
+ readonly resume?: (() => void) | undefined;
22
+ readonly problem: string | undefined;
23
+ readonly pending: readonly PendingVoice[];
24
+ readonly start: () => void;
25
+ readonly finish: () => void;
26
+ readonly cancel: () => void;
27
+ }
28
+ export interface VoiceMessageConfig {
29
+ readonly transcribe: (audio: Blob, signal: AbortSignal) => Promise<string>;
30
+ readonly maxMs?: number;
31
+ readonly upload?: (files: File[], signal: AbortSignal) => Promise<readonly {
32
+ readonly path?: string;
33
+ }[]>;
34
+ }
35
+ export interface VoiceRecorderOptions {
36
+ readonly controller: ChatController | undefined;
37
+ readonly config: VoiceMessageConfig | undefined;
38
+ readonly attachments?: {
39
+ readonly upload: NonNullable<VoiceMessageConfig['upload']>;
40
+ } | undefined;
41
+ readonly labels: DictationLabels;
42
+ readonly admitted?: (() => void) | undefined;
43
+ readonly activeController?: {
44
+ readonly current: ChatController;
45
+ } | undefined;
46
+ }
47
+ /** Local admission preparation only. Native send/reconcile owns the submission; uploaded bytes stay in the host workspace. */
48
+ export declare function useVoiceMessage({ controller, config, attachments, labels, admitted, activeController }: VoiceRecorderOptions): VoiceMessageHandle;
49
+ export interface ChatVoiceOptions {
50
+ readonly controller: ChatController | undefined;
51
+ readonly voice: (VoiceMessageConfig & {
52
+ readonly send?: 'auto' | 'draft' | 'message';
53
+ }) | undefined;
54
+ readonly attachments?: VoiceRecorderOptions['attachments'];
55
+ readonly labels: DictationLabels;
56
+ readonly onDone: (outcome: 'sent' | 'drafted') => void;
57
+ }
58
+ export interface ChatVoiceHandle extends Dictation {
59
+ readonly pending?: readonly PendingVoice[];
60
+ readonly captureSequence?: number;
61
+ readonly canStart?: boolean;
62
+ }
63
+ /** Selects retained voice messages or text-only dictation without putting send/draft policy in copied shells. */
64
+ export declare function useChatVoice({ controller, voice, attachments, labels, onDone }: ChatVoiceOptions): ChatVoiceHandle;
65
+ //# sourceMappingURL=chat-voice-message.d.ts.map