@hachej/boring-ui-kit 0.4.25 → 0.4.26
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 +26 -2
- package/dist/chat-artifacts.d.ts +157 -0
- package/dist/chat-artifacts.js +274 -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-transcript.d.ts +39 -0
- package/dist/chat-react-transcript.js +123 -0
- package/dist/chat-react.d.ts +60 -0
- package/dist/chat-react.js +94 -0
- package/dist/chat-text.d.ts +122 -0
- package/dist/chat-text.js +216 -0
- package/dist/chat-view.d.ts +2 -17
- package/dist/chat-view.js +1 -0
- 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/voice-capture.d.ts +12 -0
- package/dist/voice-capture.js +17 -2
- package/package.json +10 -2
- package/r/agent-target.json +5 -9
- package/r/app-shell.json +16 -25
- package/r/board-viewer.json +7 -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 +2 -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 +16 -26
- 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 +6 -9
- package/r/pi-chat.json +17 -45
- 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 +163 -396
- package/r/requests-board.json +2 -10
- package/r/shared-chat.json +9 -23
- package/r/task-list-viewer.json +3 -9
- package/r/version-history.json +3 -5
- package/r/viewer-code.json +2 -3
- package/r/viewer-frame.json +7 -15
- 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 +2 -7
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.26",
|
|
8
|
+
"@hachej/boring-feedback@^0.4.26",
|
|
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.26/r/app-shell.json",
|
|
15
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.26/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
|
},
|