@hachej/boring-ui-kit 0.4.3 → 0.4.5

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 +56 -0
  2. package/dist/chat-outbox.d.ts +18 -0
  3. package/dist/chat-outbox.js +12 -0
  4. package/dist/mark-store.d.ts +38 -0
  5. package/dist/mark-store.js +84 -0
  6. package/dist/markdown-editor.d.ts +6 -0
  7. package/dist/markdown-editor.js +17 -2
  8. package/dist/markdown-highlight-layer.d.ts +26 -0
  9. package/dist/markdown-highlight-layer.js +107 -0
  10. package/dist/markdown-quote.d.ts +23 -0
  11. package/dist/markdown-quote.js +39 -0
  12. package/dist/native-chat.d.ts +21 -0
  13. package/dist/native-chat.js +111 -7
  14. package/dist/remote-chat.js +16 -14
  15. package/dist/shared-chat/error.d.ts +10 -0
  16. package/dist/shared-chat/error.js +13 -0
  17. package/dist/shared-chat/fake.d.ts +39 -0
  18. package/dist/shared-chat/fake.js +90 -0
  19. package/dist/shared-chat/index.d.ts +51 -0
  20. package/dist/shared-chat/index.js +255 -0
  21. package/dist/shared-chat/socket.d.ts +20 -0
  22. package/dist/shared-chat/socket.js +114 -0
  23. package/dist/shared-chat/source.d.ts +117 -0
  24. package/dist/shared-chat/source.js +6 -0
  25. package/dist/voice-capture.d.ts +43 -0
  26. package/dist/voice-capture.js +186 -0
  27. package/package.json +19 -3
  28. package/r/agent-target.json +36 -0
  29. package/r/app-shell.json +26 -11
  30. package/r/board-viewer.json +5 -5
  31. package/r/button.json +1 -1
  32. package/r/diagram-viewer.json +1 -1
  33. package/r/dock.json +1 -1
  34. package/r/documents.json +10 -10
  35. package/r/feedback-preview.json +1 -1
  36. package/r/feedback.json +5 -5
  37. package/r/file-tree.json +5 -5
  38. package/r/highlights.json +29 -0
  39. package/r/html-viewer.json +3 -3
  40. package/r/maquette-viewer.json +2 -2
  41. package/r/markdown-editor.json +3 -3
  42. package/r/mockup-viewer.json +2 -2
  43. package/r/pi-ambient.json +7 -7
  44. package/r/pi-app.json +15 -14
  45. package/r/pi-chat.json +50 -20
  46. package/r/pi-workspace.json +4 -4
  47. package/r/provider-setup.json +2 -2
  48. package/r/record-viewer.json +40 -0
  49. package/r/registry.json +380 -86
  50. package/r/requests-board.json +41 -0
  51. package/r/shared-chat.json +79 -0
  52. package/r/task-list-viewer.json +2 -2
  53. package/r/theme.json +15 -0
  54. package/r/utils.json +6 -0
  55. package/r/version-history.json +3 -3
  56. package/r/viewers.json +15 -9
  57. package/r/windows.json +28 -0
@@ -0,0 +1,186 @@
1
+ // Shared browser microphone capture. Chat keeps the audio; feedback also uses the page-clock time of the first recorded sample
2
+ // to align speech with the pointer.
3
+ //
4
+ // Microphone access (getUserMedia) exists only in secure contexts (https or localhost) and needs the person's permission. The
5
+ // capture never opens it on load: only `start()`, called from the person's click, does, through an injectable `getUserMedia`
6
+ // whose default feature-detects `navigator.mediaDevices.getUserMedia` and refuses with a reason when it is missing. A host that
7
+ // owns microphone policy passes its own. `npm run check` (scripts/check-platform-apis.mjs) allowlists this file on that condition.
8
+ //
9
+ // The clock. MediaRecorder hands audio over late and in chunks, so neither its `start` event nor a chunk's arrival says when the
10
+ // first recorded sample was heard. `npm run feedback:voice-sync` (tools/feedback-voice-sync.mjs) measured seven candidate
11
+ // strategies in Chromium against an independent reader of the same microphone; the page time read immediately before
12
+ // `MediaRecorder.start()` was the most accurate (2–4 ms mean, under 9 ms max over three batches, with the fake device's dropped
13
+ // time removed), while the `start` event is about 60 ms late (up to 125 ms) and stop-minus-duration 30–40 ms late. So
14
+ // `startedAt` is that time.
15
+ // `endedAt` is `startedAt` plus the decoded duration when the page can decode the recording, else the `stop` event's time.
16
+ // Measured in Chromium only; a microphone that warms up after getUserMedia resolves (no audio yet when recording starts) would
17
+ // make `startedAt` early by the warm-up.
18
+ export const DEFAULT_MAX_VOICE_MS = 5 * 60_000;
19
+ /** Container types tried in order; the first the browser records wins. */
20
+ export const VOICE_MIME_TYPES = ['audio/webm;codecs=opus', 'audio/webm', 'audio/mp4', 'audio/ogg;codecs=opus'];
21
+ /** The browser's microphone, or null where the page cannot have one (insecure origin, no media devices). */
22
+ export function browserGetUserMedia() {
23
+ if (typeof globalThis.navigator?.mediaDevices?.getUserMedia !== 'function')
24
+ return null;
25
+ const devices = globalThis.navigator.mediaDevices;
26
+ return constraints => devices.getUserMedia(constraints);
27
+ }
28
+ /** Short human messages, one per refusal; each says what to do next. */
29
+ export const VOICE_REFUSALS = {
30
+ denied: 'Microphone blocked. Allow it for this site in the browser, then press Record again.',
31
+ 'no-microphone': 'No microphone found. Connect one, then press Record again.',
32
+ busy: 'The microphone is busy or could not be opened. Close other apps using it and try again.',
33
+ insecure: 'Recording needs a secure page (https or localhost).',
34
+ unsupported: 'This browser cannot record audio here.',
35
+ failed: 'The microphone could not be started.',
36
+ };
37
+ const refused = (code, detail) => ({ kind: 'refused', code, reason: detail ? `${VOICE_REFUSALS[code]} (${detail})` : VOICE_REFUSALS[code] });
38
+ function refusalOf(error) {
39
+ const name = error?.name;
40
+ if (name === 'NotAllowedError' || name === 'PermissionDeniedError' || name === 'SecurityError')
41
+ return refused('denied');
42
+ if (name === 'NotFoundError' || name === 'DevicesNotFoundError' || name === 'OverconstrainedError')
43
+ return refused('no-microphone');
44
+ if (name === 'NotReadableError' || name === 'TrackStartError' || name === 'AbortError')
45
+ return refused('busy');
46
+ return refused('failed', error instanceof Error ? error.message : String(error));
47
+ }
48
+ /** Media devices missing on a plain-http page: the usual reason is an insecure origin (http on a name that is not local; a local
49
+ * http page has them). Wording only; the capability itself is feature-detected (`browserGetUserMedia`). */
50
+ const plainHttpPage = () => globalThis.location?.protocol === 'http:';
51
+ const release = (stream) => { for (const track of stream?.getTracks() ?? [])
52
+ track.stop(); };
53
+ /** Duration in milliseconds of recorded audio, when the page can decode it (OfflineAudioContext or AudioContext). */
54
+ async function decodedMs(audio) {
55
+ try {
56
+ const Offline = globalThis.OfflineAudioContext;
57
+ if (typeof Offline !== 'function' || audio.size === 0)
58
+ return null;
59
+ const context = new Offline(1, 1, 48_000);
60
+ const buffer = await context.decodeAudioData(await audio.arrayBuffer());
61
+ return Number.isFinite(buffer.duration) ? buffer.duration * 1000 : null;
62
+ }
63
+ catch {
64
+ return null;
65
+ }
66
+ }
67
+ export function createVoiceCapture(options = {}) {
68
+ const now = options.now ?? (() => performance.now());
69
+ const maxMs = options.maxMs ?? DEFAULT_MAX_VOICE_MS;
70
+ let session = null;
71
+ let opening = false;
72
+ let cancelledWhileOpening = false;
73
+ const finish = (current, truncated) => {
74
+ if (current.timer !== null) {
75
+ clearTimeout(current.timer);
76
+ current.timer = null;
77
+ }
78
+ if (truncated)
79
+ current.truncated = true;
80
+ if (current.recorder.state !== 'inactive') {
81
+ try {
82
+ current.recorder.stop();
83
+ }
84
+ catch { /* already stopping */ }
85
+ }
86
+ };
87
+ return {
88
+ async start() {
89
+ if (session || opening)
90
+ return { kind: 'refused', code: 'failed', reason: 'Already recording.' };
91
+ const Recorder = globalThis.MediaRecorder;
92
+ const open = options.getUserMedia ?? browserGetUserMedia();
93
+ // No media devices on an insecure origin: say that, not "no microphone" (the person can fix the address, not the hardware).
94
+ if (!open)
95
+ return refused(plainHttpPage() ? 'insecure' : 'unsupported');
96
+ if (typeof Recorder !== 'function')
97
+ return refused('unsupported', 'no MediaRecorder');
98
+ // getUserMedia is called synchronously from here, inside the person's click (no await before it), so browsers that tie the
99
+ // permission prompt to a user gesture show it.
100
+ opening = true;
101
+ cancelledWhileOpening = false;
102
+ let stream;
103
+ try {
104
+ stream = await open({ audio: true });
105
+ }
106
+ catch (error) {
107
+ opening = false;
108
+ return refusalOf(error);
109
+ }
110
+ if (cancelledWhileOpening) {
111
+ opening = false;
112
+ release(stream);
113
+ return { kind: 'refused', code: 'failed', reason: 'Cancelled before the microphone opened.' };
114
+ }
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
+ opening = false;
153
+ return { kind: 'recording' };
154
+ },
155
+ async stop() {
156
+ const current = session;
157
+ if (!current)
158
+ return null;
159
+ finish(current, false);
160
+ await current.stopped;
161
+ session = null;
162
+ if (current.cancelled)
163
+ return null;
164
+ const mimeType = current.recorder.mimeType || current.mimeType || current.chunks[0]?.type || 'audio/webm';
165
+ const audio = new Blob(current.chunks, { type: mimeType });
166
+ if (audio.size === 0)
167
+ return null;
168
+ const startedAt = current.startedAt ?? current.stoppedAt ?? now();
169
+ const duration = await decodedMs(audio);
170
+ const endedAt = duration === null ? Math.max(startedAt, current.stoppedAt ?? now()) : startedAt + duration;
171
+ return { audio, mimeType, startedAt, endedAt, truncated: current.truncated };
172
+ },
173
+ cancel() {
174
+ const current = session;
175
+ if (opening)
176
+ cancelledWhileOpening = true;
177
+ if (!current)
178
+ return;
179
+ session = null;
180
+ current.cancelled = true;
181
+ current.chunks.length = 0;
182
+ finish(current, false);
183
+ release(current.stream);
184
+ },
185
+ };
186
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hachej/boring-ui-kit",
3
- "version": "0.4.3",
3
+ "version": "0.4.5",
4
4
  "license": "MIT",
5
5
  "private": false,
6
6
  "type": "module",
@@ -55,6 +55,14 @@
55
55
  "types": "./dist/native-chat.d.ts",
56
56
  "import": "./dist/native-chat.js"
57
57
  },
58
+ "./voice-capture": {
59
+ "types": "./dist/voice-capture.d.ts",
60
+ "import": "./dist/voice-capture.js"
61
+ },
62
+ "./shared-chat": {
63
+ "types": "./dist/shared-chat/index.d.ts",
64
+ "import": "./dist/shared-chat/index.js"
65
+ },
58
66
  "./remote-chat": {
59
67
  "types": "./dist/remote-chat.d.ts",
60
68
  "import": "./dist/remote-chat.js"
@@ -111,10 +119,18 @@
111
119
  "types": "./dist/file-tree-view.d.ts",
112
120
  "import": "./dist/file-tree-view.js"
113
121
  },
114
- "./file-tree.css": "./styles/file-tree.css"
122
+ "./file-tree.css": "./styles/file-tree.css",
123
+ "./markdown-quote": {
124
+ "types": "./dist/markdown-quote.d.ts",
125
+ "import": "./dist/markdown-quote.js"
126
+ },
127
+ "./mark-store": {
128
+ "types": "./dist/mark-store.d.ts",
129
+ "import": "./dist/mark-store.js"
130
+ }
115
131
  },
116
132
  "peerDependencies": {
117
- "@hachej/boring-files": "^0.4.3",
133
+ "@hachej/boring-files": "^0.4.5",
118
134
  "@earendil-works/pi-durable": "~1.2.0",
119
135
  "react": "^19.0.0",
120
136
  "react-dom": "^19.0.0",
@@ -0,0 +1,36 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "agent-target",
4
+ "title": "Point and dictate on app data",
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
+ "dependencies": [
7
+ "@hachej/boring-ui-kit@^0.4.5",
8
+ "@hachej/boring-feedback@^0.4.5",
9
+ "@earendil-works/pi-durable@~1.2.0",
10
+ "react@^19.0.0",
11
+ "react-dom@^19.0.0"
12
+ ],
13
+ "registryDependencies": [
14
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.5/r/app-shell.json",
15
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.5/r/pi-chat.json",
16
+ "button",
17
+ "card",
18
+ "message",
19
+ "bubble"
20
+ ],
21
+ "files": [
22
+ {
23
+ "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",
25
+ "type": "registry:component",
26
+ "target": "components/agent-target/agent-target.tsx"
27
+ },
28
+ {
29
+ "path": "registry/agent-target/agent-target-labels.ts",
30
+ "content": "import { chatTextFor } from '../pi-chat/locale';\nimport type { DictationLabels } from '../app-shell/use-dictation';\n\nexport interface AgentTargetLabels extends DictationLabels {\n readonly title: string;\n readonly opening: string;\n readonly listening: (seconds: number) => string;\n readonly transcribing: string;\n readonly waiting: string;\n readonly answered: string;\n readonly stop: string;\n readonly cancel: string;\n readonly close: string;\n readonly invalidContext: string;\n readonly contextTooLarge: string;\n readonly observationFailed: string;\n readonly unanswered: string;\n readonly unavailable: string;\n readonly sendFailed: string;\n readonly unconfirmed: string;\n}\n\nexport const agentTargetLabels: AgentTargetLabels = {\n ...chatTextFor(undefined).labels,\n title: 'Talk about this item',\n opening: 'Opening microphone…',\n listening: seconds => `Listening… ${seconds}s`,\n transcribing: 'Transcribing…',\n waiting: 'Waiting for the agent…',\n answered: 'Request answered.',\n stop: 'Stop and send',\n cancel: 'Cancel',\n close: 'Close',\n invalidContext: 'Target context must be a JSON object.',\n contextTooLarge: 'Target context exceeds 1 KB.',\n observationFailed: 'Could not confirm the reply. Open chat to check it.',\n unanswered: 'The agent did not answer this request.',\n unavailable: 'The chat is no longer available.',\n sendFailed: 'The request could not be sent. Your words are in the chat draft.',\n unconfirmed: 'The request was not confirmed. Check the chat before trying again.',\n};\n\nexport const agentTargetLabelsFr: AgentTargetLabels = {\n ...agentTargetLabels,\n ...chatTextFor('fr').labels,\n title: 'Parler de cet élément',\n opening: 'Ouverture du microphone…',\n listening: seconds => `J’écoute… ${seconds} s`,\n transcribing: 'Transcription…',\n waiting: 'Réponse en cours…',\n answered: 'Demande traitée.',\n stop: 'Arrêter et envoyer',\n cancel: 'Annuler',\n close: 'Fermer',\n invalidContext: 'Le contexte doit être un objet JSON.',\n contextTooLarge: 'Le contexte dépasse 1 Ko.',\n observationFailed: 'Réponse non confirmée. Ouvrez la discussion pour la vérifier.',\n unanswered: 'L’assistant n’a pas répondu à cette demande.',\n unavailable: 'La discussion n’est plus disponible.',\n sendFailed: 'Envoi impossible. Vos mots sont conservés dans le brouillon de la discussion.',\n unconfirmed: 'Envoi non confirmé. Vérifiez la discussion avant de réessayer.',\n};\n",
31
+ "type": "registry:lib",
32
+ "target": "components/agent-target/agent-target-labels.ts"
33
+ }
34
+ ],
35
+ "type": "registry:component"
36
+ }