@hachej/boring-ui-kit 0.4.13 → 0.4.14

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 (57) hide show
  1. package/README.md +12 -2
  2. package/bin/boring-ui-blocks.mjs +8 -1
  3. package/dist/file-tree-view.d.ts +48 -3
  4. package/dist/file-tree-view.js +202 -12
  5. package/dist/file-tree.d.ts +31 -0
  6. package/dist/file-tree.js +122 -1
  7. package/dist/voice-capture.d.ts +10 -0
  8. package/dist/voice-capture.js +235 -39
  9. package/package.json +2 -2
  10. package/r/agent-target.json +4 -4
  11. package/r/app-shell.json +9 -9
  12. package/r/board-viewer.json +5 -5
  13. package/r/button.json +1 -1
  14. package/r/diagram-viewer.json +1 -1
  15. package/r/dock.json +1 -1
  16. package/r/documents.json +13 -13
  17. package/r/feedback-preview.json +1 -1
  18. package/r/feedback.json +6 -6
  19. package/r/file-tree.json +8 -7
  20. package/r/highlights.json +3 -3
  21. package/r/html-viewer.json +3 -3
  22. package/r/looks-viewer.json +1 -1
  23. package/r/maquette-viewer.json +2 -2
  24. package/r/markdown-editor.json +3 -3
  25. package/r/mockup-viewer.json +1 -1
  26. package/r/pi-ambient.json +9 -9
  27. package/r/pi-app.json +21 -21
  28. package/r/pi-artifacts.json +1 -1
  29. package/r/pi-chat-approval.json +1 -1
  30. package/r/pi-chat-attachments.json +1 -1
  31. package/r/pi-chat-markdown.json +1 -1
  32. package/r/pi-chat-mentions.json +1 -1
  33. package/r/pi-chat-pickers.json +1 -1
  34. package/r/pi-chat-plugins.json +11 -11
  35. package/r/pi-chat-question.json +1 -1
  36. package/r/pi-chat-slash.json +1 -1
  37. package/r/pi-chat-subagent.json +1 -1
  38. package/r/pi-chat-voice.json +3 -3
  39. package/r/pi-chat.json +5 -5
  40. package/r/pi-workspace.json +4 -4
  41. package/r/placed-agent.json +4 -4
  42. package/r/provider-setup.json +2 -2
  43. package/r/record-viewer.json +1 -1
  44. package/r/registry.json +167 -166
  45. package/r/requests-board.json +1 -1
  46. package/r/shared-chat.json +6 -6
  47. package/r/task-list-viewer.json +2 -2
  48. package/r/version-history.json +3 -3
  49. package/r/viewer-code.json +2 -2
  50. package/r/viewer-frame.json +5 -5
  51. package/r/viewer-html.json +4 -4
  52. package/r/viewer-image.json +2 -2
  53. package/r/viewer-markdown.json +5 -5
  54. package/r/viewer-pdf.json +3 -3
  55. package/r/viewers.json +6 -6
  56. package/r/windows.json +1 -1
  57. package/styles/file-tree.css +13 -0
package/dist/file-tree.js CHANGED
@@ -3,12 +3,23 @@ import { randomUUID } from '@hachej/boring-files/platform';
3
3
  const message = (error) => error instanceof Error ? error.message : 'Files could not be loaded';
4
4
  const empty = { kind: 'ready', entries: [] };
5
5
  const parentOf = (path) => path.slice(0, Math.max(0, path.lastIndexOf('/')));
6
+ const nameOf = (path) => path.slice(path.lastIndexOf('/') + 1);
7
+ /** A file path the tree may write: relative segments, none empty, `.` or `..`, no surrounding space. */
8
+ function checkedPath(path) {
9
+ const segments = path.split('/');
10
+ if (!path || segments.some(segment => !segment || segment === '.' || segment === '..' || segment.trim() !== segment))
11
+ throw new TypeError(`${JSON.stringify(path)} is not a file name the tree can use`);
12
+ return path;
13
+ }
6
14
  const sameBytes = (left, right) => left.length === right.length && left.every((byte, index) => byte === right[index]);
7
15
  const sameTarget = (left, right) => left.resource.providerId === right.resource.providerId && left.resource.path === right.resource.path
8
16
  && left.view.kind === right.view.kind && (left.view.kind !== 'working' || (right.view.kind === 'working' && left.view.viewId === right.view.viewId));
9
17
  /** Browsing state belongs to one borrowed binding. Disposal fences late results; it never closes the workspace. */
10
18
  export function createFileTreeController({ revisionProvider }) {
11
- let state = { lifecycle: 'active', directories: new Map(), expanded: new Set(), query: '', search: empty, uploads: [] };
19
+ let state = { lifecycle: 'active', directories: new Map(), expanded: new Set(), query: '', search: empty, uploads: [], mutations: [] };
20
+ // The revision each row last learned (`prepare`): a rename or delete is refused if the file changed since.
21
+ const revisions = new Map();
22
+ const preparing = new Map();
12
23
  const listeners = new Set();
13
24
  const lifetime = new AbortController();
14
25
  const loads = new Map();
@@ -103,6 +114,67 @@ export function createFileTreeController({ revisionProvider }) {
103
114
  }
104
115
  return result;
105
116
  }
117
+ const unavailableResult = (reason) => ({ kind: 'unavailable', reason });
118
+ async function latest(path, signal) {
119
+ const read = await revisionProvider.read({ target: revisionProvider.locate(path), revision: { kind: 'latest' } }, signal);
120
+ if (read.kind === 'available')
121
+ return read.snapshot.ref.revision;
122
+ if (read.kind === 'missing')
123
+ return { kind: 'conflict', current: [], reason: `${path} is no longer there` };
124
+ return read;
125
+ }
126
+ function settle(current, result) {
127
+ const settled = { ...current, state: { kind: 'settled', result } };
128
+ update({ ...state, mutations: state.mutations.map(item => item.operationId === current.operationId ? settled : item) });
129
+ if (state.lifecycle !== 'active')
130
+ return settled;
131
+ // Whatever the outcome, the folders involved are read again, so the tree shows what is there now.
132
+ const folders = new Set([parentOf(current.path), ...(current.to === undefined ? [] : [parentOf(current.to)])]);
133
+ if (result.kind === 'committed' && current.to !== undefined && parentOf(current.to))
134
+ update({ ...state, expanded: new Set([...state.expanded, parentOf(current.to)]) });
135
+ for (const folder of folders)
136
+ void refresh(folder);
137
+ if (state.query)
138
+ void load('', false, true);
139
+ return settled;
140
+ }
141
+ async function mutate(kind, path, to) {
142
+ active();
143
+ if (kind === 'rename' ? !revisionProvider.rename : !revisionProvider.delete)
144
+ throw new Error(`This workspace cannot ${kind} files`);
145
+ if (state.mutations.some(item => item.path === path && item.state.kind === 'pending'))
146
+ throw new Error(`${path} is already being changed; wait for it to finish`);
147
+ const operationId = randomUUID();
148
+ const current = { operationId, kind, path, ...(to === undefined ? {} : { to }), state: { kind: 'pending' } };
149
+ update({ ...state, mutations: [...state.mutations.filter(item => item.path !== path), current] });
150
+ let result;
151
+ try {
152
+ await preparing.get(path);
153
+ const known = revisions.get(path) ?? await latest(path, lifetime.signal);
154
+ if (typeof known !== 'string')
155
+ result = known;
156
+ else {
157
+ const from = revisionProvider.locate(path);
158
+ const answer = kind === 'rename' && to !== undefined
159
+ ? await revisionProvider.rename({ from, to: revisionProvider.locate(to), expectedRevision: known, operationId }, lifetime.signal)
160
+ : await revisionProvider.delete({ path: from, expectedRevision: known, operationId }, lifetime.signal);
161
+ try {
162
+ result = parsePublicationResult(answer);
163
+ }
164
+ catch {
165
+ result = unknown(operationId, 'The acknowledgement was invalid; read the folder again before retrying');
166
+ }
167
+ if (result.kind === 'partial' || (result.kind === 'unknown' && result.operationId !== operationId))
168
+ result = unknown(operationId, 'The acknowledgement did not match its operation');
169
+ }
170
+ }
171
+ catch (error) {
172
+ result = error instanceof PublicationNotDispatchedError && error.operationId === operationId ? unavailableResult(`${nameOf(path)} was not changed: the request was not sent`)
173
+ : unknown(operationId, `The outcome is unconfirmed; read the folder again before retrying`);
174
+ }
175
+ revisions.delete(path);
176
+ return settle(current, result);
177
+ }
106
178
  const controller = {
107
179
  revisionProvider, getSnapshot: () => state,
108
180
  subscribe: listener => { if (state.lifecycle === 'disposed')
@@ -210,6 +282,55 @@ export function createFileTreeController({ revisionProvider }) {
210
282
  return controller.upload({ path: attempt.current.path, bytes: attempt.bytes, mediaType: change.mediaType,
211
283
  ...(change.kind === 'replace' ? { replace: change.target } : {}) });
212
284
  },
285
+ actions: { rename: typeof revisionProvider.rename === 'function', delete: typeof revisionProvider.delete === 'function' },
286
+ prepare: (path, signal) => {
287
+ try {
288
+ active();
289
+ }
290
+ catch (error) {
291
+ return Promise.reject(error);
292
+ }
293
+ // A rename or delete started meanwhile waits for this read, so it uses the revision the row learned.
294
+ const reading = (async () => {
295
+ try {
296
+ const known = await latest(path, signal ? AbortSignal.any([signal, lifetime.signal]) : lifetime.signal);
297
+ if (typeof known !== 'string') {
298
+ revisions.delete(path);
299
+ return undefined;
300
+ }
301
+ revisions.set(path, known);
302
+ return known;
303
+ }
304
+ catch {
305
+ return undefined;
306
+ }
307
+ })();
308
+ preparing.set(path, reading);
309
+ void reading.finally(() => { if (preparing.get(path) === reading)
310
+ preparing.delete(path); });
311
+ return reading;
312
+ },
313
+ rename: async (path, to) => {
314
+ checkedPath(path);
315
+ checkedPath(to);
316
+ if (to === path)
317
+ throw new TypeError('The new name is the current name');
318
+ return mutate('rename', path, to);
319
+ },
320
+ move: async (path, folder) => {
321
+ checkedPath(path);
322
+ if (folder)
323
+ checkedPath(folder);
324
+ const to = folder ? `${folder}/${nameOf(path)}` : nameOf(path);
325
+ if (to === path)
326
+ throw new TypeError(`${nameOf(path)} is already in that folder`);
327
+ if (folder === path || folder.startsWith(`${path}/`))
328
+ throw new TypeError('A file cannot move into itself');
329
+ return mutate('rename', path, to);
330
+ },
331
+ remove: async (path) => mutate('delete', checkedPath(path)),
332
+ dismiss: operationId => { if (state.mutations.some(item => item.operationId === operationId && item.state.kind === 'settled'))
333
+ update({ ...state, mutations: state.mutations.filter(item => item.operationId !== operationId) }); },
213
334
  history: (path, signal) => { active(); return revisionProvider.history(path, signal ? AbortSignal.any([signal, lifetime.signal]) : lifetime.signal); },
214
335
  dispose: () => { if (state.lifecycle === 'disposed')
215
336
  return; lifetime.abort(); for (const load of loads.values())
@@ -31,8 +31,18 @@ export interface VoiceCaptureOptions {
31
31
  readonly maxMs?: number;
32
32
  /** The page clock (default `performance.now()`). */
33
33
  readonly now?: () => number;
34
+ /** The Web Audio context for WAV recording (default `new AudioContext()` when the page has one); `null` records with MediaRecorder. */
35
+ readonly audioContext?: (() => AudioContext) | null;
36
+ /** How long `start()` waits for the first sound before refusing as `busy` (default 5 s). */
37
+ readonly firstSoundMs?: number;
34
38
  }
35
39
  export declare const DEFAULT_MAX_VOICE_MS: number;
40
+ /** WAV recordings are 16 kHz mono 16-bit PCM: 32 kB per second. */
41
+ export declare const WAV_RATE = 16000;
42
+ /** `input` (samples at `from` Hz) at `to` Hz, each output sample the mean of the input samples it covers. */
43
+ export declare function downsample(input: Float32Array, from: number, to?: number): Float32Array;
44
+ /** The WAV file (16-bit PCM, mono) of `chunks` of samples at `rate` Hz, end to end, clipped to [-1, 1]. */
45
+ export declare function encodeWav(chunks: readonly Float32Array[], rate?: number): Uint8Array<ArrayBuffer>;
36
46
  /** Container types tried in order; the first the browser records wins. */
37
47
  export declare const VOICE_MIME_TYPES: readonly ["audio/webm;codecs=opus", "audio/webm", "audio/mp4", "audio/ogg;codecs=opus"];
38
48
  /** The browser's microphone, or null where the page cannot have one (insecure origin, no media devices). */
@@ -15,7 +15,69 @@
15
15
  // `endedAt` is `startedAt` plus the decoded duration when the page can decode the recording, else the `stop` event's time.
16
16
  // Measured in Chromium only; a microphone that warms up after getUserMedia resolves (no audio yet when recording starts) would
17
17
  // make `startedAt` early by the warm-up.
18
+ //
19
+ // WAV first. Where the page has Web Audio, the capture records 16 kHz mono PCM WAV instead: a MediaRecorder webm carries the
20
+ // browser's clock, and one stamped at 0 s then 3900 s (23/10/2026) was refused by every OpenAI transcription model ("Audio file
21
+ // might be corrupted or unsupported"); a WAV has no timestamps, its length is its byte count. `start()` resolves only once the
22
+ // first sound arrives (a fresh AudioContext can take a second or two), so a host that shows "recording" never loses the first
23
+ // words; no sound within `firstSoundMs` refuses as `busy`. `startedAt` is the page time of that first buffer's first sample and
24
+ // `endedAt` adds the samples' duration, so the clock no longer depends on MediaRecorder's late chunks. A ScriptProcessorNode, not
25
+ // an AudioWorklet: a worklet is a module the page must serve, and a host's CSP may refuse one made from a blob. MediaRecorder
26
+ // stays the fallback where there is no AudioContext.
18
27
  export const DEFAULT_MAX_VOICE_MS = 5 * 60_000;
28
+ /** WAV recordings are 16 kHz mono 16-bit PCM: 32 kB per second. */
29
+ export const WAV_RATE = 16_000;
30
+ /** `input` (samples at `from` Hz) at `to` Hz, each output sample the mean of the input samples it covers. */
31
+ export function downsample(input, from, to = WAV_RATE) {
32
+ if (from === to)
33
+ return Float32Array.from(input);
34
+ if (from < to)
35
+ throw new Error(`cannot upsample ${from} Hz to ${to} Hz`);
36
+ const ratio = from / to;
37
+ const out = new Float32Array(Math.floor(input.length / ratio));
38
+ for (let i = 0; i < out.length; i++) {
39
+ const start = Math.floor(i * ratio), end = Math.min(input.length, Math.floor((i + 1) * ratio));
40
+ let sum = 0;
41
+ for (let j = start; j < end; j++)
42
+ sum += input[j];
43
+ out[i] = end > start ? sum / (end - start) : 0;
44
+ }
45
+ return out;
46
+ }
47
+ /** The WAV file (16-bit PCM, mono) of `chunks` of samples at `rate` Hz, end to end, clipped to [-1, 1]. */
48
+ export function encodeWav(chunks, rate = WAV_RATE) {
49
+ const length = chunks.reduce((n, chunk) => n + chunk.length, 0);
50
+ const view = new DataView(new ArrayBuffer(44 + length * 2));
51
+ const ascii = (at, text) => { for (let i = 0; i < text.length; i++)
52
+ view.setUint8(at + i, text.charCodeAt(i)); };
53
+ ascii(0, 'RIFF');
54
+ view.setUint32(4, 36 + length * 2, true);
55
+ ascii(8, 'WAVE');
56
+ ascii(12, 'fmt ');
57
+ view.setUint32(16, 16, true);
58
+ view.setUint16(20, 1, true);
59
+ view.setUint16(22, 1, true);
60
+ view.setUint32(24, rate, true);
61
+ view.setUint32(28, rate * 2, true);
62
+ view.setUint16(32, 2, true);
63
+ view.setUint16(34, 16, true);
64
+ ascii(36, 'data');
65
+ view.setUint32(40, length * 2, true);
66
+ let at = 44;
67
+ for (const chunk of chunks)
68
+ for (const sample of chunk) {
69
+ const v = Math.max(-1, Math.min(1, sample));
70
+ view.setInt16(at, v < 0 ? v * 0x8000 : v * 0x7fff, true);
71
+ at += 2;
72
+ }
73
+ return new Uint8Array(view.buffer);
74
+ }
75
+ /** The page's Web Audio context constructor, or null (Node, old browsers). */
76
+ function browserAudioContext() {
77
+ const Context = globalThis.AudioContext
78
+ ?? globalThis.webkitAudioContext;
79
+ return typeof Context === 'function' ? () => new Context() : null;
80
+ }
19
81
  /** Container types tried in order; the first the browser records wins. */
20
82
  export const VOICE_MIME_TYPES = ['audio/webm;codecs=opus', 'audio/webm', 'audio/mp4', 'audio/ogg;codecs=opus'];
21
83
  /** The browser's microphone, or null where the page cannot have one (insecure origin, no media devices). */
@@ -70,6 +132,157 @@ export function createVoiceCapture(options = {}) {
70
132
  let session = null;
71
133
  let opening = false;
72
134
  let cancelledWhileOpening = false;
135
+ const makeContext = options.audioContext === undefined ? browserAudioContext() : options.audioContext;
136
+ const firstSoundMs = options.firstSoundMs ?? 5_000;
137
+ let wav = null;
138
+ /** Stops capturing (the samples so far are kept), releases the microphone and closes the context. */
139
+ const endWav = (current, truncated) => {
140
+ if (truncated)
141
+ current.truncated = true;
142
+ if (current.ended)
143
+ return;
144
+ current.ended = true;
145
+ if (current.node) {
146
+ current.node.onaudioprocess = null;
147
+ current.node.disconnect();
148
+ }
149
+ current.source?.disconnect();
150
+ release(current.stream);
151
+ void current.context.close().catch(() => { });
152
+ };
153
+ /** MediaRecorder on an open microphone (`startedAt` read just before `start()`, see the clock above). */
154
+ function record(Recorder, stream) {
155
+ const supported = (type) => { try {
156
+ return typeof Recorder.isTypeSupported !== 'function' || Recorder.isTypeSupported(type);
157
+ }
158
+ catch {
159
+ return false;
160
+ } };
161
+ const preferred = VOICE_MIME_TYPES.find(supported);
162
+ let recorder;
163
+ try {
164
+ recorder = preferred ? new Recorder(stream, { mimeType: preferred }) : new Recorder(stream);
165
+ }
166
+ catch (error) {
167
+ release(stream);
168
+ return refused('unsupported', error instanceof Error ? error.message : String(error));
169
+ }
170
+ let settle;
171
+ const current = {
172
+ stream, recorder, mimeType: preferred ?? '', chunks: [], startedAt: null, stoppedAt: null, truncated: false, timer: null,
173
+ stopped: new Promise(resolve => { settle = resolve; }), cancelled: false,
174
+ };
175
+ recorder.addEventListener('dataavailable', event => { if (event.data && event.data.size > 0)
176
+ current.chunks.push(event.data); });
177
+ recorder.addEventListener('stop', () => { current.stoppedAt = now(); release(stream); settle(); });
178
+ recorder.addEventListener('error', () => { current.stoppedAt ??= now(); release(stream); settle(); });
179
+ try {
180
+ current.startedAt = now();
181
+ recorder.start(1000);
182
+ }
183
+ catch (error) {
184
+ release(stream);
185
+ return refused('failed', error instanceof Error ? error.message : String(error));
186
+ }
187
+ if (Number.isFinite(maxMs) && maxMs > 0)
188
+ current.timer = setTimeout(() => finish(current, true), maxMs);
189
+ session = current;
190
+ return { kind: 'recording' };
191
+ }
192
+ /** WAV through Web Audio: the context is made and resumed inside the person's click, before awaiting the microphone. */
193
+ async function startWav(makeContext, open, Recorder) {
194
+ opening = true;
195
+ cancelledWhileOpening = false;
196
+ let context;
197
+ try {
198
+ context = makeContext();
199
+ }
200
+ catch (error) {
201
+ opening = false;
202
+ return refused('unsupported', error instanceof Error ? error.message : String(error));
203
+ }
204
+ const resuming = context.resume().catch(() => { });
205
+ let stream;
206
+ try {
207
+ stream = await open({ audio: true });
208
+ }
209
+ catch (error) {
210
+ opening = false;
211
+ void context.close().catch(() => { });
212
+ return refusalOf(error);
213
+ }
214
+ let cancelled;
215
+ const current = {
216
+ stream, context, source: null, node: null, chunks: [], samples: 0, startedAt: null, truncated: false, cancelled: false, ended: false,
217
+ maxSamples: Number.isFinite(maxMs) && maxMs > 0 ? Math.floor((maxMs / 1000) * WAV_RATE) : Number.POSITIVE_INFINITY,
218
+ cancel: () => cancelled(),
219
+ };
220
+ const gone = new Promise(resolve => { cancelled = () => resolve('cancelled'); });
221
+ if (cancelledWhileOpening) {
222
+ opening = false;
223
+ endWav(current, false);
224
+ return { kind: 'refused', code: 'failed', reason: 'Cancelled before the microphone opened.' };
225
+ }
226
+ // A context the browser keeps suspended (no user activation: a press-and-hold begun on touchstart, which is not one) records
227
+ // nothing: that take is recorded by MediaRecorder on the microphone already open, as before WAV.
228
+ await Promise.race([resuming, new Promise(resolve => setTimeout(resolve, 500))]);
229
+ if (cancelledWhileOpening) {
230
+ opening = false;
231
+ endWav(current, false);
232
+ return { kind: 'refused', code: 'failed', reason: 'Cancelled before the microphone opened.' };
233
+ }
234
+ if (context.state !== 'running') {
235
+ void context.close().catch(() => { });
236
+ opening = false;
237
+ if (Recorder)
238
+ return record(Recorder, stream);
239
+ release(stream);
240
+ return refused('unsupported', 'audio is not allowed to start here');
241
+ }
242
+ let heard;
243
+ const sound = new Promise(resolve => { heard = () => resolve('heard'); });
244
+ try {
245
+ current.source = context.createMediaStreamSource(stream);
246
+ current.node = context.createScriptProcessor(4096, 1, 1);
247
+ }
248
+ catch (error) {
249
+ opening = false;
250
+ endWav(current, false);
251
+ return refused('unsupported', error instanceof Error ? error.message : String(error));
252
+ }
253
+ current.node.onaudioprocess = event => {
254
+ if (current.ended)
255
+ return;
256
+ const input = event.inputBuffer.getChannelData(0);
257
+ if (current.startedAt === null) {
258
+ current.startedAt = now() - (input.length / context.sampleRate) * 1000;
259
+ heard();
260
+ }
261
+ const chunk = downsample(input, context.sampleRate);
262
+ const room = current.maxSamples - current.samples;
263
+ const kept = chunk.length > room ? chunk.subarray(0, room) : chunk;
264
+ current.chunks.push(kept);
265
+ current.samples += kept.length;
266
+ if (current.samples >= current.maxSamples)
267
+ endWav(current, true);
268
+ };
269
+ current.source.connect(current.node);
270
+ current.node.connect(context.destination);
271
+ wav = current;
272
+ opening = false;
273
+ let timer;
274
+ const silent = new Promise(resolve => { timer = setTimeout(() => resolve('silent'), firstSoundMs); });
275
+ const outcome = await Promise.race([sound, silent, gone]);
276
+ clearTimeout(timer);
277
+ if (outcome === 'heard')
278
+ return { kind: 'recording' };
279
+ if (wav === current)
280
+ wav = null;
281
+ endWav(current, false);
282
+ return outcome === 'cancelled'
283
+ ? { kind: 'refused', code: 'failed', reason: 'Cancelled before the microphone opened.' }
284
+ : refused('busy', 'no sound from the microphone');
285
+ }
73
286
  const finish = (current, truncated) => {
74
287
  if (current.timer !== null) {
75
288
  clearTimeout(current.timer);
@@ -86,13 +299,15 @@ export function createVoiceCapture(options = {}) {
86
299
  };
87
300
  return {
88
301
  async start() {
89
- if (session || opening)
302
+ if (session || wav || opening)
90
303
  return { kind: 'refused', code: 'failed', reason: 'Already recording.' };
91
304
  const Recorder = globalThis.MediaRecorder;
92
305
  const open = options.getUserMedia ?? browserGetUserMedia();
93
306
  // No media devices on an insecure origin: say that, not "no microphone" (the person can fix the address, not the hardware).
94
307
  if (!open)
95
308
  return refused(plainHttpPage() ? 'insecure' : 'unsupported');
309
+ if (makeContext)
310
+ return startWav(makeContext, open, typeof Recorder === 'function' ? Recorder : null);
96
311
  if (typeof Recorder !== 'function')
97
312
  return refused('unsupported', 'no MediaRecorder');
98
313
  // getUserMedia is called synchronously from here, inside the person's click (no await before it), so browsers that tie the
@@ -112,47 +327,20 @@ export function createVoiceCapture(options = {}) {
112
327
  release(stream);
113
328
  return { kind: 'refused', code: 'failed', reason: 'Cancelled before the microphone opened.' };
114
329
  }
115
- const supported = (type) => { try {
116
- return typeof Recorder.isTypeSupported !== 'function' || Recorder.isTypeSupported(type);
117
- }
118
- catch {
119
- return false;
120
- } };
121
- const preferred = VOICE_MIME_TYPES.find(supported);
122
- let recorder;
123
- try {
124
- recorder = preferred ? new Recorder(stream, { mimeType: preferred }) : new Recorder(stream);
125
- }
126
- catch (error) {
127
- opening = false;
128
- release(stream);
129
- return refused('unsupported', error instanceof Error ? error.message : String(error));
130
- }
131
- let settle;
132
- const current = {
133
- stream, recorder, mimeType: preferred ?? '', chunks: [], startedAt: null, stoppedAt: null, truncated: false, timer: null,
134
- stopped: new Promise(resolve => { settle = resolve; }), cancelled: false,
135
- };
136
- recorder.addEventListener('dataavailable', event => { if (event.data && event.data.size > 0)
137
- current.chunks.push(event.data); });
138
- recorder.addEventListener('stop', () => { current.stoppedAt = now(); release(stream); settle(); });
139
- recorder.addEventListener('error', () => { current.stoppedAt ??= now(); release(stream); settle(); });
140
- try {
141
- current.startedAt = now();
142
- recorder.start(1000);
143
- }
144
- catch (error) {
145
- opening = false;
146
- release(stream);
147
- return refused('failed', error instanceof Error ? error.message : String(error));
148
- }
149
- if (Number.isFinite(maxMs) && maxMs > 0)
150
- current.timer = setTimeout(() => finish(current, true), maxMs);
151
- session = current;
152
330
  opening = false;
153
- return { kind: 'recording' };
331
+ return record(Recorder, stream);
154
332
  },
155
333
  async stop() {
334
+ const recorded = wav;
335
+ if (recorded) {
336
+ wav = null;
337
+ endWav(recorded, false);
338
+ if (recorded.cancelled || recorded.samples === 0 || recorded.startedAt === null)
339
+ return null;
340
+ const audio = new Blob([encodeWav(recorded.chunks)], { type: 'audio/wav' });
341
+ const endedAt = recorded.startedAt + (recorded.samples / WAV_RATE) * 1000;
342
+ return { audio, mimeType: 'audio/wav', startedAt: recorded.startedAt, endedAt, truncated: recorded.truncated };
343
+ }
156
344
  const current = session;
157
345
  if (!current)
158
346
  return null;
@@ -171,6 +359,14 @@ export function createVoiceCapture(options = {}) {
171
359
  return { audio, mimeType, startedAt, endedAt, truncated: current.truncated };
172
360
  },
173
361
  cancel() {
362
+ const recorded = wav;
363
+ if (recorded) {
364
+ wav = null;
365
+ recorded.cancelled = true;
366
+ recorded.chunks.length = 0;
367
+ endWav(recorded, false);
368
+ recorded.cancel();
369
+ }
174
370
  const current = session;
175
371
  if (opening)
176
372
  cancelledWhileOpening = true;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hachej/boring-ui-kit",
3
- "version": "0.4.13",
3
+ "version": "0.4.14",
4
4
  "license": "MIT",
5
5
  "private": false,
6
6
  "type": "module",
@@ -134,7 +134,7 @@
134
134
  }
135
135
  },
136
136
  "peerDependencies": {
137
- "@hachej/boring-files": "^0.4.13",
137
+ "@hachej/boring-files": "^0.4.14",
138
138
  "@earendil-works/pi-durable": "~1.2.0",
139
139
  "react": "^19.0.0",
140
140
  "react-dom": "^19.0.0",
@@ -4,15 +4,15 @@
4
4
  "title": "Point and dictate on app data",
5
5
  "description": "Tap explicitly marked JSON app context, dictate once and show the same app agent’s native reply without opening chat. Host-controlled presentation; no feedback report or second controller.",
6
6
  "dependencies": [
7
- "@hachej/boring-ui-kit@^0.4.13",
8
- "@hachej/boring-feedback@^0.4.13",
7
+ "@hachej/boring-ui-kit@^0.4.14",
8
+ "@hachej/boring-feedback@^0.4.14",
9
9
  "@earendil-works/pi-durable@~1.2.0",
10
10
  "react@^19.0.0",
11
11
  "react-dom@^19.0.0"
12
12
  ],
13
13
  "registryDependencies": [
14
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.13/r/app-shell.json",
15
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.13/r/pi-chat.json",
14
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.14/r/app-shell.json",
15
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.14/r/pi-chat.json",
16
16
  "button",
17
17
  "card",
18
18
  "message",
package/r/app-shell.json CHANGED
@@ -4,21 +4,21 @@
4
4
  "title": "App shell: layouts, list/detail and agent layer",
5
5
  "description": "AppShell with three layouts. B \"workspace\" puts a ListDetail on a screen (phone: the list, then the detail as a stack step with Back; wide: a 20rem list and the detail side by side, sidebar as an icon rail). C \"chat\" wraps the app's own AgentWorkspace (untouched, passed as the nav item's render; install the pi-app item yourself) in the same sidebar, account menu and header; it takes no shell agent. A \"screen\": one screen at a time, every screen kept mounted. On a phone bottom tabs (at most four; more give three plus a « Plus » sheet) and an avatar button that opens the account sheet; on a wide screen the shadcn sidebar (icon rail, NavUser). One header per screen through useScreenHeader (title, subtitle, back, up to two actions, the rest behind « … »). The account menu holds « Améliorer l'app » (the improve agent), the app's own entries and sign out. AppSheet is the one bottom sheet (one open at a time, Back closes it). It mounts the AgentLayer: one agent's PiChat above the app (phone: a drawer with peek, half and full snap points; wide: a right panel), the improve agent in the same layer, optional push-to-talk and point mode. Also useAgentContext and readAgentContext (the screen on show travels as `context` with each submit) and useBackLayer, the one Back manager. Built on explicitly pinned Base UI drawer and dropdown-menu recipes, plus the sidebar and avatar of the host's own shadcn style.",
6
6
  "dependencies": [
7
- "@hachej/boring-feedback@^0.4.13",
8
- "@hachej/boring-ui-kit@^0.4.13",
7
+ "@hachej/boring-feedback@^0.4.14",
8
+ "@hachej/boring-ui-kit@^0.4.14",
9
9
  "@base-ui/react@^1.9.0",
10
10
  "react@^19.0.0",
11
11
  "react-dom@^19.0.0",
12
12
  "lucide-react@^1.51.0"
13
13
  ],
14
14
  "registryDependencies": [
15
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.13/r/pi-chat.json",
16
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.13/r/pi-chat-plugins.json",
17
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.13/r/pi-chat-voice.json",
18
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.13/r/dock.json",
19
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.13/r/feedback.json",
20
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.13/r/utils.json",
21
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.13/r/button.json",
15
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.14/r/pi-chat.json",
16
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.14/r/pi-chat-plugins.json",
17
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.14/r/pi-chat-voice.json",
18
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.14/r/dock.json",
19
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.14/r/feedback.json",
20
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.14/r/utils.json",
21
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.14/r/button.json",
22
22
  "drawer",
23
23
  "sidebar",
24
24
  "avatar",
@@ -4,16 +4,16 @@
4
4
  "title": "Board viewer",
5
5
  "description": "A board@1 artifact kind for *.board.json. Lazy native tldraw projection of maquette screens, sandboxed HTML mock-ups, goto arrows and notes. Conditional board-file autosave; phone viewing without accidental frame edits. Hosts supply their resource client, asset URLs and license key.",
6
6
  "dependencies": [
7
- "@hachej/boring-ui-kit@^0.4.13",
8
- "@hachej/boring-files@^0.4.13",
7
+ "@hachej/boring-ui-kit@^0.4.14",
8
+ "@hachej/boring-files@^0.4.14",
9
9
  "react@^19.0.0",
10
10
  "lucide-react@1.51.0",
11
11
  "tldraw@5.5.2"
12
12
  ],
13
13
  "registryDependencies": [
14
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.13/r/maquette-viewer.json",
15
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.13/r/mockup-viewer.json",
16
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.13/r/pi-artifacts.json",
14
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.14/r/maquette-viewer.json",
15
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.14/r/mockup-viewer.json",
16
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.14/r/pi-artifacts.json",
17
17
  "button",
18
18
  "alert",
19
19
  "card",
package/r/button.json CHANGED
@@ -9,7 +9,7 @@
9
9
  "lucide-react@^1.51.0"
10
10
  ],
11
11
  "registryDependencies": [
12
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.13/r/utils.json",
12
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.14/r/utils.json",
13
13
  "dropdown-menu",
14
14
  "button",
15
15
  "toggle",
@@ -8,7 +8,7 @@
8
8
  "lucide-react@^1.51.0"
9
9
  ],
10
10
  "registryDependencies": [
11
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.13/r/theme.json",
11
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.14/r/theme.json",
12
12
  "alert",
13
13
  "badge"
14
14
  ],
package/r/dock.json CHANGED
@@ -7,7 +7,7 @@
7
7
  "react@^19.0.0"
8
8
  ],
9
9
  "registryDependencies": [
10
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.13/r/utils.json"
10
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.14/r/utils.json"
11
11
  ],
12
12
  "files": [
13
13
  {