@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.
- package/README.md +40 -3
- package/dist/chat-artifacts.d.ts +157 -0
- package/dist/chat-artifacts.js +274 -0
- package/dist/chat-dictation.d.ts +27 -0
- package/dist/chat-dictation.js +71 -0
- package/dist/chat-react-controller.d.ts +25 -0
- package/dist/chat-react-controller.js +72 -0
- package/dist/chat-react-conversations.d.ts +40 -0
- package/dist/chat-react-conversations.js +68 -0
- package/dist/chat-react-draft.d.ts +11 -0
- package/dist/chat-react-draft.js +91 -0
- package/dist/chat-react-remote.d.ts +33 -0
- package/dist/chat-react-remote.js +72 -0
- package/dist/chat-react-session.d.ts +170 -0
- package/dist/chat-react-session.js +367 -0
- package/dist/chat-react-transcript.d.ts +39 -0
- package/dist/chat-react-transcript.js +123 -0
- package/dist/chat-react.d.ts +62 -0
- package/dist/chat-react.js +95 -0
- package/dist/chat-text.d.ts +131 -0
- package/dist/chat-text.js +220 -0
- package/dist/chat-view.d.ts +2 -17
- package/dist/chat-view.js +1 -0
- package/dist/chat-voice-message.d.ts +65 -0
- package/dist/chat-voice-message.js +235 -0
- package/dist/chat-voice.d.ts +7 -0
- package/dist/chat-voice.js +4 -0
- package/dist/experience-catalog.d.ts +2 -2
- package/dist/experience-region-tree.d.ts +1 -1
- package/dist/markdown-editor-labels.d.ts +83 -0
- package/dist/markdown-editor-labels.js +75 -0
- package/dist/markdown-editor.d.ts +7 -1
- package/dist/markdown-editor.js +28 -18
- package/dist/markdown-extensions.d.ts +4 -2
- package/dist/markdown-extensions.js +2 -2
- package/dist/markdown-preserved.d.ts +5 -1
- package/dist/markdown-preserved.js +15 -4
- package/dist/markdown-proposal-diff.d.ts +3 -1
- package/dist/markdown-proposal-diff.js +6 -5
- package/dist/run-notifications.d.ts +42 -0
- package/dist/run-notifications.js +67 -0
- package/dist/shared-chat/index.d.ts +1 -1
- package/dist/shared-chat/index.js +5 -4
- package/dist/text-draft-controls.d.ts +5 -1
- package/dist/text-draft-controls.js +6 -5
- package/dist/text-draft-labels.d.ts +26 -0
- package/dist/text-draft-labels.js +47 -0
- package/dist/voice-attempt.d.ts +33 -0
- package/dist/voice-attempt.js +122 -0
- package/dist/voice-capture.d.ts +12 -0
- package/dist/voice-capture.js +17 -2
- package/package.json +14 -2
- package/r/agent-target.json +5 -9
- package/r/app-shell.json +19 -28
- package/r/board-viewer.json +8 -11
- package/r/button.json +2 -9
- package/r/diagram-viewer.json +0 -5
- package/r/dock.json +2 -2
- package/r/documents.json +13 -18
- package/r/feedback-preview.json +1 -7
- package/r/feedback.json +12 -19
- package/r/file-tree.json +4 -14
- package/r/highlights.json +4 -9
- package/r/html-viewer.json +3 -3
- package/r/looks-viewer.json +3 -6
- package/r/maquette-viewer.json +2 -20
- package/r/markdown-editor.json +4 -7
- package/r/mockup-viewer.json +2 -5
- package/r/pi-ambient.json +11 -19
- package/r/pi-app-kinds.json +10 -10
- package/r/pi-app.json +17 -27
- package/r/pi-artifacts.json +3 -4
- package/r/pi-chat-approval.json +2 -5
- package/r/pi-chat-attachments.json +2 -4
- package/r/pi-chat-markdown.json +3 -5
- package/r/pi-chat-mentions.json +2 -3
- package/r/pi-chat-pickers.json +2 -4
- package/r/pi-chat-plugins.json +11 -11
- package/r/pi-chat-question.json +1 -7
- package/r/pi-chat-slash.json +3 -6
- package/r/pi-chat-subagent.json +3 -8
- package/r/pi-chat-voice.json +7 -10
- package/r/pi-chat.json +25 -53
- package/r/pi-workspace.json +4 -7
- package/r/placed-agent.json +4 -5
- package/r/provider-setup.json +2 -8
- package/r/record-viewer.json +0 -9
- package/r/registry.json +182 -403
- package/r/requests-board.json +2 -10
- package/r/shared-chat.json +10 -24
- package/r/task-list-viewer.json +3 -9
- package/r/utils.json +14 -2
- package/r/version-history.json +3 -5
- package/r/viewer-code.json +2 -3
- package/r/viewer-frame.json +9 -17
- package/r/viewer-html.json +4 -5
- package/r/viewer-image.json +3 -4
- package/r/viewer-markdown.json +6 -8
- package/r/viewer-pdf.json +5 -7
- package/r/viewers.json +6 -6
- package/r/windows.json +3 -8
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
const frenchRecoveryStates = { disabled: 'désactivée', revoked: 'accès révoqué', expired: 'expirée', disposed: 'fermée' };
|
|
2
|
+
export const textDraftLabels = {
|
|
3
|
+
en: {
|
|
4
|
+
draftRecovery: 'Draft recovery',
|
|
5
|
+
draftRecoveryState: kind => `Draft recovery ${kind}`,
|
|
6
|
+
draftChecking: 'Checking stored drafts',
|
|
7
|
+
draftStoring: 'Storing draft',
|
|
8
|
+
draftStored: 'Draft stored for recovery. Storage is not a save receipt.',
|
|
9
|
+
draftStorageUnavailable: 'Draft recovery storage unavailable',
|
|
10
|
+
draftEnabled: 'Draft recovery enabled',
|
|
11
|
+
checkStoredDrafts: 'Check stored drafts',
|
|
12
|
+
storeDraft: 'Store draft for recovery',
|
|
13
|
+
noStoredDrafts: 'No stored drafts',
|
|
14
|
+
moreStoredDrafts: 'More stored drafts exist than this listing shows.',
|
|
15
|
+
storedDraft: 'Stored draft',
|
|
16
|
+
draftConflict: 'Saved revision changed. Review this draft separately.',
|
|
17
|
+
draftMatches: 'Stored draft matches the checked saved revision.',
|
|
18
|
+
reviewStoredDraft: 'Review stored draft',
|
|
19
|
+
restoreDraft: 'Restore draft',
|
|
20
|
+
discardStoredDraft: 'Discard stored draft',
|
|
21
|
+
draftRestored: 'Draft restored locally. Save to publish it.',
|
|
22
|
+
draftDiscarded: 'Stored draft discarded. Current edits are unchanged.',
|
|
23
|
+
draftStorageFailed: 'Draft storage unavailable. Current edits are unchanged.',
|
|
24
|
+
},
|
|
25
|
+
fr: {
|
|
26
|
+
draftRecovery: 'Récupération de brouillon',
|
|
27
|
+
draftRecoveryState: kind => `Récupération de brouillon : ${frenchRecoveryStates[kind] ?? kind}`,
|
|
28
|
+
draftChecking: 'Recherche des brouillons enregistrés',
|
|
29
|
+
draftStoring: 'Enregistrement du brouillon',
|
|
30
|
+
draftStored: 'Brouillon conservé pour récupération. Ce n’est pas un enregistrement du document.',
|
|
31
|
+
draftStorageUnavailable: 'Stockage des brouillons indisponible',
|
|
32
|
+
draftEnabled: 'Récupération de brouillon activée',
|
|
33
|
+
checkStoredDrafts: 'Chercher les brouillons enregistrés',
|
|
34
|
+
storeDraft: 'Conserver le brouillon pour récupération',
|
|
35
|
+
noStoredDrafts: 'Aucun brouillon enregistré',
|
|
36
|
+
moreStoredDrafts: 'Il existe d’autres brouillons que ceux listés ici.',
|
|
37
|
+
storedDraft: 'Brouillon enregistré',
|
|
38
|
+
draftConflict: 'La version enregistrée a changé. Examinez ce brouillon à part.',
|
|
39
|
+
draftMatches: 'Ce brouillon correspond à la version enregistrée vérifiée.',
|
|
40
|
+
reviewStoredDraft: 'Examiner le brouillon',
|
|
41
|
+
restoreDraft: 'Restaurer le brouillon',
|
|
42
|
+
discardStoredDraft: 'Supprimer le brouillon',
|
|
43
|
+
draftRestored: 'Brouillon restauré localement. Enregistrez pour le publier.',
|
|
44
|
+
draftDiscarded: 'Brouillon supprimé. Vos modifications actuelles sont inchangées.',
|
|
45
|
+
draftStorageFailed: 'Stockage des brouillons indisponible. Vos modifications actuelles sont inchangées.',
|
|
46
|
+
},
|
|
47
|
+
};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { VoiceCapture, VoiceRecording, VoiceStart } from './voice-capture.js';
|
|
2
|
+
export type VoiceAttempt<Data, Phase extends string = never> = Data & {
|
|
3
|
+
readonly id: number;
|
|
4
|
+
readonly abort: AbortController;
|
|
5
|
+
readonly capture: VoiceCapture;
|
|
6
|
+
readonly maxMs: number;
|
|
7
|
+
phase: 'starting' | 'recording' | 'processing' | Phase;
|
|
8
|
+
seconds: number;
|
|
9
|
+
levels: readonly number[];
|
|
10
|
+
paused: boolean;
|
|
11
|
+
canPause: boolean;
|
|
12
|
+
};
|
|
13
|
+
export interface VoiceAttemptOptions<Data, Phase extends string> {
|
|
14
|
+
readonly valid?: (item: VoiceAttempt<Data, Phase>) => boolean;
|
|
15
|
+
readonly refused?: (result: Extract<VoiceStart, {
|
|
16
|
+
kind: 'refused';
|
|
17
|
+
}>) => void;
|
|
18
|
+
}
|
|
19
|
+
/** Owns only this viewer's microphone attempts, never the borrowed chat or native execution. */
|
|
20
|
+
export declare function useVoiceAttempt<Data extends object, Phase extends string = never>(options?: VoiceAttemptOptions<Data, Phase>): {
|
|
21
|
+
attempts: readonly VoiceAttempt<Data, Phase>[];
|
|
22
|
+
captureSequence: number;
|
|
23
|
+
current: () => readonly VoiceAttempt<Data, Phase>[];
|
|
24
|
+
start: (data: Data, configuredMaxMs?: number) => VoiceAttempt<Data, Phase>;
|
|
25
|
+
stop: (item: VoiceAttempt<Data, Phase>) => Promise<VoiceRecording | null>;
|
|
26
|
+
pause: (item: VoiceAttempt<Data, Phase>) => void;
|
|
27
|
+
resume: (item: VoiceAttempt<Data, Phase>) => void;
|
|
28
|
+
remove: (item: VoiceAttempt<Data, Phase>) => void;
|
|
29
|
+
clear: () => void;
|
|
30
|
+
valid: (item: VoiceAttempt<Data, Phase>) => boolean;
|
|
31
|
+
update: () => void;
|
|
32
|
+
};
|
|
33
|
+
//# sourceMappingURL=voice-attempt.d.ts.map
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
|
3
|
+
import { createVoiceCapture, DEFAULT_MAX_VOICE_MS } from './voice-capture.js';
|
|
4
|
+
/** Owns only this viewer's microphone attempts, never the borrowed chat or native execution. */
|
|
5
|
+
export function useVoiceAttempt(options = {}) {
|
|
6
|
+
const latest = useRef(options);
|
|
7
|
+
useLayoutEffect(() => { latest.current = options; });
|
|
8
|
+
const mounted = useRef(false), owned = useRef([]), sequence = useRef(0);
|
|
9
|
+
const timers = useRef(new Map());
|
|
10
|
+
const [, changed] = useState(0);
|
|
11
|
+
const update = () => { if (mounted.current)
|
|
12
|
+
changed(value => value + 1); };
|
|
13
|
+
const valid = (item) => mounted.current && owned.current.includes(item) && !item.abort.signal.aborted && (latest.current.valid?.(item) ?? true);
|
|
14
|
+
const stopTimer = (item) => { clearInterval(timers.current.get(item)); timers.current.delete(item); };
|
|
15
|
+
const remove = (item) => {
|
|
16
|
+
owned.current = owned.current.filter(current => current !== item);
|
|
17
|
+
stopTimer(item);
|
|
18
|
+
item.abort.abort();
|
|
19
|
+
item.capture.cancel();
|
|
20
|
+
update();
|
|
21
|
+
};
|
|
22
|
+
const clear = () => { for (const item of [...owned.current])
|
|
23
|
+
remove(item); };
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
if (typeof window === 'undefined' || typeof document === 'undefined')
|
|
26
|
+
return;
|
|
27
|
+
const cancelRecording = () => {
|
|
28
|
+
for (const item of [...owned.current])
|
|
29
|
+
if (item.phase === 'starting' || item.phase === 'recording')
|
|
30
|
+
remove(item);
|
|
31
|
+
};
|
|
32
|
+
const escape = (event) => { if (event.key === 'Escape')
|
|
33
|
+
cancelRecording(); };
|
|
34
|
+
const hidden = () => { if (document.hidden)
|
|
35
|
+
cancelRecording(); };
|
|
36
|
+
window.addEventListener('keydown', escape, true);
|
|
37
|
+
window.addEventListener('blur', cancelRecording);
|
|
38
|
+
document.addEventListener('visibilitychange', hidden);
|
|
39
|
+
return () => {
|
|
40
|
+
window.removeEventListener('keydown', escape, true);
|
|
41
|
+
window.removeEventListener('blur', cancelRecording);
|
|
42
|
+
document.removeEventListener('visibilitychange', hidden);
|
|
43
|
+
};
|
|
44
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- handlers cancel only the currently owned captures, not admitted or processing work
|
|
45
|
+
}, []);
|
|
46
|
+
const pause = (item) => { if (valid(item) && item.phase === 'recording' && item.capture.pause()) {
|
|
47
|
+
item.paused = true;
|
|
48
|
+
update();
|
|
49
|
+
} };
|
|
50
|
+
const resume = (item) => { if (valid(item) && item.phase === 'recording' && item.capture.resume()) {
|
|
51
|
+
item.paused = false;
|
|
52
|
+
update();
|
|
53
|
+
} };
|
|
54
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- teardown releases the attempts owned at unmount, including ones created after mount
|
|
55
|
+
useEffect(() => { mounted.current = true; return () => { mounted.current = false; clear(); }; }, []);
|
|
56
|
+
const start = (data, configuredMaxMs = DEFAULT_MAX_VOICE_MS) => {
|
|
57
|
+
const maxMs = Number.isFinite(configuredMaxMs) && configuredMaxMs > 0 ? configuredMaxMs : Infinity;
|
|
58
|
+
const item = { ...data, id: ++sequence.current, maxMs, abort: new AbortController(), capture: createVoiceCapture({ maxMs }), phase: 'starting', seconds: 0, levels: [], paused: false, canPause: false };
|
|
59
|
+
owned.current = [...owned.current, item];
|
|
60
|
+
update();
|
|
61
|
+
// No await before start: microphone permission belongs to the initiating gesture.
|
|
62
|
+
void item.capture.start().then(result => {
|
|
63
|
+
if (!valid(item)) {
|
|
64
|
+
remove(item);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
if (result.kind === 'refused') {
|
|
68
|
+
remove(item);
|
|
69
|
+
latest.current.refused?.(result);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
item.phase = 'recording';
|
|
73
|
+
update();
|
|
74
|
+
let elapsedMs = 0;
|
|
75
|
+
timers.current.set(item, setInterval(() => {
|
|
76
|
+
if (!valid(item)) {
|
|
77
|
+
remove(item);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
const progress = item.capture.progress();
|
|
81
|
+
if (!progress)
|
|
82
|
+
return;
|
|
83
|
+
item.seconds = Math.floor(progress.elapsedMs / 1000);
|
|
84
|
+
item.canPause = progress.canPause;
|
|
85
|
+
item.paused = progress.paused;
|
|
86
|
+
if (progress.level !== undefined && !progress.paused && progress.elapsedMs > elapsedMs)
|
|
87
|
+
item.levels = [...item.levels.slice(-31), progress.level];
|
|
88
|
+
elapsedMs = progress.elapsedMs;
|
|
89
|
+
update();
|
|
90
|
+
}, 100));
|
|
91
|
+
}).catch(() => {
|
|
92
|
+
if (!valid(item)) {
|
|
93
|
+
remove(item);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
remove(item);
|
|
97
|
+
latest.current.refused?.({ kind: 'refused', code: 'failed', reason: 'The microphone could not be started.' });
|
|
98
|
+
});
|
|
99
|
+
return item;
|
|
100
|
+
};
|
|
101
|
+
const stop = async (item) => {
|
|
102
|
+
if (!valid(item))
|
|
103
|
+
return null;
|
|
104
|
+
if (item.phase === 'starting') {
|
|
105
|
+
remove(item);
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
if (item.phase !== 'recording')
|
|
109
|
+
return null;
|
|
110
|
+
item.phase = 'processing';
|
|
111
|
+
stopTimer(item);
|
|
112
|
+
update();
|
|
113
|
+
const recording = await item.capture.stop().catch(() => null);
|
|
114
|
+
if (!valid(item))
|
|
115
|
+
return null;
|
|
116
|
+
if (recording)
|
|
117
|
+
item.seconds = Math.max(0, Math.round((recording.endedAt - recording.startedAt) / 1000));
|
|
118
|
+
update();
|
|
119
|
+
return recording;
|
|
120
|
+
};
|
|
121
|
+
return { attempts: owned.current, captureSequence: sequence.current, current: () => owned.current, start, stop, pause, resume, remove, clear, valid, update };
|
|
122
|
+
}
|
package/dist/voice-capture.d.ts
CHANGED
|
@@ -18,8 +18,20 @@ export interface VoiceRecording {
|
|
|
18
18
|
/** The recording reached `maxMs` and stopped on its own. */
|
|
19
19
|
readonly truncated: boolean;
|
|
20
20
|
}
|
|
21
|
+
/** Live capture facts. PCM elapsed time and level come from kept samples, never a synthetic waveform. */
|
|
22
|
+
export interface VoiceCaptureProgress {
|
|
23
|
+
readonly elapsedMs: number;
|
|
24
|
+
/** Root mean square amplitude in [0, 1]; unavailable on the MediaRecorder fallback. */
|
|
25
|
+
readonly level: number | undefined;
|
|
26
|
+
readonly paused: boolean;
|
|
27
|
+
readonly canPause: boolean;
|
|
28
|
+
}
|
|
21
29
|
export interface VoiceCapture {
|
|
22
30
|
readonly start: () => Promise<VoiceStart>;
|
|
31
|
+
readonly progress: () => VoiceCaptureProgress | null;
|
|
32
|
+
/** PCM only: pauses/resumes appending samples. False when unavailable or already ended. */
|
|
33
|
+
readonly pause: () => boolean;
|
|
34
|
+
readonly resume: () => boolean;
|
|
23
35
|
/** Ends the recording and returns it; null when nothing was recorded (never started, cancelled, or empty). */
|
|
24
36
|
readonly stop: () => Promise<VoiceRecording | null>;
|
|
25
37
|
/** Ends the recording, releases the microphone and discards the audio. */
|
package/dist/voice-capture.js
CHANGED
|
@@ -213,7 +213,7 @@ export function createVoiceCapture(options = {}) {
|
|
|
213
213
|
}
|
|
214
214
|
let cancelled;
|
|
215
215
|
const current = {
|
|
216
|
-
stream, context, source: null, node: null, chunks: [], samples: 0, startedAt: null, truncated: false, cancelled: false, ended: false,
|
|
216
|
+
stream, context, source: null, node: null, chunks: [], samples: 0, startedAt: null, truncated: false, cancelled: false, ended: false, paused: false, level: 0,
|
|
217
217
|
maxSamples: Number.isFinite(maxMs) && maxMs > 0 ? Math.floor((maxMs / 1000) * WAV_RATE) : Number.POSITIVE_INFINITY,
|
|
218
218
|
cancel: () => cancelled(),
|
|
219
219
|
};
|
|
@@ -251,7 +251,7 @@ export function createVoiceCapture(options = {}) {
|
|
|
251
251
|
return refused('unsupported', error instanceof Error ? error.message : String(error));
|
|
252
252
|
}
|
|
253
253
|
current.node.onaudioprocess = event => {
|
|
254
|
-
if (current.ended)
|
|
254
|
+
if (current.ended || current.paused)
|
|
255
255
|
return;
|
|
256
256
|
const input = event.inputBuffer.getChannelData(0);
|
|
257
257
|
if (current.startedAt === null) {
|
|
@@ -261,6 +261,10 @@ export function createVoiceCapture(options = {}) {
|
|
|
261
261
|
const chunk = downsample(input, context.sampleRate);
|
|
262
262
|
const room = current.maxSamples - current.samples;
|
|
263
263
|
const kept = chunk.length > room ? chunk.subarray(0, room) : chunk;
|
|
264
|
+
let squares = 0;
|
|
265
|
+
for (const sample of kept)
|
|
266
|
+
squares += sample * sample;
|
|
267
|
+
current.level = kept.length ? Math.min(1, Math.sqrt(squares / kept.length)) : 0;
|
|
264
268
|
current.chunks.push(kept);
|
|
265
269
|
current.samples += kept.length;
|
|
266
270
|
if (current.samples >= current.maxSamples)
|
|
@@ -298,6 +302,17 @@ export function createVoiceCapture(options = {}) {
|
|
|
298
302
|
}
|
|
299
303
|
};
|
|
300
304
|
return {
|
|
305
|
+
progress() {
|
|
306
|
+
if (wav)
|
|
307
|
+
return { elapsedMs: wav.samples / WAV_RATE * 1000, level: wav.level, paused: wav.paused, canPause: !wav.ended && wav.startedAt !== null };
|
|
308
|
+
if (!session || session.startedAt === null)
|
|
309
|
+
return null;
|
|
310
|
+
return { elapsedMs: Math.min(Math.max(0, (session.stoppedAt ?? now()) - session.startedAt), maxMs > 0 ? maxMs : Infinity), level: undefined, paused: false, canPause: false };
|
|
311
|
+
},
|
|
312
|
+
pause() { if (!wav || wav.ended || wav.startedAt === null)
|
|
313
|
+
return false; wav.paused = true; return true; },
|
|
314
|
+
resume() { if (!wav || wav.ended || wav.startedAt === null)
|
|
315
|
+
return false; wav.paused = false; return true; },
|
|
301
316
|
async start() {
|
|
302
317
|
if (session || wav || opening)
|
|
303
318
|
return { kind: 'refused', code: 'failed', reason: 'Already recording.' };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hachej/boring-ui-kit",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.27",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"private": false,
|
|
6
6
|
"type": "module",
|
|
@@ -59,6 +59,18 @@
|
|
|
59
59
|
"types": "./dist/chat-view.d.ts",
|
|
60
60
|
"import": "./dist/chat-view.js"
|
|
61
61
|
},
|
|
62
|
+
"./chat-react": {
|
|
63
|
+
"types": "./dist/chat-react.d.ts",
|
|
64
|
+
"import": "./dist/chat-react.js"
|
|
65
|
+
},
|
|
66
|
+
"./chat-react/voice": {
|
|
67
|
+
"types": "./dist/chat-voice.d.ts",
|
|
68
|
+
"import": "./dist/chat-voice.js"
|
|
69
|
+
},
|
|
70
|
+
"./chat-text": {
|
|
71
|
+
"types": "./dist/chat-text.d.ts",
|
|
72
|
+
"import": "./dist/chat-text.js"
|
|
73
|
+
},
|
|
62
74
|
"./voice-capture": {
|
|
63
75
|
"types": "./dist/voice-capture.d.ts",
|
|
64
76
|
"import": "./dist/voice-capture.js"
|
|
@@ -138,7 +150,7 @@
|
|
|
138
150
|
}
|
|
139
151
|
},
|
|
140
152
|
"peerDependencies": {
|
|
141
|
-
"@hachej/boring-files": "^0.4.
|
|
153
|
+
"@hachej/boring-files": "^0.4.27",
|
|
142
154
|
"@earendil-works/pi-durable": "~1.2.0",
|
|
143
155
|
"react": "^19.0.0",
|
|
144
156
|
"react-dom": "^19.0.0",
|
package/r/agent-target.json
CHANGED
|
@@ -4,24 +4,20 @@
|
|
|
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.
|
|
8
|
-
"@hachej/boring-feedback@^0.4.
|
|
7
|
+
"@hachej/boring-ui-kit@^0.4.27",
|
|
8
|
+
"@hachej/boring-feedback@^0.4.27",
|
|
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.
|
|
15
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
16
|
-
"button",
|
|
17
|
-
"card",
|
|
18
|
-
"message",
|
|
19
|
-
"bubble"
|
|
14
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.27/r/app-shell.json",
|
|
15
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.27/r/pi-chat.json"
|
|
20
16
|
],
|
|
21
17
|
"files": [
|
|
22
18
|
{
|
|
23
19
|
"path": "registry/agent-target/agent-target.tsx",
|
|
24
|
-
"content": "'use client';\n\n// Host presentation over the app's borrowed chat controller, not a feedback report or a second Pi lifecycle.\nimport { createPortal } from 'react-dom';\nimport { useCallback, useContext, useEffect, useLayoutEffect, useRef, useState } from 'react';\nimport type { CSSProperties } from 'react';\nimport type { EntryRecord, JsonObject, Submission } from '@earendil-works/pi-durable';\nimport type { NativeChatController } from '@hachej/boring-ui-kit/native-chat';\nimport { createPicker, createPrivacyPolicy, isExcluded } from '@hachej/boring-feedback/page';\nimport type { Picker } from '@hachej/boring-feedback/page';\nimport { Button } from '@/components/ui/button';\nimport { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';\nimport { Message, MessageContent } from '@/components/ui/message';\nimport { Bubble, BubbleContent } from '@/components/ui/bubble';\nimport { useAgentLayer } from '../app-shell/agent-layer';\nimport { ScreenActiveContext } from '../app-shell/agent-context';\nimport { useDictation } from '../app-shell/use-dictation';\nimport { useVisualViewport } from '../app-shell/use-visual-viewport';\nimport { object } from '../pi-chat/rows';\nimport { agentTargetLabels } from './agent-target-labels';\nimport type { AgentTargetLabels } from './agent-target-labels';\n\nexport interface AgentTargetResult { readonly target: JsonObject; readonly reply: string; readonly ok: boolean }\nexport interface AgentTargetModeProps {\n readonly enabled: boolean;\n readonly transcribe: (audio: Blob, signal: AbortSignal) => Promise<string>;\n readonly onSettled: (result: AgentTargetResult) => void;\n readonly attribute?: string;\n readonly labels?: Partial<AgentTargetLabels> | undefined;\n}\ntype Stage = 'dictating' | 'sending' | 'observing' | 'settled' | 'cancelled';\ninterface Session {\n readonly target: JsonObject;\n readonly block: string;\n readonly element: Element;\n readonly controller: NativeChatController;\n readonly abort: AbortController;\n readonly onSettled: AgentTargetModeProps['onSettled'];\n readonly labels: AgentTargetLabels;\n stage: Stage;\n}\ninterface Presentation { readonly element: Element; readonly stage: Stage; readonly reply: string; readonly problem?: string }\n\nfunction freeze(value: unknown): void {\n if (value && typeof value === 'object') { for (const child of Object.values(value)) freeze(child); Object.freeze(value); }\n}\nfunction contextOf(element: Element, attribute: string, labels: AgentTargetLabels): { readonly target: JsonObject; readonly block: string } {\n const raw = element.getAttribute(attribute) ?? '';\n if (new TextEncoder().encode(raw).length > 1024) throw new Error(labels.contextTooLarge);\n let target: unknown;\n try { target = JSON.parse(raw); } catch { throw new Error(labels.invalidContext); }\n if (!target || typeof target !== 'object' || Array.isArray(target)) throw new Error(labels.invalidContext);\n const json = JSON.stringify(target).replace(/</g, '\\\\u003c');\n if (new TextEncoder().encode(json).length > 1024) throw new Error(labels.contextTooLarge);\n freeze(target);\n return { target: target as JsonObject, block: `<target>${json}</target>` };\n}\nconst textOf = (entries: readonly EntryRecord[]): string => entries.flatMap(entry => (entry.model ?? []).flatMap(message => message.role === 'assistant'\n ? message.content.flatMap(part => part.type === 'text' ? [part.text] : []) : [])).filter(Boolean).join('\\n\\n');\n\n/** A local observation only. Stream attribution uses native run inputs; settlement uses the native submission's exact answer entry. */\nfunction observe(session: Session, submission: Submission, show: (reply: string) => void, settle: (reply: string, ok: boolean, problem?: string) => void): void {\n let timer: ReturnType<typeof setTimeout> | undefined;\n const { controller, abort } = session;\n const stream = () => {\n if (abort.signal.aborted || session.stage !== 'observing') return;\n const view = controller.getSnapshot().view;\n const live = object(view?.docs['pi.live']), run = object(live?.['run']);\n if (!Array.isArray(run?.['inputs']) || !run['inputs'].includes(submission.id)) return;\n const generation = object(live?.['generation']), message = object(generation?.['message']);\n const partial = Array.isArray(message?.['content']) ? message['content'].map(object).flatMap(part => part?.['type'] === 'text' && typeof part['text'] === 'string' ? [part['text']] : []).join('\\n') : '';\n show([textOf((view?.entries ?? []).filter(entry => entry.byTaskId === run['taskId'])), partial].filter(Boolean).join('\\n\\n'));\n };\n const unsubscribe = controller.subscribe(stream);\n const stop = () => { clearTimeout(timer); unsubscribe(); abort.signal.removeEventListener('abort', stop); };\n abort.signal.addEventListener('abort', stop, { once: true });\n const poll = async () => {\n try {\n const record = await controller.submissionStatus(submission);\n if (abort.signal.aborted) return;\n stream();\n if (record.status === 'unanswered') { stop(); settle('', false, session.labels.unanswered); return; }\n if (record.status === 'done' && record.type === 'input') {\n const view = controller.getSnapshot().view;\n const answer = view?.entries.find(entry => entry.id === record.answer);\n if (answer) {\n const entries = answer.byTaskId === undefined ? [answer] : view!.entries.filter(entry => entry.byTaskId === answer.byTaskId);\n stop(); settle(textOf(entries), true); return;\n }\n // A coalesced watch may arrive after status. An unavailable answer stays unconfirmed, never guessed from neighbouring text.\n }\n if (!abort.signal.aborted) timer = setTimeout(() => { void poll(); }, 150);\n } catch {\n stop();\n if (!abort.signal.aborted) settle('', false, session.labels.observationFailed);\n }\n };\n stream();\n void poll();\n}\n\n/** Tap only explicitly marked app data, dictate once and show its native answer without opening the app's chat. */\nexport function AgentTargetMode({ enabled, transcribe, onSettled, labels, attribute = 'data-agent-target' }: AgentTargetModeProps) {\n const words = { ...agentTargetLabels, ...labels };\n const { agent } = useAgentLayer();\n const active = useContext(ScreenActiveContext);\n const viewport = useVisualViewport();\n const session = useRef<Session | null>(null);\n const picker = useRef<Picker | null>(null);\n const box = useRef<HTMLDivElement>(null);\n const latest = useRef({ onSettled, words });\n latest.current = { onSettled, words };\n const [shown, setShown] = useState<Presentation | null>(null);\n const [place, setPlace] = useState<CSSProperties>();\n const settle = (selected: Session, reply: string, ok: boolean, problem?: string) => {\n if (session.current !== selected || selected.abort.signal.aborted || selected.stage === 'settled') return;\n selected.stage = 'settled';\n setShown({ element: selected.element, stage: 'settled', reply, ...(problem ? { problem } : {}) });\n selected.onSettled({ target: selected.target, reply, ok });\n };\n const dictation = useDictation({ transcribe, labels: words, onText: async spoken => {\n const selected = session.current;\n if (!selected || selected.stage !== 'dictating') return;\n selected.stage = 'sending';\n setShown({ element: selected.element, stage: 'sending', reply: '' });\n let submission: Submission | undefined;\n try {\n await selected.controller.connect();\n if (selected.abort.signal.aborted || session.current !== selected) return;\n submission = await selected.controller.send('followUp', { text: `${selected.block}\\n\\n${spoken}`, restore: { text: spoken } });\n }\n catch {\n if (selected.abort.signal.aborted || session.current !== selected) return;\n const snapshot = selected.controller.getSnapshot();\n if (!snapshot.disposed) {\n const draft = snapshot.draft.text;\n selected.controller.setText(`${draft}${draft && !/\\s$/.test(draft) ? ' ' : ''}${spoken}`);\n }\n settle(selected, '', false, snapshot.disposed ? selected.labels.unavailable : selected.labels.sendFailed);\n return;\n }\n if (selected.abort.signal.aborted || session.current !== selected) return;\n if (!submission) { settle(selected, '', false, selected.labels.unconfirmed); return; }\n selected.stage = 'observing';\n setShown({ element: selected.element, stage: 'observing', reply: '' });\n observe(selected, submission, reply => {\n if (session.current === selected && !selected.abort.signal.aborted) setShown({ element: selected.element, stage: 'observing', reply });\n }, (reply, ok, problem) => settle(selected, reply, ok, problem));\n } });\n const { start, cancel: cancelDictation } = dictation;\n const cancel = useCallback(() => {\n const old = session.current;\n session.current = null;\n if (old) { old.stage = 'cancelled'; old.abort.abort(); }\n cancelDictation();\n picker.current?.suspend(true);\n setShown(null);\n }, [cancelDictation]);\n\n useEffect(() => {\n if (!enabled || !active || !agent) return;\n if (!/^data-[a-z][a-z0-9-]*$/.test(attribute)) throw new TypeError('Agent target attribute must be a data-* attribute name');\n const selector = `[${attribute}]`;\n let pickedGesture: Element | null = null;\n const marked = (element: Element | null) => element && !isExcluded(element) ? element.closest(selector) : null;\n const gate = (event: Event) => {\n const element = event.target instanceof Element ? event.target : null;\n if (event.type === 'pointerdown') pickedGesture = null;\n if (element && box.current?.contains(element)) return;\n if (session.current || box.current) { if (event.type === 'pointerdown') cancel(); return; }\n if (element && isExcluded(element)) { picker.current?.suspend(true); return; }\n picker.current?.suspend(!marked(element));\n };\n const key = (event: KeyboardEvent) => {\n if (event.key === 'Escape' && (session.current || box.current || picker.current?.state().suspended === false)) { event.preventDefault(); event.stopImmediatePropagation(); cancel(); }\n };\n const click = (event: MouseEvent) => {\n // Suspending the picker on touch pointerup removes its touchend listener. Consume only that tap's compatibility click.\n if (pickedGesture && event.target instanceof Node && pickedGesture.contains(event.target)) { pickedGesture = null; event.preventDefault(); event.stopImmediatePropagation(); }\n };\n // Registered before the picker: unmarked actions remain ordinary app actions, not consumed empty picks.\n window.addEventListener('pointerdown', gate, true);\n window.addEventListener('pointermove', gate, true);\n window.addEventListener('keydown', key, true);\n window.addEventListener('click', click, true);\n const created = createPicker({ root: document.body, policy: createPrivacyPolicy(), tap: 'pick', wheel: false, keys: 'outside-ignored',\n hitTest: (x, y) => {\n const top = document.elementsFromPoint(x, y).find(element => !isExcluded(element));\n const target = marked(top ?? null);\n return target ? [target] : [];\n },\n onPick: element => {\n const target = marked(element);\n if (!target || session.current) return;\n pickedGesture = target;\n created.suspend(true);\n try {\n const context = contextOf(target, attribute, latest.current.words);\n session.current = { ...context, element: target, controller: agent, abort: new AbortController(), onSettled: latest.current.onSettled, labels: latest.current.words, stage: 'dictating' };\n setShown({ element: target, stage: 'dictating', reply: '' });\n start();\n } catch (error) { setShown({ element: target, stage: 'settled', reply: '', problem: error instanceof Error ? error.message : latest.current.words.invalidContext }); }\n },\n });\n picker.current = created;\n void created.start();\n created.suspend(true);\n return () => {\n window.removeEventListener('pointerdown', gate, true);\n window.removeEventListener('pointermove', gate, true);\n window.removeEventListener('keydown', key, true);\n window.removeEventListener('click', click, true);\n cancel();\n created.dispose();\n if (picker.current === created) picker.current = null;\n };\n }, [enabled, active, agent, attribute, start, cancel]);\n\n useEffect(() => {\n const selected = session.current;\n if (dictation.problem && selected?.stage === 'dictating') settle(selected, '', false, dictation.problem);\n }, [dictation.problem]);\n useLayoutEffect(() => {\n if (!shown || !box.current) return;\n const node = box.current;\n const measure = () => {\n const rect = shown.element.getBoundingClientRect();\n const topEdge = (viewport?.top ?? 0) + 8, bottom = (viewport ? viewport.top + viewport.height : window.innerHeight) - 8;\n const height = Math.min(node.getBoundingClientRect().height, Math.max(0, bottom - topEdge));\n const lowest = Math.max(topEdge, bottom - height);\n const top = Math.max(topEdge, Math.min(rect.bottom + 8 + height <= bottom ? rect.bottom + 8 : rect.top - height - 8 >= topEdge ? rect.top - height - 8 : lowest, lowest));\n const width = Math.min(Math.max(240, rect.width), window.innerWidth - 16);\n const next = { position: 'fixed' as const, top, left: Math.min(Math.max(8, rect.left), Math.max(8, window.innerWidth - width - 8)), width, maxHeight: Math.max(0, bottom - top), overflowY: 'auto' as const };\n setPlace(previous => JSON.stringify(previous) === JSON.stringify(next) ? previous : next);\n };\n measure();\n window.addEventListener('resize', measure);\n window.addEventListener('scroll', measure, true);\n const observer = typeof ResizeObserver === 'undefined' ? undefined : new ResizeObserver(measure);\n observer?.observe(node);\n return () => { window.removeEventListener('resize', measure); window.removeEventListener('scroll', measure, true); observer?.disconnect(); };\n }, [shown, viewport]);\n\n if (!enabled || !active || !shown || typeof document === 'undefined') return null;\n const phase = shown.stage === 'dictating' ? dictation.phase : shown.stage;\n return createPortal(<Card ref={box} size=\"sm\" data-feedback-ignore=\"\" data-boring=\"agent-target\" data-testid=\"agent-target-bar\" data-phase={phase}\n role=\"region\" aria-label={words.title} className=\"app-shell fixed z-50\" style={place}>\n <CardHeader><CardTitle>{words.title}</CardTitle></CardHeader>\n <CardContent>\n {shown.problem ? <p role=\"alert\" data-testid=\"agent-target-problem\">{shown.problem}</p>\n : shown.reply ? <Message align=\"start\"><MessageContent><Bubble variant=\"secondary\"><BubbleContent data-testid=\"agent-target-reply\" className=\"whitespace-pre-wrap\">{shown.reply}</BubbleContent></Bubble></MessageContent></Message>\n : <p role=\"status\">{phase === 'starting' ? words.opening : phase === 'recording' ? words.listening(dictation.seconds) : phase === 'transcribing' ? words.transcribing : phase === 'settled' ? words.answered : words.waiting}</p>}\n </CardContent>\n <CardFooter className=\"flex gap-2\">\n {shown.stage === 'dictating' && <Button data-testid=\"agent-target-stop\" className=\"min-h-11 min-w-11\" disabled={phase !== 'recording'} onClick={dictation.finish}>{words.stop}</Button>}\n <Button variant=\"ghost\" data-testid=\"agent-target-close\" className=\"min-h-11 min-w-11\" onClick={cancel}>{shown.stage === 'settled' ? words.close : words.cancel}</Button>\n </CardFooter>\n </Card>, document.body);\n}\n",
|
|
20
|
+
"content": "'use client';\n\n// Host presentation over the app's borrowed chat controller, not a feedback report or a second Pi lifecycle.\nimport { createPortal } from 'react-dom';\nimport { useCallback, useContext, useEffect, useLayoutEffect, useRef, useState } from 'react';\nimport type { CSSProperties } from 'react';\nimport type { EntryRecord, JsonObject, Submission } from '@earendil-works/pi-durable';\nimport type { NativeChatController } from '@hachej/boring-ui-kit/native-chat';\nimport { appendToDraft } from '@hachej/boring-ui-kit/chat-text';\nimport { createPicker, createPrivacyPolicy, isExcluded } from '@hachej/boring-feedback/page';\nimport type { Picker } from '@hachej/boring-feedback/page';\nimport { Button } from '@/components/ui/button';\nimport { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';\nimport { Message, MessageContent } from '@/components/ui/message';\nimport { Bubble, BubbleContent } from '@/components/ui/bubble';\nimport { useAgentLayer } from '../app-shell/agent-layer';\nimport { ScreenActiveContext } from '../app-shell/agent-context';\nimport { useDictation } from '../app-shell/use-dictation';\nimport { useVisualViewport } from '../app-shell/use-visual-viewport';\nimport { object } from '../pi-chat/rows';\nimport { agentTargetLabels } from './agent-target-labels';\nimport type { AgentTargetLabels } from './agent-target-labels';\n\nexport interface AgentTargetResult { readonly target: JsonObject; readonly reply: string; readonly ok: boolean }\nexport interface AgentTargetModeProps {\n readonly enabled: boolean;\n readonly transcribe: (audio: Blob, signal: AbortSignal) => Promise<string>;\n readonly onSettled: (result: AgentTargetResult) => void;\n readonly attribute?: string;\n readonly labels?: Partial<AgentTargetLabels> | undefined;\n}\ntype Stage = 'dictating' | 'sending' | 'observing' | 'settled' | 'cancelled';\ninterface Session {\n readonly target: JsonObject;\n readonly block: string;\n readonly element: Element;\n readonly controller: NativeChatController;\n readonly abort: AbortController;\n readonly onSettled: AgentTargetModeProps['onSettled'];\n readonly labels: AgentTargetLabels;\n stage: Stage;\n}\ninterface Presentation { readonly element: Element; readonly stage: Stage; readonly reply: string; readonly problem?: string }\n\nfunction freeze(value: unknown): void {\n if (value && typeof value === 'object') { for (const child of Object.values(value)) freeze(child); Object.freeze(value); }\n}\nfunction contextOf(element: Element, attribute: string, labels: AgentTargetLabels): { readonly target: JsonObject; readonly block: string } {\n const raw = element.getAttribute(attribute) ?? '';\n if (new TextEncoder().encode(raw).length > 1024) throw new Error(labels.contextTooLarge);\n let target: unknown;\n try { target = JSON.parse(raw); } catch { throw new Error(labels.invalidContext); }\n if (!target || typeof target !== 'object' || Array.isArray(target)) throw new Error(labels.invalidContext);\n const json = JSON.stringify(target).replace(/</g, '\\\\u003c');\n if (new TextEncoder().encode(json).length > 1024) throw new Error(labels.contextTooLarge);\n freeze(target);\n return { target: target as JsonObject, block: `<target>${json}</target>` };\n}\nconst textOf = (entries: readonly EntryRecord[]): string => entries.flatMap(entry => (entry.model ?? []).flatMap(message => message.role === 'assistant'\n ? message.content.flatMap(part => part.type === 'text' ? [part.text] : []) : [])).filter(Boolean).join('\\n\\n');\n\n/** A local observation only. Stream attribution uses native run inputs; settlement uses the native submission's exact answer entry. */\nfunction observe(session: Session, submission: Submission, show: (reply: string) => void, settle: (reply: string, ok: boolean, problem?: string) => void): void {\n let timer: ReturnType<typeof setTimeout> | undefined;\n const { controller, abort } = session;\n const stream = () => {\n if (abort.signal.aborted || session.stage !== 'observing') return;\n const view = controller.getSnapshot().view;\n const live = object(view?.docs['pi.live']), run = object(live?.['run']);\n if (!Array.isArray(run?.['inputs']) || !run['inputs'].includes(submission.id)) return;\n const generation = object(live?.['generation']), message = object(generation?.['message']);\n const partial = Array.isArray(message?.['content']) ? message['content'].map(object).flatMap(part => part?.['type'] === 'text' && typeof part['text'] === 'string' ? [part['text']] : []).join('\\n') : '';\n show([textOf((view?.entries ?? []).filter(entry => entry.byTaskId === run['taskId'])), partial].filter(Boolean).join('\\n\\n'));\n };\n const unsubscribe = controller.subscribe(stream);\n const stop = () => { clearTimeout(timer); unsubscribe(); abort.signal.removeEventListener('abort', stop); };\n abort.signal.addEventListener('abort', stop, { once: true });\n const poll = async () => {\n try {\n const record = await controller.submissionStatus(submission);\n if (abort.signal.aborted) return;\n stream();\n if (record.status === 'unanswered') { stop(); settle('', false, session.labels.unanswered); return; }\n if (record.status === 'done' && record.type === 'input') {\n const view = controller.getSnapshot().view;\n const answer = view?.entries.find(entry => entry.id === record.answer);\n if (answer) {\n const entries = answer.byTaskId === undefined ? [answer] : view!.entries.filter(entry => entry.byTaskId === answer.byTaskId);\n stop(); settle(textOf(entries), true); return;\n }\n // A coalesced watch may arrive after status. An unavailable answer stays unconfirmed, never guessed from neighbouring text.\n }\n if (!abort.signal.aborted) timer = setTimeout(() => { void poll(); }, 150);\n } catch {\n stop();\n if (!abort.signal.aborted) settle('', false, session.labels.observationFailed);\n }\n };\n stream();\n void poll();\n}\n\n/** Tap only explicitly marked app data, dictate once and show its native answer without opening the app's chat. */\nexport function AgentTargetMode({ enabled, transcribe, onSettled, labels, attribute = 'data-agent-target' }: AgentTargetModeProps) {\n const words = { ...agentTargetLabels, ...labels };\n const { agent } = useAgentLayer();\n const active = useContext(ScreenActiveContext);\n const viewport = useVisualViewport();\n const session = useRef<Session | null>(null);\n const picker = useRef<Picker | null>(null);\n const box = useRef<HTMLDivElement>(null);\n const latest = useRef({ onSettled, words });\n useLayoutEffect(() => { latest.current = { onSettled, words }; });\n const [shown, setShown] = useState<Presentation | null>(null);\n const [place, setPlace] = useState<CSSProperties>();\n const settle = (selected: Session, reply: string, ok: boolean, problem?: string) => {\n if (session.current !== selected || selected.abort.signal.aborted || selected.stage === 'settled') return;\n selected.stage = 'settled';\n setShown({ element: selected.element, stage: 'settled', reply, ...(problem ? { problem } : {}) });\n selected.onSettled({ target: selected.target, reply, ok });\n };\n const dictation = useDictation({ transcribe, labels: words, onText: async spoken => {\n const selected = session.current;\n if (!selected || selected.stage !== 'dictating') return;\n selected.stage = 'sending';\n setShown({ element: selected.element, stage: 'sending', reply: '' });\n let submission: Submission | undefined;\n try {\n await selected.controller.connect();\n if (selected.abort.signal.aborted || session.current !== selected) return;\n submission = await selected.controller.send('followUp', { text: `${selected.block}\\n\\n${spoken}`, restore: { text: spoken } });\n }\n catch {\n if (selected.abort.signal.aborted || session.current !== selected) return;\n const snapshot = selected.controller.getSnapshot();\n if (!snapshot.disposed) {\n const draft = snapshot.draft.text;\n selected.controller.setText(appendToDraft(draft, spoken));\n }\n settle(selected, '', false, snapshot.disposed ? selected.labels.unavailable : selected.labels.sendFailed);\n return;\n }\n if (selected.abort.signal.aborted || session.current !== selected) return;\n if (!submission) { settle(selected, '', false, selected.labels.unconfirmed); return; }\n selected.stage = 'observing';\n setShown({ element: selected.element, stage: 'observing', reply: '' });\n observe(selected, submission, reply => {\n if (session.current === selected && !selected.abort.signal.aborted) setShown({ element: selected.element, stage: 'observing', reply });\n }, (reply, ok, problem) => settle(selected, reply, ok, problem));\n } });\n const { start, cancel: cancelDictation } = dictation;\n const cancel = useCallback(() => {\n const old = session.current;\n session.current = null;\n if (old) { old.stage = 'cancelled'; old.abort.abort(); }\n cancelDictation();\n picker.current?.suspend(true);\n setShown(null);\n }, [cancelDictation]);\n\n useEffect(() => {\n if (!enabled || !active || !agent) return;\n if (!/^data-[a-z][a-z0-9-]*$/.test(attribute)) throw new TypeError('Agent target attribute must be a data-* attribute name');\n const selector = `[${attribute}]`;\n let pickedGesture: Element | null = null;\n const marked = (element: Element | null) => element && !isExcluded(element) ? element.closest(selector) : null;\n const gate = (event: Event) => {\n const element = event.target instanceof Element ? event.target : null;\n if (event.type === 'pointerdown') pickedGesture = null;\n if (element && box.current?.contains(element)) return;\n if (session.current || box.current) { if (event.type === 'pointerdown') cancel(); return; }\n if (element && isExcluded(element)) { picker.current?.suspend(true); return; }\n picker.current?.suspend(!marked(element));\n };\n const key = (event: KeyboardEvent) => {\n if (event.key === 'Escape' && (session.current || box.current || picker.current?.state().suspended === false)) { event.preventDefault(); event.stopImmediatePropagation(); cancel(); }\n };\n const click = (event: MouseEvent) => {\n // Suspending the picker on touch pointerup removes its touchend listener. Consume only that tap's compatibility click.\n if (pickedGesture && event.target instanceof Node && pickedGesture.contains(event.target)) { pickedGesture = null; event.preventDefault(); event.stopImmediatePropagation(); }\n };\n // Registered before the picker: unmarked actions remain ordinary app actions, not consumed empty picks.\n window.addEventListener('pointerdown', gate, true);\n window.addEventListener('pointermove', gate, true);\n window.addEventListener('keydown', key, true);\n window.addEventListener('click', click, true);\n const created = createPicker({ root: document.body, policy: createPrivacyPolicy(), tap: 'pick', wheel: false, keys: 'outside-ignored',\n hitTest: (x, y) => {\n const top = document.elementsFromPoint(x, y).find(element => !isExcluded(element));\n const target = marked(top ?? null);\n return target ? [target] : [];\n },\n onPick: element => {\n const target = marked(element);\n if (!target || session.current) return;\n pickedGesture = target;\n created.suspend(true);\n try {\n const context = contextOf(target, attribute, latest.current.words);\n session.current = { ...context, element: target, controller: agent, abort: new AbortController(), onSettled: latest.current.onSettled, labels: latest.current.words, stage: 'dictating' };\n setShown({ element: target, stage: 'dictating', reply: '' });\n start();\n } catch (error) { setShown({ element: target, stage: 'settled', reply: '', problem: error instanceof Error ? error.message : latest.current.words.invalidContext }); }\n },\n });\n picker.current = created;\n void created.start();\n created.suspend(true);\n return () => {\n window.removeEventListener('pointerdown', gate, true);\n window.removeEventListener('pointermove', gate, true);\n window.removeEventListener('keydown', key, true);\n window.removeEventListener('click', click, true);\n cancel();\n created.dispose();\n if (picker.current === created) picker.current = null;\n };\n }, [enabled, active, agent, attribute, start, cancel]);\n\n useEffect(() => {\n const selected = session.current;\n if (dictation.problem && selected?.stage === 'dictating') settle(selected, '', false, dictation.problem);\n }, [dictation.problem]);\n useLayoutEffect(() => {\n if (!shown || !box.current) return;\n const node = box.current;\n const measure = () => {\n const rect = shown.element.getBoundingClientRect();\n const topEdge = (viewport?.top ?? 0) + 8, bottom = (viewport ? viewport.top + viewport.height : window.innerHeight) - 8;\n const height = Math.min(node.getBoundingClientRect().height, Math.max(0, bottom - topEdge));\n const lowest = Math.max(topEdge, bottom - height);\n const top = Math.max(topEdge, Math.min(rect.bottom + 8 + height <= bottom ? rect.bottom + 8 : rect.top - height - 8 >= topEdge ? rect.top - height - 8 : lowest, lowest));\n const width = Math.min(Math.max(240, rect.width), window.innerWidth - 16);\n const next = { position: 'fixed' as const, top, left: Math.min(Math.max(8, rect.left), Math.max(8, window.innerWidth - width - 8)), width, maxHeight: Math.max(0, bottom - top), overflowY: 'auto' as const };\n setPlace(previous => JSON.stringify(previous) === JSON.stringify(next) ? previous : next);\n };\n measure();\n window.addEventListener('resize', measure);\n window.addEventListener('scroll', measure, true);\n const observer = typeof ResizeObserver === 'undefined' ? undefined : new ResizeObserver(measure);\n observer?.observe(node);\n return () => { window.removeEventListener('resize', measure); window.removeEventListener('scroll', measure, true); observer?.disconnect(); };\n }, [shown, viewport]);\n\n if (!enabled || !active || !shown || typeof document === 'undefined') return null;\n const phase = shown.stage === 'dictating' ? dictation.phase : shown.stage;\n return createPortal(<Card ref={box} size=\"sm\" data-feedback-ignore=\"\" data-boring=\"agent-target\" data-testid=\"agent-target-bar\" data-phase={phase}\n role=\"region\" aria-label={words.title} className=\"app-shell fixed z-50\" style={place}>\n <CardHeader><CardTitle>{words.title}</CardTitle></CardHeader>\n <CardContent>\n {shown.problem ? <p role=\"alert\" data-testid=\"agent-target-problem\">{shown.problem}</p>\n : shown.reply ? <Message align=\"start\"><MessageContent><Bubble variant=\"secondary\"><BubbleContent data-testid=\"agent-target-reply\" className=\"whitespace-pre-wrap\">{shown.reply}</BubbleContent></Bubble></MessageContent></Message>\n : <p role=\"status\">{phase === 'starting' ? words.opening : phase === 'recording' ? words.listening(dictation.seconds) : phase === 'transcribing' ? words.transcribing : phase === 'settled' ? words.answered : words.waiting}</p>}\n </CardContent>\n <CardFooter className=\"flex gap-2\">\n {shown.stage === 'dictating' && <Button data-testid=\"agent-target-stop\" className=\"min-h-11 min-w-11\" disabled={phase !== 'recording'} onClick={dictation.finish}>{words.stop}</Button>}\n <Button variant=\"ghost\" data-testid=\"agent-target-close\" className=\"min-h-11 min-w-11\" onClick={cancel}>{shown.stage === 'settled' ? words.close : words.cancel}</Button>\n </CardFooter>\n </Card>, document.body);\n}\n",
|
|
25
21
|
"type": "registry:component",
|
|
26
22
|
"target": "components/agent-target/agent-target.tsx"
|
|
27
23
|
},
|