@hachej/boring-ui-kit 0.2.4 → 0.3.0

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/r/feedback.json CHANGED
@@ -4,21 +4,21 @@
4
4
  "title": "Feedback",
5
5
  "description": "Feedback from the chat composer: one Feedback button starts feedback mode on the application page (hover, click to pin a numbered note, hold ⌥ to use the app, optional voice), and one chip carries the notes to the next message. FeedbackBar, NoteBubble, FeedbackChip with its review, useComposerFeedback for the pi-chat composer, and PickerOverlay styles. Optional, not mounted by default: PointButton, AnnotateSheet, FeedbackList and FeedbackReport. Picking, masking, anchoring and placement stay in @hachej/boring-feedback/page and /ui, so restyling cannot weaken them.",
6
6
  "dependencies": [
7
- "@hachej/boring-feedback@^0.2.4",
8
- "@hachej/boring-ui-kit@^0.2.4",
9
- "@hachej/boring-files@^0.2.4",
7
+ "@hachej/boring-feedback@^0.3.0",
8
+ "@hachej/boring-ui-kit@^0.3.0",
9
+ "@hachej/boring-files@^0.3.0",
10
10
  "react@^19.0.0",
11
11
  "react-dom@^19.0.0",
12
12
  "lucide-react@^1.51.0"
13
13
  ],
14
14
  "registryDependencies": [
15
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.4/r/pi-chat.json",
16
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.4/r/utils.json"
15
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.0/r/pi-chat.json",
16
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.0/r/utils.json"
17
17
  ],
18
18
  "files": [
19
19
  {
20
20
  "path": "registry/feedback/feedback-session.tsx",
21
- "content": "// The feedback session's look (FEEDBACK.md, \"UX\"): the slim bar of feedback mode, the note bubble beside a pin, and the one chip the\n// composer shows afterwards with its review. Everything they show and do is the headless `createFeedbackSession` from\n// `@hachej/boring-feedback/ui` (hover labels, pins, steps, voice, the draft and Send come from there, through the privacy policy); this file is\n// how it looks. `useComposerFeedback` adapts a session to the chat composer's optional `feedback` prop (registry/pi-chat), structurally,\n// so neither item imports the other.\nimport { useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type KeyboardEvent, type ReactNode } from 'react';\nimport { createPortal } from 'react-dom';\nimport { readableElement } from '@hachej/boring-feedback/format';\nimport { pinMark, type FeedbackSession, type FeedbackSessionSnapshot, type SessionNote } from '@hachej/boring-feedback/ui';\nimport { useExternal } from './use-external';\nimport { withLabels, type FeedbackLabels } from './feedback-labels';\n\n/** A microphone outline drawn inline: emoji glyphs (🎙) are missing from some system fonts. */\nconst MicIcon = () => <svg className=\"boring-feedback-mic\" viewBox=\"0 0 24 24\" width=\"16\" height=\"16\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden=\"true\" focusable=\"false\">\n <rect x=\"9\" y=\"2\" width=\"6\" height=\"13\" rx=\"3\" /><path d=\"M19 10v2a7 7 0 0 1-14 0v-2\" /><path d=\"M12 19v3\" />\n</svg>;\n\nconst plural = (count: number, one: string, many: string): string => `${count} ${count === 1 ? one : many}`;\n/** Notes the chip and the bar count: every note that says something (not a pin still being typed), plus the general note. */\nexport function noteCount(state: FeedbackSessionSnapshot): number {\n return state.notes.filter(note => note.text.trim()).length + (state.general.trim() ? 1 : 0);\n}\n\nexport interface FeedbackBarProps {\n readonly session: FeedbackSession;\n readonly className?: string;\n /** Docked under the chat composer (the composer's `feedback.bar`) instead of floating at the top of the page. */\n readonly docked?: boolean;\n /** Words of the controls (`feedbackLabelsFr` for French); what is left out keeps the English default. */\n readonly labels?: Partial<FeedbackLabels>;\n}\n\n/** Pins dropped while recording that have no note yet (nothing transcribed for them, or transcription failed). */\nexport function emptyPinCount(state: FeedbackSessionSnapshot): number {\n return state.notes.filter(note => note.recorded && !note.text.trim()).length;\n}\n\n/** `mm:ss` since `since` (ms), for the recording timer. */\nexport function elapsed(ms: number): string {\n const seconds = Math.max(0, Math.floor(ms / 1000));\n return `${String(Math.floor(seconds / 60)).padStart(2, '0')}:${String(seconds % 60).padStart(2, '0')}`;\n}\n\n/** The running time of a recording, from when the bar saw it start; 00:00 when not recording. */\nfunction useRecordingTime(recording: boolean): string {\n const [since, setSince] = useState<number | undefined>(undefined);\n const [, tick] = useState(0);\n useEffect(() => {\n if (!recording) { setSince(undefined); return; }\n setSince(Date.now());\n const timer = setInterval(() => tick(value => value + 1), 500);\n return () => clearInterval(timer);\n }, [recording]);\n return elapsed(since === undefined ? 0 : Date.now() - since);\n}\n\nfunction barHint(state: FeedbackSessionSnapshot): string {\n if (state.phase === 'processing') return state.transcribing ? 'Transcribing…' : 'Preparing your feedback…';\n if (state.problem) return state.problem;\n if (state.voice.kind === 'starting') return 'Allow the microphone if your browser asks…';\n if (state.voice.kind === 'recording') return state.using ? 'Recording — using the app' : 'Recording — point and talk';\n return state.using ? 'Using the app: your clicks are recorded as steps' : 'Click what you want to comment on';\n}\n\n/**\n * Feedback mode's bar: `● Feedback · N notes · 🎙 Record · Hold ⌥ to use the app · Use app · Done · ✕`. While recording: a red dot, the\n * running time, \"Recording — point and talk\" and Stop. A refused microphone is said in the bar itself, never only in a tooltip.\n */\nexport function FeedbackBar({ session, className, docked = false, labels }: FeedbackBarProps) {\n const state = useExternal(session);\n const words = withLabels(labels);\n const recording = state.voice.kind === 'recording';\n const time = useRecordingTime(recording && (state.phase === 'active' || state.phase === 'processing'));\n if (state.phase !== 'active' && state.phase !== 'processing') return null;\n const processing = state.phase === 'processing';\n const pins = emptyPinCount(state);\n const voiceProblem = state.voice.kind === 'refused' ? state.voice : undefined;\n return <div data-boring=\"feedback\" data-feedback-ignore=\"\" role=\"toolbar\" aria-label=\"Feedback mode\" data-testid=\"feedback-bar\" data-docked={docked ? 'true' : 'false'}\n data-using={state.using ? 'true' : 'false'} data-recording={recording ? 'true' : 'false'} data-transcribing={state.transcribing ? 'true' : 'false'}\n className={['boring-feedback-bar', className].filter(Boolean).join(' ')}\n onKeyDown={event => { if (event.key === 'Escape' && (state.bubble || state.generalOpen)) { event.stopPropagation(); if (state.bubble) session.cancelBubble(); else session.closeGeneral(); } }}>\n <span className=\"boring-feedback-bar-title\"><span className=\"boring-feedback-dot\" aria-hidden=\"true\" />Feedback</span>\n <span className=\"boring-feedback-bar-meta\" data-testid=\"feedback-bar-count\">{plural(noteCount(state), 'note', 'notes')}{pins ? ` · ${plural(pins, 'pin', 'pins')}` : ''}</span>\n {state.voiceAvailable && <button type=\"button\" className=\"boring-feedback-button boring-feedback-record\" data-testid=\"feedback-voice\" aria-pressed={recording}\n disabled={processing || state.voice.kind === 'starting' || state.voice.kind === 'stopping'} title={recording ? 'Stop recording' : 'Record your voice while you point: each click drops a pin, what you say becomes its note'}\n onClick={() => { void session.toggleVoice(); }}>\n {recording ? <><span className=\"boring-feedback-stop\" aria-hidden=\"true\" />Stop</> : <><MicIcon />{state.voice.kind === 'starting' ? 'Starting…' : state.voice.kind === 'stopping' ? 'Stopping…' : 'Record'}</>}\n </button>}\n {recording && <span className=\"boring-feedback-recording\" role=\"status\" data-testid=\"feedback-recording\"><span className=\"boring-feedback-rec\" aria-hidden=\"true\" />\n <span className=\"boring-feedback-timer\" data-testid=\"feedback-recording-time\">{time}</span></span>}\n <span className=\"boring-feedback-hint\" role=\"status\" aria-live=\"polite\" data-testid=\"feedback-bar-hint\">{barHint(state)}</span>\n {voiceProblem && <span className=\"boring-feedback-voice-problem\" role=\"alert\" data-testid=\"feedback-voice-problem\" data-code={voiceProblem.code}>{voiceProblem.reason}</span>}\n {!processing && <button type=\"button\" className=\"boring-feedback-button\" data-testid=\"feedback-general\" aria-pressed={state.generalOpen} onClick={() => session.askGeneral()}>{words.askGeneral}</button>}\n {!processing && <span className=\"boring-feedback-keys boring-feedback-alt\">Hold ⌥ to use the app</span>}\n {!processing && <button type=\"button\" className=\"boring-feedback-button\" data-testid=\"feedback-use-app\" aria-pressed={state.useToggle} onClick={() => session.setUseToggle(!state.useToggle)}>Use app</button>}\n <button type=\"button\" className=\"boring-feedback-button\" data-variant=\"primary\" data-testid=\"feedback-done\" disabled={processing} onClick={() => { void session.done(); }}>Done</button>\n <button type=\"button\" className=\"boring-feedback-icon\" data-testid=\"feedback-discard\" aria-label=\"Discard feedback\" title=\"Discard feedback\" disabled={processing} onClick={() => session.discard()}>×</button>\n </div>;\n}\n\nconst BUBBLE_WIDTH = 280;\n/** Pixels at the bottom of the viewport taken by point mode's bar (PointBar, `data-feedback-point-bar`), plus a gap; 0 without it. */\nexport function pointBarInset(document: Document): number {\n const bar = document.querySelector('[data-feedback-point-bar]');\n const height = document.defaultView?.innerHeight ?? 768;\n return bar ? Math.max(0, height - bar.getBoundingClientRect().top + 8) : 0;\n}\n\n/**\n * Beside the element: below it when the bubble (`size`, its measured height once drawn) fits above point mode's bar (if any), else\n * above it, else as low as it fits. Every branch is clamped to the space above the bar, so the bubble never covers it; a bubble taller\n * than that space scrolls inside it.\n */\nfunction bubblePlace(element: Element, size = 120): CSSProperties {\n const view = element.ownerDocument.defaultView;\n const width = view?.innerWidth ?? 1024, height = (view?.innerHeight ?? 768) - pointBarInset(element.ownerDocument);\n const rect = element.getBoundingClientRect();\n const left = Math.min(Math.max(8, rect.left), Math.max(8, width - BUBBLE_WIDTH - 8));\n const below = rect.bottom + 8;\n const above = rect.top - size - 8;\n const lowest = Math.max(8, height - size);\n const top = Math.min(below + size <= height ? below : above >= 8 ? above : lowest, lowest);\n return { position: 'fixed', top, left, width: Math.min(BUBBLE_WIDTH, width - 16), maxHeight: Math.max(0, height - top), overflowY: 'auto', boxSizing: 'border-box' };\n}\n\n/** After a bubble closes with focus nowhere (the focused field left the page), focus goes back to the bar's general-question entry. */\nfunction useFocusReturn(open: boolean, document: Document | undefined): void {\n const was = useRef(false);\n useEffect(() => {\n if (was.current && !open && document) {\n const active = document.activeElement;\n if (!active || active === document.body) document.querySelector<HTMLElement>('[data-testid=\"feedback-general\"]')?.focus({ preventScroll: true });\n }\n was.current = open;\n }, [open, document]);\n}\n\n/**\n * The note bubble beside a new or reopened pin: \"What's wrong here?\", Enter saves, Shift-Enter for a new line, Esc discards the pin,\n * and \"Cancel selection\" (also clicking the pinned element again) drops the pin and keeps what was typed for a general question.\n * With the general question open it is a bubble with no element: the same field, docked at the bottom, Esc closes it. In point mode\n * it keeps clear of the PointBar. One NoteBubble serves both modes.\n */\nexport function NoteBubble({ session, labels }: { readonly session: FeedbackSession; readonly labels?: Partial<FeedbackLabels> }) {\n const state = useExternal(session);\n const words = withLabels(labels);\n const bubble = state.bubble;\n const field = useRef<HTMLTextAreaElement>(null);\n const general = useRef<HTMLTextAreaElement>(null);\n const [place, setPlace] = useState<CSSProperties | undefined>(undefined);\n const [, redraw] = useState(0);\n const [announce, setAnnounce] = useState('');\n const generalOpen = state.generalOpen && state.phase === 'active';\n const box = useRef<HTMLDivElement>(null);\n // The bubble's natural height (its content plus borders), not the height it is clamped to, so it grows back when there is room.\n const size = (): number | undefined => (box.current ? box.current.scrollHeight + box.current.offsetHeight - box.current.clientHeight : 0) || undefined;\n useLayoutEffect(() => { if (bubble) setPlace(bubblePlace(bubble.element, size())); }, [bubble?.noteId, bubble?.element]);\n // Once drawn, place it again with its real height (taller with 44px touch targets and wrapped buttons).\n useLayoutEffect(() => {\n if (!bubble || !place || !box.current) return;\n const next = bubblePlace(bubble.element, size());\n if (next.top !== place.top || next.left !== place.left) setPlace(next);\n });\n useEffect(() => { if (bubble) field.current?.focus({ preventScroll: true }); }, [bubble?.noteId]);\n useEffect(() => { if (generalOpen) { general.current?.focus({ preventScroll: true }); setAnnounce(words.generalOpened); } }, [generalOpen]);\n useEffect(() => {\n if (!bubble) return;\n const view = bubble.element.ownerDocument.defaultView;\n const follow = () => { setPlace(bubblePlace(bubble.element, size())); redraw(value => value + 1); };\n view?.addEventListener('scroll', follow, { capture: true, passive: true });\n view?.addEventListener('resize', follow);\n return () => { view?.removeEventListener('scroll', follow, { capture: true }); view?.removeEventListener('resize', follow); };\n }, [bubble?.noteId, bubble?.element]);\n useFocusReturn(Boolean(bubble) || generalOpen, typeof document === 'undefined' ? undefined : document);\n // A polite live region outlives the bubble, so the cancellation is heard even though the dialog is gone.\n const cancel = (): void => { session.cancelSelection(); setAnnounce(words.selectionCancelled); };\n const live = <span data-boring=\"feedback\" data-feedback-ignore=\"\" role=\"status\" aria-live=\"polite\" className=\"boring-feedback-sr\" data-testid=\"feedback-bubble-announce\"\n style={{ position: 'absolute', width: 1, height: 1, overflow: 'hidden', clip: 'rect(0 0 0 0)', whiteSpace: 'nowrap' }}>{announce}</span>;\n if (generalOpen && !bubble) {\n const onGeneralKey = (event: KeyboardEvent<HTMLTextAreaElement>): void => {\n if (event.key === 'Escape' || (event.key === 'Enter' && !event.shiftKey && !event.nativeEvent.isComposing)) { event.preventDefault(); event.stopPropagation(); session.closeGeneral(); }\n };\n return <>{live}<div data-boring=\"feedback\" data-feedback-ignore=\"\" role=\"dialog\" aria-label={words.askGeneral} data-testid=\"feedback-general-bubble\"\n className=\"boring-feedback-bubble\" style={{ position: 'fixed', left: 8, right: 8, bottom: Math.max(72, typeof document === 'undefined' ? 0 : pointBarInset(document)), margin: '0 auto', maxWidth: 420 }}>\n <span className=\"boring-feedback-bubble-head\"><span className=\"boring-feedback-bubble-label\">{words.askGeneral}</span></span>\n <textarea ref={general} data-testid=\"feedback-general-input\" rows={3} placeholder={words.generalPlaceholder} aria-label={words.generalField} value={state.general}\n onChange={event => session.setGeneral(event.target.value)} onKeyDown={onGeneralKey} />\n <span className=\"boring-feedback-keys\">{words.generalHint}</span>\n </div></>;\n }\n if (!bubble || state.phase !== 'active') return state.phase === 'active' ? live : null;\n const onKeyDown = (event: KeyboardEvent<HTMLElement>): void => {\n if (event.key === 'Enter' && !event.shiftKey && !event.nativeEvent.isComposing && event.target === field.current) { event.preventDefault(); session.saveBubble(); }\n else if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); session.cancelBubble(); }\n };\n return <>{live}<div data-boring=\"feedback\" data-feedback-ignore=\"\" role=\"dialog\" aria-label={`Note ${bubble.number}`} data-testid=\"feedback-bubble\" data-note={bubble.number}\n ref={box} className=\"boring-feedback-bubble\" style={place} onKeyDown={onKeyDown}>\n <span className=\"boring-feedback-bubble-head\"><span className=\"boring-feedback-pin\" aria-hidden=\"true\">{pinMark(bubble.number)}</span>\n <span className=\"boring-feedback-bubble-label\" data-testid=\"feedback-bubble-label\">{bubble.label}</span></span>\n <textarea ref={field} data-testid=\"feedback-bubble-input\" rows={2} placeholder={words.bubblePlaceholder} aria-label={words.bubblePlaceholder} value={bubble.text}\n onChange={event => session.setBubbleText(event.target.value)} />\n <span className=\"boring-feedback-bubble-actions\">\n <button type=\"button\" className=\"boring-feedback-button\" data-testid=\"feedback-cancel-selection\" onClick={cancel}>{words.cancelSelection}</button>\n <button type=\"button\" className=\"boring-feedback-button\" data-testid=\"feedback-bubble-general\" onClick={() => session.askGeneral()}>{words.askGeneral}</button>\n </span>\n <span className=\"boring-feedback-keys\">{words.bubbleHint}</span>\n </div></>;\n}\n\nexport interface FeedbackChipProps {\n readonly session: FeedbackSession;\n /** Opens the review on first render (journeys). */\n readonly defaultOpen?: boolean;\n /** Offer Copy report in the review: the way to use feedback with no chat and no store. */\n readonly copy?: boolean;\n}\n\n/** The one composer chip after Done: `💬 Feedback · 3 notes · /settings/:section`. Click opens the review; × discards. */\nexport function FeedbackChip({ session, defaultOpen = false, copy = false }: FeedbackChipProps) {\n const state = useExternal(session);\n const [open, setOpen] = useState(defaultOpen);\n const chip = useRef<HTMLSpanElement>(null);\n if (state.phase !== 'review') return null;\n const count = noteCount(state);\n const pins = emptyPinCount(state);\n const refusal = state.send.kind !== 'idle' && state.send.kind !== 'sending' && state.send.kind !== 'saved' ? state.send.reason : undefined;\n return <span ref={chip} data-boring=\"feedback\" data-feedback-ignore=\"\" data-testid=\"feedback-chip\" data-send={state.send.kind} className=\"boring-feedback-chip\">\n <button type=\"button\" className=\"boring-feedback-chip-open\" data-testid=\"feedback-chip-open\" aria-expanded={open} onClick={() => setOpen(value => !value)}\n title={refusal ?? 'Review your feedback before sending'}>\n <span aria-hidden=\"true\">💬</span> Feedback · {plural(count, 'note', 'notes')}{pins ? ` · ${plural(pins, 'pin', 'pins')} to describe` : ''} · <span className=\"boring-feedback-chip-route\">{state.route}</span>\n </button>\n <button type=\"button\" className=\"boring-feedback-icon boring-feedback-chip-remove\" data-testid=\"feedback-chip-discard\" aria-label=\"Discard feedback\" onClick={() => { session.highlight(undefined); session.discard(); }}>×</button>\n {state.problem && <span className=\"boring-feedback-warning\" role=\"alert\" data-testid=\"feedback-chip-voice\">{state.problem}</span>}\n {refusal && <span className=\"boring-feedback-warning\" role=\"alert\" data-testid=\"feedback-chip-problem\">{refusal}</span>}\n {/* In the page's body: the chip may sit in a transformed chat bar, where a fixed panel would not be placed against the viewport. */}\n {open && createPortal(<FeedbackReview session={session} anchor={chip.current} copy={copy} onClose={() => setOpen(false)} />, chip.current?.ownerDocument.body ?? document.body)}\n </span>;\n}\n\nfunction reviewPlace(anchor: Element | null): CSSProperties | undefined {\n if (!anchor) return undefined;\n const view = anchor.ownerDocument.defaultView;\n const width = view?.innerWidth ?? 1024, height = view?.innerHeight ?? 768;\n const rect = anchor.getBoundingClientRect();\n const panel = Math.min(416, width - 24);\n return { position: 'fixed', left: Math.min(Math.max(12, rect.left), width - panel - 12), bottom: Math.max(12, height - rect.top + 8), width: panel };\n}\n\n/** The compact review: notes in order, editable and removable; hovering a note highlights its element in the page, or says it is not here. */\nexport function FeedbackReview({ session, anchor = null, copy = false, onClose }: { readonly session: FeedbackSession; readonly anchor?: Element | null; readonly copy?: boolean; readonly onClose: () => void }) {\n const state = useExternal(session);\n const [where, setWhere] = useState<Readonly<Record<number, 'shown' | 'not-on-page'>>>({});\n const [place, setPlace] = useState<CSSProperties | undefined>(undefined);\n useLayoutEffect(() => { setPlace(reviewPlace(anchor)); }, [anchor]);\n useEffect(() => () => { session.highlight(undefined); }, [session]);\n const hover = (id: number | undefined): void => {\n const result = session.highlight(id);\n if (id !== undefined && result !== 'none') setWhere(value => ({ ...value, [id]: result }));\n };\n const onKeyDown = (event: KeyboardEvent<HTMLElement>): void => { if (event.key === 'Escape') { event.stopPropagation(); onClose(); } };\n return <section data-boring=\"feedback\" data-feedback-ignore=\"\" role=\"dialog\" aria-label=\"Review feedback\" data-testid=\"feedback-review\" className=\"boring-feedback-review\"\n style={place} onKeyDown={onKeyDown} onMouseLeave={() => hover(undefined)}>\n <header className=\"boring-feedback-sheet-header\"><h2>Feedback · {state.route}</h2>\n <button type=\"button\" className=\"boring-feedback-icon\" aria-label=\"Close review\" data-testid=\"feedback-review-close\" onClick={onClose}>×</button></header>\n {state.problem && <p className=\"boring-feedback-warning\" role=\"alert\" data-testid=\"feedback-review-problem\">{state.problem}</p>}\n <ol className=\"boring-feedback-notes\">\n {state.notes.map(note => <li key={note.id} data-testid=\"feedback-review-note\" data-note={note.number} data-where={where[note.id]}\n onMouseEnter={() => hover(note.id)} onFocus={() => hover(note.id)}>\n <span className=\"boring-feedback-note-head\"><span className=\"boring-feedback-pin\" aria-hidden=\"true\">{pinMark(note.number)}</span>\n <ElementName note={note} />\n {note.from === 'voice' && <span className=\"boring-feedback-badge\" data-testid=\"feedback-review-voice\">voice</span>}\n {where[note.id] === 'not-on-page' && <span className=\"boring-feedback-badge\" data-testid=\"feedback-review-elsewhere\">not on this page</span>}\n <button type=\"button\" className=\"boring-feedback-icon\" aria-label={`Remove note ${note.number}`} data-testid=\"feedback-review-remove\" onClick={() => session.removeNote(note.id)}>×</button></span>\n <textarea rows={2} aria-label={`Note ${note.number}`} data-testid=\"feedback-review-text\" value={note.text} placeholder={note.recorded ? 'Nothing was said here: type a note, or remove the pin' : undefined}\n onChange={event => session.setNoteText(note.id, event.target.value)} />\n </li>)}\n </ol>\n <label className=\"boring-feedback-field\"><span>General note</span>\n <textarea rows={2} data-testid=\"feedback-review-general\" placeholder=\"Anything else? (optional)\" value={state.general} onChange={event => session.setGeneral(event.target.value)} /></label>\n <p className=\"boring-feedback-meta\">{state.stepCount > 1 ? `${plural(state.stepCount, 'step', 'steps')} recorded between the notes. ` : ''}{copy ? 'Copy the report and paste it anywhere.' : 'Sent with your next message.'}</p>\n {copy && <div className=\"boring-feedback-actions\">\n <button type=\"button\" className=\"boring-feedback-button\" data-variant=\"primary\" data-testid=\"feedback-copy\" onClick={() => { void session.copy(); }}>Copy report</button></div>}\n {copy && <p role=\"status\" aria-live=\"polite\" className=\"boring-feedback-status\" data-testid=\"feedback-status\" data-copy={state.copy.kind}>\n {state.copy.kind === 'copied' ? 'Report copied. Paste it anywhere.' : state.copy.kind === 'manual' ? 'The clipboard is not available here: select the report below and copy it.' : state.copy.kind === 'refused' ? state.copy.reason : ''}</p>}\n {copy && state.copy.kind === 'manual' && <textarea readOnly className=\"boring-feedback-manual\" data-testid=\"feedback-manual-copy\" value={state.copy.text} rows={6} onFocus={event => event.currentTarget.select()} />}\n {copy && state.copy.kind === 'copied' && <details className=\"boring-feedback-copied\"><summary>Copied report</summary><pre data-testid=\"feedback-copy-text\">{state.copy.text}</pre></details>}\n </section>;\n}\n\n/**\n * A note's element as one calm line: the readable name (semibold) and the file basename (muted, monospace), the full path in the\n * tooltip. The same reading as the pi-chat feedback card's element line (`readableElement` from `@hachej/boring-feedback/format`).\n */\nexport function ElementName({ note }: { readonly note: SessionNote }) {\n const readable = note.fallback ? readableElement(note.fallback, note.source) : { name: note.label };\n return <span className=\"boring-feedback-element\" data-testid=\"feedback-review-element\" title={'path' in readable && readable.path ? readable.path : note.label}>\n <span className=\"boring-feedback-element-name\" data-testid=\"feedback-review-name\">{readable.name}</span>\n {'file' in readable && readable.file && <span className=\"boring-feedback-element-file\" data-testid=\"feedback-review-file\">{readable.file}</span>}\n </span>;\n}\n\n/** What `useComposerFeedback` returns: the structural shape of pi-chat's `ComposerFeedback`. */\nexport interface ComposerFeedbackProp {\n readonly start?: () => void;\n readonly point?: () => void;\n readonly active: boolean;\n readonly pending: boolean;\n readonly chip?: ReactNode;\n readonly attach: (text: string) => Promise<{ readonly kind: 'ok'; readonly text: string } | { readonly kind: 'refused'; readonly reason: string }>;\n readonly sent: () => void;\n}\n\n/** A Markdown fence longer than any backtick run in the text. */\nfunction fenced(text: string): string {\n const longest = Math.max(0, ...[...text.matchAll(/`+/g)].map(match => match[0].length));\n const fence = '`'.repeat(Math.max(3, longest + 1));\n return `${fence}markdown\\n${text.endsWith('\\n') ? text : `${text}\\n`}${fence}`;\n}\n\nexport interface ComposerFeedbackOptions {\n /**\n * Which entry points the composer offers: `feedback` (default) the Feedback button and feedback mode docked in the composer; `point`\n * the « Show in app » action for phones (point mode: the host hides its panel while `usePointing` is true and mounts `PointMode`);\n * `both` offers the two.\n */\n readonly entry?: 'feedback' | 'point' | 'both';\n}\n\n/**\n * The composer's `feedback` prop for a session: the Feedback button (or « Show in app ») starts it, the chip shows the draft, and Send\n * attaches the report: a saved report as its `@` mention (the host's mention resolver inlines the stored report with the person's\n * access), otherwise the rendered report inline in a fenced block.\n */\nexport function useComposerFeedback(session: FeedbackSession | undefined, { entry = 'feedback' }: ComposerFeedbackOptions = {}): ComposerFeedbackProp | undefined {\n const state = useSyncState(session);\n if (!session || !state) return undefined;\n const on = state.phase === 'active' || state.phase === 'processing';\n return {\n ...(entry !== 'point' ? { start: () => session.start() } : {}),\n ...(entry !== 'feedback' ? { point: () => session.start({ mode: 'point' }) } : {}),\n active: on,\n pending: state.phase === 'review',\n // Point mode's bar is PointMode, mounted by the host outside its (hidden) panel.\n ...(on && state.mode === 'feedback' ? { bar: <FeedbackBar session={session} docked /> } : {}),\n ...(state.phase === 'review' ? { chip: <FeedbackChip session={session} /> } : {}),\n attach: async text => {\n const attached = await session.attach();\n if (attached.kind === 'refused') return attached;\n const body = attached.kind === 'mention' ? `@${attached.path}` : fenced(attached.text);\n return { kind: 'ok', text: text.trim() ? `${text.trimEnd()}\\n\\n${body}` : body };\n },\n sent: () => session.sent(),\n };\n}\n\nconst NO_STATE = { getSnapshot: () => undefined, subscribe: () => () => {} };\nfunction useSyncState(session: FeedbackSession | undefined): FeedbackSessionSnapshot | undefined {\n return useExternal<FeedbackSessionSnapshot | undefined>(session ?? NO_STATE);\n}\n",
21
+ "content": "// The feedback session's look (FEEDBACK.md, \"UX\"): the note pin beside a picked element (NoteBubble: a text field with a mic inside it),\n// the slim bar and the chip with its review (optional, for hosts with no chat). Everything they show and do is the headless\n// `createFeedbackSession` from `@hachej/boring-feedback/ui` (hover labels, pins, steps, the draft and Send come from there, through the\n// privacy policy); this file is how it looks. `useComposerFeedback` adapts a session to the chat composer's optional `feedback` prop\n// (registry/pi-chat): ONE annotate toggle and a count chip, structurally, so neither item imports the other.\nimport { useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type KeyboardEvent } from 'react';\nimport { createPortal } from 'react-dom';\nimport { readableElement } from '@hachej/boring-feedback/format';\nimport { createVoiceCapture, type VoiceCapture } from '@hachej/boring-feedback/page';\nimport { pinMark, type FeedbackSession, type FeedbackSessionSnapshot, type SessionNote } from '@hachej/boring-feedback/ui';\nimport { useExternal } from './use-external';\nimport { withLabels, type FeedbackLabels } from './feedback-labels';\n\n/** A microphone outline drawn inline: emoji glyphs (🎙) are missing from some system fonts. */\nconst MicIcon = () => <svg className=\"boring-feedback-mic\" viewBox=\"0 0 24 24\" width=\"16\" height=\"16\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden=\"true\" focusable=\"false\">\n <rect x=\"9\" y=\"2\" width=\"6\" height=\"13\" rx=\"3\" /><path d=\"M19 10v2a7 7 0 0 1-14 0v-2\" /><path d=\"M12 19v3\" />\n</svg>;\n\nconst plural = (count: number, one: string, many: string): string => `${count} ${count === 1 ? one : many}`;\n/** Notes the chip and the bar count: every note that says something (not a pin still being typed), plus the general note. */\nexport function noteCount(state: FeedbackSessionSnapshot): number {\n return state.notes.filter(note => note.text.trim()).length + (state.general.trim() ? 1 : 0);\n}\n\nexport interface FeedbackBarProps {\n readonly session: FeedbackSession;\n readonly className?: string;\n /** Docked under the chat composer (the composer's `feedback.bar`) instead of floating at the top of the page. */\n readonly docked?: boolean;\n}\n\n/** Pins dropped while recording that have no note yet (nothing transcribed for them, or transcription failed). */\nexport function emptyPinCount(state: FeedbackSessionSnapshot): number {\n return state.notes.filter(note => note.recorded && !note.text.trim()).length;\n}\n\n/** `mm:ss` since `since` (ms), for the recording timer. */\nexport function elapsed(ms: number): string {\n const seconds = Math.max(0, Math.floor(ms / 1000));\n return `${String(Math.floor(seconds / 60)).padStart(2, '0')}:${String(seconds % 60).padStart(2, '0')}`;\n}\n\n/** The running time of a recording, from when the bar saw it start; 00:00 when not recording. */\nfunction useRecordingTime(recording: boolean): string {\n const [since, setSince] = useState<number | undefined>(undefined);\n const [, tick] = useState(0);\n useEffect(() => {\n if (!recording) { setSince(undefined); return; }\n setSince(Date.now());\n const timer = setInterval(() => tick(value => value + 1), 500);\n return () => clearInterval(timer);\n }, [recording]);\n return elapsed(since === undefined ? 0 : Date.now() - since);\n}\n\nfunction barHint(state: FeedbackSessionSnapshot): string {\n if (state.phase === 'processing') return state.transcribing ? 'Transcribing…' : 'Preparing your feedback…';\n if (state.problem) return state.problem;\n if (state.voice.kind === 'starting') return 'Allow the microphone if your browser asks…';\n if (state.voice.kind === 'recording') return state.using ? 'Recording — using the app' : 'Recording — point and talk';\n return state.using ? 'Using the app: your clicks are recorded as steps' : 'Click what you want to comment on';\n}\n\n/**\n * Feedback mode's bar: `● Feedback · N notes · 🎙 Record · Hold ⌥ to use the app · Use app · Done · ✕`. While recording: a red dot, the\n * running time, \"Recording — point and talk\" and Stop. A refused microphone is said in the bar itself, never only in a tooltip.\n */\nexport function FeedbackBar({ session, className, docked = false }: FeedbackBarProps) {\n const state = useExternal(session);\n const recording = state.voice.kind === 'recording';\n const time = useRecordingTime(recording && (state.phase === 'active' || state.phase === 'processing'));\n if (state.phase !== 'active' && state.phase !== 'processing') return null;\n const processing = state.phase === 'processing';\n const pins = emptyPinCount(state);\n const voiceProblem = state.voice.kind === 'refused' ? state.voice : undefined;\n return <div data-boring=\"feedback\" data-feedback-ignore=\"\" role=\"toolbar\" aria-label=\"Feedback mode\" data-testid=\"feedback-bar\" data-docked={docked ? 'true' : 'false'}\n data-using={state.using ? 'true' : 'false'} data-recording={recording ? 'true' : 'false'} data-transcribing={state.transcribing ? 'true' : 'false'}\n className={['boring-feedback-bar', className].filter(Boolean).join(' ')}\n onKeyDown={event => { if (event.key === 'Escape' && state.bubble) { event.stopPropagation(); session.cancelBubble(); } }}>\n <span className=\"boring-feedback-bar-title\"><span className=\"boring-feedback-dot\" aria-hidden=\"true\" />Feedback</span>\n <span className=\"boring-feedback-bar-meta\" data-testid=\"feedback-bar-count\">{plural(noteCount(state), 'note', 'notes')}{pins ? ` · ${plural(pins, 'pin', 'pins')}` : ''}</span>\n {state.voiceAvailable && <button type=\"button\" className=\"boring-feedback-button boring-feedback-record\" data-testid=\"feedback-voice\" aria-pressed={recording}\n disabled={processing || state.voice.kind === 'starting' || state.voice.kind === 'stopping'} title={recording ? 'Stop recording' : 'Record your voice while you point: each click drops a pin, what you say becomes its note'}\n onClick={() => { void session.toggleVoice(); }}>\n {recording ? <><span className=\"boring-feedback-stop\" aria-hidden=\"true\" />Stop</> : <><MicIcon />{state.voice.kind === 'starting' ? 'Starting…' : state.voice.kind === 'stopping' ? 'Stopping…' : 'Record'}</>}\n </button>}\n {recording && <span className=\"boring-feedback-recording\" role=\"status\" data-testid=\"feedback-recording\"><span className=\"boring-feedback-rec\" aria-hidden=\"true\" />\n <span className=\"boring-feedback-timer\" data-testid=\"feedback-recording-time\">{time}</span></span>}\n <span className=\"boring-feedback-hint\" role=\"status\" aria-live=\"polite\" data-testid=\"feedback-bar-hint\">{barHint(state)}</span>\n {voiceProblem && <span className=\"boring-feedback-voice-problem\" role=\"alert\" data-testid=\"feedback-voice-problem\" data-code={voiceProblem.code}>{voiceProblem.reason}</span>}\n {!processing && <span className=\"boring-feedback-keys boring-feedback-alt\">Hold ⌥ to use the app</span>}\n {!processing && <button type=\"button\" className=\"boring-feedback-button\" data-testid=\"feedback-use-app\" aria-pressed={state.useToggle} onClick={() => session.setUseToggle(!state.useToggle)}>Use app</button>}\n <button type=\"button\" className=\"boring-feedback-button\" data-variant=\"primary\" data-testid=\"feedback-done\" disabled={processing} onClick={() => { void session.done(); }}>Done</button>\n <button type=\"button\" className=\"boring-feedback-icon\" data-testid=\"feedback-discard\" aria-label=\"Discard feedback\" title=\"Discard feedback\" disabled={processing} onClick={() => session.discard()}>×</button>\n </div>;\n}\n\nconst BUBBLE_WIDTH = 280;\n/** Pixels at the bottom of the viewport taken by point mode's bar (PointBar, `data-feedback-point-bar`), plus a gap; 0 without it. */\nexport function pointBarInset(document: Document): number {\n const bar = document.querySelector('[data-feedback-point-bar]');\n const height = document.defaultView?.innerHeight ?? 768;\n return bar ? Math.max(0, height - bar.getBoundingClientRect().top + 8) : 0;\n}\n\n/**\n * Beside the element: below it when the bubble (`size`, its measured height once drawn) fits above point mode's bar (if any), else\n * above it, else as low as it fits. Every branch is clamped to the space above the bar, so the bubble never covers it; a bubble taller\n * than that space scrolls inside it.\n */\nfunction bubblePlace(element: Element, size = 120): CSSProperties {\n const view = element.ownerDocument.defaultView;\n const width = view?.innerWidth ?? 1024, height = (view?.innerHeight ?? 768) - pointBarInset(element.ownerDocument);\n const rect = element.getBoundingClientRect();\n const left = Math.min(Math.max(8, rect.left), Math.max(8, width - BUBBLE_WIDTH - 8));\n const below = rect.bottom + 8;\n const above = rect.top - size - 8;\n const lowest = Math.max(8, height - size);\n const top = Math.min(below + size <= height ? below : above >= 8 ? above : lowest, lowest);\n return { position: 'fixed', top, left, width: Math.min(BUBBLE_WIDTH, width - 16), maxHeight: Math.max(0, height - top), overflowY: 'auto', boxSizing: 'border-box' };\n}\n\ntype PinVoice = 'idle' | 'starting' | 'recording' | 'transcribing';\n\n/**\n * The mic inside the pin's field: record, stop, and the transcript is appended to what is typed. A refused microphone, nothing heard or a\n * rejected transcription is said under the field and never touches the typed text. One capture (`createVoiceCapture`) per pin.\n */\nfunction PinMic({ session, transcribe, words }: { readonly session: FeedbackSession; readonly transcribe: PinTranscribe; readonly words: FeedbackLabels }) {\n const [phase, setPhase] = useState<PinVoice>('idle');\n const [problem, setProblem] = useState<string | undefined>(undefined);\n const capture = useRef<VoiceCapture | null>(null);\n const run = useRef(0);\n const work = useRef<AbortController | null>(null);\n capture.current ??= createVoiceCapture();\n // A closed pin releases the microphone and drops a transcription still in flight.\n useEffect(() => () => { run.current += 1; capture.current?.cancel(); work.current?.abort(); }, []);\n const start = async (): Promise<void> => {\n const attempt = ++run.current;\n setProblem(undefined); setPhase('starting');\n // Called straight from the click, so the browser ties the permission prompt to the person's gesture.\n const result = await capture.current!.start();\n if (attempt !== run.current) return;\n if (result.kind === 'refused') { setPhase('idle'); setProblem(result.reason); return; }\n setPhase('recording');\n };\n const finish = async (): Promise<void> => {\n const attempt = run.current;\n setPhase('transcribing');\n const recording = await capture.current!.stop().catch(() => null);\n if (attempt !== run.current) return;\n if (!recording) { setPhase('idle'); setProblem(words.voiceNothing); return; }\n const abort = new AbortController();\n work.current = abort;\n try {\n const spoken = (await transcribe(recording.audio, abort.signal)).trim();\n if (attempt !== run.current) return;\n if (!spoken) { setProblem(words.voiceNothing); return; }\n // Appended to what is typed NOW (the person may have kept typing), never replacing it.\n const typed = session.getSnapshot().bubble?.text ?? '';\n session.setBubbleText(`${typed}${typed === '' || /\\s$/.test(typed) ? '' : ' '}${spoken}`);\n } catch { if (attempt === run.current) setProblem(words.voiceFailed); }\n finally { if (work.current === abort) work.current = null; if (attempt === run.current) setPhase('idle'); }\n };\n const recording = phase === 'recording';\n return <>\n <button type=\"button\" className=\"boring-feedback-pin-mic\" data-testid=\"feedback-bubble-mic\" data-state={phase} aria-pressed={recording}\n aria-label={recording ? words.voiceStop : words.voiceRecord} title={recording ? words.voiceStop : words.voiceRecord} disabled={phase === 'starting' || phase === 'transcribing'}\n onClick={() => { if (phase === 'idle') void start(); else if (recording) void finish(); }}>\n {phase === 'transcribing' ? <span role=\"status\" aria-label={words.voiceTranscribing}>…</span>\n : recording ? <span className=\"boring-feedback-stop\" aria-hidden=\"true\" /> : <MicIcon />}\n </button>\n {problem && <span className=\"boring-feedback-voice-problem\" role=\"alert\" data-testid=\"feedback-bubble-voice-problem\">{problem}</span>}\n </>;\n}\n\n/** Turns a recording into text (`signal` aborts when the pin closes); a rejection keeps what was typed. */\nexport type PinTranscribe = (audio: Blob, signal: AbortSignal) => Promise<string>;\n\nconst touchScreen = (): boolean => globalThis.matchMedia?.('(pointer: coarse)').matches === true;\n\n/**\n * The pin beside a picked element: a text field with a mic inside it, \"Add note\" (Enter) and Cancel (Esc). Enter saves, Shift-Enter for a\n * new line, Esc discards the pin. On desktop one line says that holding Alt passes clicks to the app. In point mode it keeps clear of\n * the PointBar. `transcribe` turns the mic's recording into text; without it there is no mic.\n */\nexport function NoteBubble({ session, labels, transcribe }: { readonly session: FeedbackSession; readonly labels?: Partial<FeedbackLabels>; readonly transcribe?: PinTranscribe }) {\n const state = useExternal(session);\n const words = withLabels(labels);\n const bubble = state.bubble;\n const field = useRef<HTMLTextAreaElement>(null);\n const [place, setPlace] = useState<CSSProperties | undefined>(undefined);\n const [, redraw] = useState(0);\n const [announce, setAnnounce] = useState('');\n const box = useRef<HTMLDivElement>(null);\n // The bubble's natural height (its content plus borders), not the height it is clamped to, so it grows back when there is room.\n const size = (): number | undefined => (box.current ? box.current.scrollHeight + box.current.offsetHeight - box.current.clientHeight : 0) || undefined;\n useLayoutEffect(() => { if (bubble) setPlace(bubblePlace(bubble.element, size())); }, [bubble?.noteId, bubble?.element]);\n // Once drawn, place it again with its real height (taller with 44px touch targets and wrapped buttons).\n useLayoutEffect(() => {\n if (!bubble || !place || !box.current) return;\n const next = bubblePlace(bubble.element, size());\n if (next.top !== place.top || next.left !== place.left) setPlace(next);\n });\n useEffect(() => { if (bubble) field.current?.focus({ preventScroll: true }); }, [bubble?.noteId]);\n useEffect(() => {\n if (!bubble) return;\n const view = bubble.element.ownerDocument.defaultView;\n const follow = () => { setPlace(bubblePlace(bubble.element, size())); redraw(value => value + 1); };\n view?.addEventListener('scroll', follow, { capture: true, passive: true });\n view?.addEventListener('resize', follow);\n return () => { view?.removeEventListener('scroll', follow, { capture: true }); view?.removeEventListener('resize', follow); };\n }, [bubble?.noteId, bubble?.element]);\n // A polite live region outlives the bubble, so the cancellation is heard even though the dialog is gone.\n const cancel = (): void => { session.cancelSelection(); setAnnounce(words.selectionCancelled); };\n const live = <span data-boring=\"feedback\" data-feedback-ignore=\"\" role=\"status\" aria-live=\"polite\" className=\"boring-feedback-sr\" data-testid=\"feedback-bubble-announce\"\n style={{ position: 'absolute', width: 1, height: 1, overflow: 'hidden', clip: 'rect(0 0 0 0)', whiteSpace: 'nowrap' }}>{announce}</span>;\n if (!bubble || state.phase !== 'active') return state.phase === 'active' ? live : null;\n const onKeyDown = (event: KeyboardEvent<HTMLElement>): void => {\n if (event.key === 'Enter' && !event.shiftKey && !event.nativeEvent.isComposing && event.target === field.current) { event.preventDefault(); session.saveBubble(); }\n else if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); session.cancelBubble(); }\n };\n return <>{live}<div data-boring=\"feedback\" data-feedback-ignore=\"\" role=\"dialog\" aria-label={`Note ${bubble.number}`} data-testid=\"feedback-bubble\" data-note={bubble.number}\n ref={box} className=\"boring-feedback-bubble\" style={place} onKeyDown={onKeyDown}>\n <span className=\"boring-feedback-bubble-head\"><span className=\"boring-feedback-pin\" aria-hidden=\"true\">{pinMark(bubble.number)}</span>\n <span className=\"boring-feedback-bubble-label\" data-testid=\"feedback-bubble-label\">{bubble.label}</span></span>\n <span className=\"boring-feedback-pin-field\">\n <textarea ref={field} data-testid=\"feedback-bubble-input\" rows={2} placeholder={words.bubblePlaceholder} aria-label={words.bubblePlaceholder} value={bubble.text}\n onChange={event => session.setBubbleText(event.target.value)} />\n {transcribe && <PinMic key={bubble.noteId} session={session} transcribe={transcribe} words={words} />}\n </span>\n <span className=\"boring-feedback-bubble-actions\">\n <button type=\"button\" className=\"boring-feedback-button\" data-testid=\"feedback-cancel-selection\" onClick={cancel}>{words.cancelSelection}</button>\n <button type=\"button\" className=\"boring-feedback-button\" data-variant=\"primary\" data-testid=\"feedback-bubble-save\" onClick={() => session.saveBubble()}>{words.add}</button>\n </span>\n {!touchScreen() && <span className=\"boring-feedback-keys\" data-testid=\"feedback-bubble-hint\">{words.altHint}</span>}\n </div></>;\n}\n\nexport interface FeedbackChipProps {\n readonly session: FeedbackSession;\n /** Opens the review on first render (journeys). */\n readonly defaultOpen?: boolean;\n /** Offer Copy report in the review: the way to use feedback with no chat and no store. */\n readonly copy?: boolean;\n}\n\n/** The one composer chip after Done: `💬 Feedback · 3 notes · /settings/:section`. Click opens the review; × discards. */\nexport function FeedbackChip({ session, defaultOpen = false, copy = false }: FeedbackChipProps) {\n const state = useExternal(session);\n const [open, setOpen] = useState(defaultOpen);\n const chip = useRef<HTMLSpanElement>(null);\n if (state.phase !== 'review') return null;\n const count = noteCount(state);\n const pins = emptyPinCount(state);\n const refusal = state.send.kind !== 'idle' && state.send.kind !== 'sending' && state.send.kind !== 'saved' ? state.send.reason : undefined;\n return <span ref={chip} data-boring=\"feedback\" data-feedback-ignore=\"\" data-testid=\"feedback-chip\" data-send={state.send.kind} className=\"boring-feedback-chip\">\n <button type=\"button\" className=\"boring-feedback-chip-open\" data-testid=\"feedback-chip-open\" aria-expanded={open} onClick={() => setOpen(value => !value)}\n title={refusal ?? 'Review your feedback before sending'}>\n <span aria-hidden=\"true\">💬</span> Feedback · {plural(count, 'note', 'notes')}{pins ? ` · ${plural(pins, 'pin', 'pins')} to describe` : ''} · <span className=\"boring-feedback-chip-route\">{state.route}</span>\n </button>\n <button type=\"button\" className=\"boring-feedback-icon boring-feedback-chip-remove\" data-testid=\"feedback-chip-discard\" aria-label=\"Discard feedback\" onClick={() => { session.highlight(undefined); session.discard(); }}>×</button>\n {state.problem && <span className=\"boring-feedback-warning\" role=\"alert\" data-testid=\"feedback-chip-voice\">{state.problem}</span>}\n {refusal && <span className=\"boring-feedback-warning\" role=\"alert\" data-testid=\"feedback-chip-problem\">{refusal}</span>}\n {/* In the page's body: the chip may sit in a transformed chat bar, where a fixed panel would not be placed against the viewport. */}\n {open && createPortal(<FeedbackReview session={session} anchor={chip.current} copy={copy} onClose={() => setOpen(false)} />, chip.current?.ownerDocument.body ?? document.body)}\n </span>;\n}\n\nfunction reviewPlace(anchor: Element | null): CSSProperties | undefined {\n if (!anchor) return undefined;\n const view = anchor.ownerDocument.defaultView;\n const width = view?.innerWidth ?? 1024, height = view?.innerHeight ?? 768;\n const rect = anchor.getBoundingClientRect();\n const panel = Math.min(416, width - 24);\n return { position: 'fixed', left: Math.min(Math.max(12, rect.left), width - panel - 12), bottom: Math.max(12, height - rect.top + 8), width: panel };\n}\n\n/** The compact review: notes in order, editable and removable; hovering a note highlights its element in the page, or says it is not here. */\nexport function FeedbackReview({ session, anchor = null, copy = false, onClose }: { readonly session: FeedbackSession; readonly anchor?: Element | null; readonly copy?: boolean; readonly onClose: () => void }) {\n const state = useExternal(session);\n const [where, setWhere] = useState<Readonly<Record<number, 'shown' | 'not-on-page'>>>({});\n const [place, setPlace] = useState<CSSProperties | undefined>(undefined);\n useLayoutEffect(() => { setPlace(reviewPlace(anchor)); }, [anchor]);\n useEffect(() => () => { session.highlight(undefined); }, [session]);\n const hover = (id: number | undefined): void => {\n const result = session.highlight(id);\n if (id !== undefined && result !== 'none') setWhere(value => ({ ...value, [id]: result }));\n };\n const onKeyDown = (event: KeyboardEvent<HTMLElement>): void => { if (event.key === 'Escape') { event.stopPropagation(); onClose(); } };\n return <section data-boring=\"feedback\" data-feedback-ignore=\"\" role=\"dialog\" aria-label=\"Review feedback\" data-testid=\"feedback-review\" className=\"boring-feedback-review\"\n style={place} onKeyDown={onKeyDown} onMouseLeave={() => hover(undefined)}>\n <header className=\"boring-feedback-sheet-header\"><h2>Feedback · {state.route}</h2>\n <button type=\"button\" className=\"boring-feedback-icon\" aria-label=\"Close review\" data-testid=\"feedback-review-close\" onClick={onClose}>×</button></header>\n {state.problem && <p className=\"boring-feedback-warning\" role=\"alert\" data-testid=\"feedback-review-problem\">{state.problem}</p>}\n <ol className=\"boring-feedback-notes\">\n {state.notes.map(note => <li key={note.id} data-testid=\"feedback-review-note\" data-note={note.number} data-where={where[note.id]}\n onMouseEnter={() => hover(note.id)} onFocus={() => hover(note.id)}>\n <span className=\"boring-feedback-note-head\"><span className=\"boring-feedback-pin\" aria-hidden=\"true\">{pinMark(note.number)}</span>\n <ElementName note={note} />\n {note.from === 'voice' && <span className=\"boring-feedback-badge\" data-testid=\"feedback-review-voice\">voice</span>}\n {where[note.id] === 'not-on-page' && <span className=\"boring-feedback-badge\" data-testid=\"feedback-review-elsewhere\">not on this page</span>}\n <button type=\"button\" className=\"boring-feedback-icon\" aria-label={`Remove note ${note.number}`} data-testid=\"feedback-review-remove\" onClick={() => session.removeNote(note.id)}>×</button></span>\n <textarea rows={2} aria-label={`Note ${note.number}`} data-testid=\"feedback-review-text\" value={note.text} placeholder={note.recorded ? 'Nothing was said here: type a note, or remove the pin' : undefined}\n onChange={event => session.setNoteText(note.id, event.target.value)} />\n </li>)}\n </ol>\n <label className=\"boring-feedback-field\"><span>General note</span>\n <textarea rows={2} data-testid=\"feedback-review-general\" placeholder=\"Anything else? (optional)\" value={state.general} onChange={event => session.setGeneral(event.target.value)} /></label>\n <p className=\"boring-feedback-meta\">{state.stepCount > 1 ? `${plural(state.stepCount, 'step', 'steps')} recorded between the notes. ` : ''}{copy ? 'Copy the report and paste it anywhere.' : 'Sent with your next message.'}</p>\n {copy && <div className=\"boring-feedback-actions\">\n <button type=\"button\" className=\"boring-feedback-button\" data-variant=\"primary\" data-testid=\"feedback-copy\" onClick={() => { void session.copy(); }}>Copy report</button></div>}\n {copy && <p role=\"status\" aria-live=\"polite\" className=\"boring-feedback-status\" data-testid=\"feedback-status\" data-copy={state.copy.kind}>\n {state.copy.kind === 'copied' ? 'Report copied. Paste it anywhere.' : state.copy.kind === 'manual' ? 'The clipboard is not available here: select the report below and copy it.' : state.copy.kind === 'refused' ? state.copy.reason : ''}</p>}\n {copy && state.copy.kind === 'manual' && <textarea readOnly className=\"boring-feedback-manual\" data-testid=\"feedback-manual-copy\" value={state.copy.text} rows={6} onFocus={event => event.currentTarget.select()} />}\n {copy && state.copy.kind === 'copied' && <details className=\"boring-feedback-copied\"><summary>Copied report</summary><pre data-testid=\"feedback-copy-text\">{state.copy.text}</pre></details>}\n </section>;\n}\n\n/**\n * A note's element as one calm line: the readable name (semibold) and the file basename (muted, monospace), the full path in the\n * tooltip. The same reading as the pi-chat feedback card's element line (`readableElement` from `@hachej/boring-feedback/format`).\n */\nexport function ElementName({ note }: { readonly note: SessionNote }) {\n const readable = note.fallback ? readableElement(note.fallback, note.source) : { name: note.label };\n return <span className=\"boring-feedback-element\" data-testid=\"feedback-review-element\" title={'path' in readable && readable.path ? readable.path : note.label}>\n <span className=\"boring-feedback-element-name\" data-testid=\"feedback-review-name\">{readable.name}</span>\n {'file' in readable && readable.file && <span className=\"boring-feedback-element-file\" data-testid=\"feedback-review-file\">{readable.file}</span>}\n </span>;\n}\n\n/** What `useComposerFeedback` returns: the structural shape of pi-chat's `ComposerFeedback`. */\nexport interface ComposerFeedbackProp {\n readonly annotate: () => void;\n readonly active: boolean;\n readonly count: number;\n readonly clear: () => void;\n readonly attach: (text: string) => Promise<{ readonly kind: 'ok'; readonly text: string } | { readonly kind: 'refused'; readonly reason: string }>;\n readonly sent: () => void;\n}\n\n/** A Markdown fence longer than any backtick run in the text. */\nfunction fenced(text: string): string {\n const longest = Math.max(0, ...[...text.matchAll(/`+/g)].map(match => match[0].length));\n const fence = '`'.repeat(Math.max(3, longest + 1));\n return `${fence}markdown\\n${text.endsWith('\\n') ? text : `${text}\\n`}${fence}`;\n}\n\n/** Touch or a compact screen: the panel covers the app and there is no hover, so annotating is point mode (use the app, then point). */\nfunction pointsInstead(): boolean {\n return globalThis.matchMedia?.('(pointer: coarse)').matches === true || (globalThis.innerWidth ?? 1024) < 768;\n}\n\n/**\n * Starts (or resumes) annotating in either mode, with Esc as a way out. The session swallows Esc on the page (it only closes a pin), so\n * this listens on the window's capture phase BEFORE the session starts, and ends the mode (Done: the notes wait for Send) when no pin is\n * open. From the review, `resume` keeps the notes, anchors and save guards: a second round adds to the same draft.\n */\nfunction startAnnotating(session: FeedbackSession): void {\n const view = globalThis.window;\n const onKey = (event: globalThis.KeyboardEvent): void => {\n // Esc that ends annotating is spent: a host layer around the composer (a panel that closes on Esc) must not also react to it.\n if (event.key === 'Escape' && !session.getSnapshot().bubble) { event.preventDefault(); event.stopPropagation(); void session.done(); }\n };\n view?.addEventListener('keydown', onKey, { capture: true });\n session.resume(pointsInstead() ? { mode: 'point' } : {});\n const stop = session.subscribe(() => {\n if (session.getSnapshot().phase === 'active') return;\n view?.removeEventListener('keydown', onKey, { capture: true });\n stop();\n });\n}\n\n/**\n * The composer's `feedback` prop for a session: ONE annotate toggle (targeting with a hover highlight; point mode on touch and compact\n * screens, and again after Esc to add more), a count of the notes taken, and Send attaching them: a saved report as its `@` mention (the host's mention resolver inlines\n * the stored report with the person's access), otherwise the rendered report inline in a fenced block. Pair it with `NoteBubble`.\n */\nexport function useComposerFeedback(session: FeedbackSession | undefined): ComposerFeedbackProp | undefined {\n const state = useSyncState(session);\n if (!session || !state) return undefined;\n const open = state.phase === 'active' || state.phase === 'processing';\n return {\n annotate: () => {\n const phase = session.getSnapshot().phase;\n if (phase === 'active') void session.done();\n else if (phase === 'idle' || phase === 'review') startAnnotating(session);\n },\n active: open,\n // A note being typed in the pin counts: Send saves it.\n count: noteCount(state) + (state.bubble?.fresh && state.bubble.text.trim() ? 1 : 0),\n clear: () => { session.highlight(undefined); session.discard(); },\n attach: async text => {\n // Send while annotating ends it first: the pin being typed is kept.\n if (session.getSnapshot().phase === 'active') await session.done();\n const attached = await session.attach();\n if (attached.kind === 'refused') return attached;\n const body = attached.kind === 'mention' ? `@${attached.path}` : fenced(attached.text);\n return { kind: 'ok', text: text.trim() ? `${text.trimEnd()}\\n\\n${body}` : body };\n },\n sent: () => session.sent(),\n };\n}\n\nconst NO_STATE = { getSnapshot: () => undefined, subscribe: () => () => {} };\nfunction useSyncState(session: FeedbackSession | undefined): FeedbackSessionSnapshot | undefined {\n return useExternal<FeedbackSessionSnapshot | undefined>(session ?? NO_STATE);\n}\n",
22
22
  "type": "registry:component",
23
23
  "target": "components/feedback/feedback-session.tsx"
24
24
  },
@@ -72,7 +72,7 @@
72
72
  },
73
73
  {
74
74
  "path": "registry/feedback/feedback-labels.ts",
75
- "content": "// The words of the feedback controls that cancel a selection, ask a general question and drive point mode, as data. The defaults are English, like the\n// rest of the copied components; `feedbackLabelsFr` is the French set (pass it as `labels` to AnnotateSheet, FeedbackBar,\n// NoteBubble and PointMode). A host may pass any subset: what it leaves out keeps the default.\nexport interface FeedbackLabels {\n /** The annotate sheet's title for a selected zone, and the bubble's accessible name prefix is `note`. */\n readonly title: string;\n readonly close: string;\n /** The button that drops the selected zone and keeps what was typed. */\n readonly cancelSelection: string;\n /** The entry point (bar button, sheet button, general sheet title): a note with no element attached. */\n readonly askGeneral: string;\n readonly generalPlaceholder: string;\n readonly generalHint: string;\n /** The sheet's field label for a selected zone / for a general question. */\n readonly changeField: string;\n readonly generalField: string;\n readonly copyReport: string;\n readonly save: string;\n readonly saveAgain: string;\n readonly saving: string;\n readonly saved: string;\n readonly bubblePlaceholder: string;\n readonly bubbleHint: string;\n /** Announced when the selection was cancelled, so a screen reader hears that the zone is gone. */\n readonly selectionCancelled: string;\n readonly generalOpened: string;\n /** Point mode's bar (phones): the hint, the count of picks, Done, Cancel and the \"use the app\" toggle with its hint. */\n readonly pointHint: string;\n readonly pointCount: (count: number) => string;\n readonly pointDone: string;\n readonly pointCancel: string;\n readonly pointUseApp: string;\n readonly pointUsing: string;\n readonly pointBar: string;\n}\n\nexport const feedbackLabels: FeedbackLabels = {\n title: 'Feedback',\n close: 'Close',\n cancelSelection: 'Cancel selection',\n askGeneral: 'Ask a general question',\n generalPlaceholder: 'Your question (no element attached)',\n generalHint: 'Esc to close · Done to send',\n changeField: 'What should change?',\n generalField: 'Your question',\n copyReport: 'Copy report',\n save: 'Save',\n saveAgain: 'Save again',\n saving: 'Saving…',\n saved: 'Saved',\n bubblePlaceholder: \"What's wrong here?\",\n bubbleHint: 'Enter to save · Esc to discard',\n selectionCancelled: 'Selection cancelled.',\n generalOpened: 'General question: no element attached.',\n pointHint: 'Tap an element to comment',\n pointCount: count => `${count} ${count === 1 ? 'selection' : 'selections'}`,\n pointDone: 'Done',\n pointCancel: 'Cancel',\n pointUseApp: 'Use the app',\n pointUsing: 'Use the app, then come back to comment',\n pointBar: 'Point mode',\n};\n\nexport const feedbackLabelsFr: FeedbackLabels = {\n title: 'Retour',\n close: 'Fermer',\n cancelSelection: 'Annuler la sélection',\n askGeneral: 'Poser une question générale',\n generalPlaceholder: 'Votre question (sans zone)',\n generalHint: 'Échap pour fermer · Terminé pour envoyer',\n changeField: 'Que faut-il changer ?',\n generalField: 'Votre question',\n copyReport: 'Copier le rapport',\n save: 'Enregistrer',\n saveAgain: 'Enregistrer à nouveau',\n saving: 'Enregistrement…',\n saved: 'Enregistré',\n bubblePlaceholder: \"Qu'est-ce qui ne va pas ici ?\",\n bubbleHint: 'Entrée pour valider · Échap pour annuler',\n selectionCancelled: 'Sélection annulée.',\n generalOpened: 'Question générale : aucune zone sélectionnée.',\n pointHint: 'Touchez un élément à commenter',\n pointCount: count => `${count} ${count <= 1 ? 'sélection' : 'sélections'}`,\n pointDone: 'Terminé',\n pointCancel: 'Annuler',\n pointUseApp: 'Utiliser l’app',\n pointUsing: 'Utilisez l’app, puis revenez commenter',\n pointBar: 'Mode pointage',\n};\n\n/** The default labels with the host's overrides on top. */\nexport const withLabels = (labels?: Partial<FeedbackLabels>): FeedbackLabels => (labels ? { ...feedbackLabels, ...labels } : feedbackLabels);\n",
75
+ "content": "// The words of the feedback controls that cancel a selection, ask a general question and drive point mode, as data. The defaults are English, like the\n// rest of the copied components; `feedbackLabelsFr` is the French set (pass it as `labels` to AnnotateSheet, FeedbackBar,\n// NoteBubble and PointMode). A host may pass any subset: what it leaves out keeps the default.\nexport interface FeedbackLabels {\n /** The annotate sheet's title for a selected zone, and the bubble's accessible name prefix is `note`. */\n readonly title: string;\n readonly close: string;\n /** The button that drops the selected zone and keeps what was typed. */\n readonly cancelSelection: string;\n /** The entry point (bar button, sheet button, general sheet title): a note with no element attached. */\n readonly askGeneral: string;\n /** The sheet's field label for a selected zone / for a general question. */\n readonly changeField: string;\n readonly generalField: string;\n readonly copyReport: string;\n readonly save: string;\n readonly saveAgain: string;\n readonly saving: string;\n readonly saved: string;\n readonly bubblePlaceholder: string;\n /** The pin's one-line desktop hint: holding Alt passes clicks to the app. */\n readonly altHint: string;\n /** The pin's field label, its Add button, and the mic inside the field (record, stop, transcribing, nothing heard, failed). */\n readonly add: string;\n readonly voiceRecord: string;\n readonly voiceStop: string;\n readonly voiceTranscribing: string;\n readonly voiceNothing: string;\n readonly voiceFailed: string;\n /** Announced when the selection was cancelled, so a screen reader hears that the zone is gone. */\n readonly selectionCancelled: string;\n /** Point mode's bar (phones): the hint, the count of picks, Done, Cancel and the \"use the app\" toggle with its hint. */\n readonly pointHint: string;\n readonly pointCount: (count: number) => string;\n readonly pointDone: string;\n readonly pointCancel: string;\n readonly pointUseApp: string;\n readonly pointUsing: string;\n readonly pointBar: string;\n}\n\nexport const feedbackLabels: FeedbackLabels = {\n title: 'Feedback',\n close: 'Close',\n cancelSelection: 'Cancel selection',\n askGeneral: 'Ask a general question',\n changeField: 'What should change?',\n generalField: 'Your question',\n copyReport: 'Copy report',\n save: 'Save',\n saveAgain: 'Save again',\n saving: 'Saving…',\n saved: 'Saved',\n bubblePlaceholder: \"What's wrong here?\",\n altHint: 'Hold ⌥ to use the app',\n add: 'Add note',\n voiceRecord: 'Speak your note',\n voiceStop: 'Stop recording',\n voiceTranscribing: 'Transcribing…',\n voiceNothing: 'Nothing was heard. Your text is kept.',\n voiceFailed: 'The recording could not be transcribed. Your text is kept.',\n selectionCancelled: 'Selection cancelled.',\n pointHint: 'Tap an element to comment',\n pointCount: count => `${count} ${count === 1 ? 'selection' : 'selections'}`,\n pointDone: 'Done',\n pointCancel: 'Cancel',\n pointUseApp: 'Use the app',\n pointUsing: 'Use the app, then come back to comment',\n pointBar: 'Point mode',\n};\n\nexport const feedbackLabelsFr: FeedbackLabels = {\n title: 'Retour',\n close: 'Fermer',\n cancelSelection: 'Annuler la sélection',\n askGeneral: 'Poser une question générale',\n changeField: 'Que faut-il changer ?',\n generalField: 'Votre question',\n copyReport: 'Copier le rapport',\n save: 'Enregistrer',\n saveAgain: 'Enregistrer à nouveau',\n saving: 'Enregistrement…',\n saved: 'Enregistré',\n bubblePlaceholder: \"Qu'est-ce qui ne va pas ici ?\",\n altHint: 'Maintenez ⌥ pour utiliser l’app',\n add: 'Ajouter la note',\n voiceRecord: 'Dicter votre note',\n voiceStop: 'Arrêter l’enregistrement',\n voiceTranscribing: 'Transcription…',\n voiceNothing: 'Rien n’a été entendu. Votre texte est conservé.',\n voiceFailed: 'L’enregistrement n’a pas pu être transcrit. Votre texte est conservé.',\n selectionCancelled: 'Sélection annulée.',\n pointHint: 'Touchez un élément à commenter',\n pointCount: count => `${count} ${count <= 1 ? 'sélection' : 'sélections'}`,\n pointDone: 'Terminé',\n pointCancel: 'Annuler',\n pointUseApp: 'Utiliser l’app',\n pointUsing: 'Utilisez l’app, puis revenez commenter',\n pointBar: 'Mode pointage',\n};\n\n/** The default labels with the host's overrides on top. */\nexport const withLabels = (labels?: Partial<FeedbackLabels>): FeedbackLabels => (labels ? { ...feedbackLabels, ...labels } : feedbackLabels);\n",
76
76
  "type": "registry:lib",
77
77
  "target": "components/feedback/feedback-labels.ts"
78
78
  },
@@ -744,13 +744,56 @@
744
744
  "color": "var(--boring-feedback-text)",
745
745
  "font": "inherit"
746
746
  },
747
+ "[data-boring=\"feedback\"] .boring-feedback-pin-field": {
748
+ "display": "grid"
749
+ },
750
+ "[data-boring=\"feedback\"] .boring-feedback-pin-field > *": {
751
+ "grid-column": "1"
752
+ },
753
+ "[data-boring=\"feedback\"] .boring-feedback-pin-field > textarea, [data-boring=\"feedback\"] .boring-feedback-pin-field > .boring-feedback-pin-mic": {
754
+ "grid-row": "1"
755
+ },
756
+ "[data-boring=\"feedback\"] .boring-feedback-pin-field > .boring-feedback-voice-problem": {
757
+ "grid-row": "2"
758
+ },
759
+ "[data-boring=\"feedback\"] .boring-feedback-pin-field textarea": {
760
+ "display": "block",
761
+ "box-sizing": "border-box",
762
+ "width": "100%",
763
+ "padding-right": "3.25rem"
764
+ },
765
+ "[data-boring=\"feedback\"] .boring-feedback-pin-mic": {
766
+ "justify-self": "end",
767
+ "align-self": "end",
768
+ "display": "inline-flex",
769
+ "align-items": "center",
770
+ "justify-content": "center",
771
+ "width": "2.75rem",
772
+ "height": "2.75rem",
773
+ "padding": "0",
774
+ "border": "0",
775
+ "border-radius": "var(--boring-feedback-radius-md)",
776
+ "background": "transparent",
777
+ "color": "var(--boring-feedback-muted)",
778
+ "cursor": "pointer"
779
+ },
780
+ "[data-boring=\"feedback\"] .boring-feedback-pin-mic:hover": {
781
+ "color": "var(--boring-feedback-text, inherit)"
782
+ },
783
+ "[data-boring=\"feedback\"] .boring-feedback-pin-mic[aria-pressed=\"true\"]": {
784
+ "color": "var(--boring-feedback-accent)"
785
+ },
786
+ "[data-boring=\"feedback\"] .boring-feedback-pin-mic:disabled": {
787
+ "opacity": "0.6",
788
+ "cursor": "default"
789
+ },
747
790
  "[data-boring=\"feedback\"] .boring-feedback-bubble-actions": {
748
791
  "display": "flex",
749
792
  "flex-wrap": "wrap",
750
793
  "gap": "0.375rem"
751
794
  },
752
795
  "[data-boring=\"feedback\"] .boring-feedback-bubble-actions .boring-feedback-button": {
753
- "min-height": "2rem",
796
+ "min-height": "2.75rem",
754
797
  "padding": "0 0.625rem",
755
798
  "font-size": "var(--text-sm, 0.875rem)"
756
799
  },
package/r/file-tree.json CHANGED
@@ -4,14 +4,14 @@
4
4
  "title": "File tree",
5
5
  "description": "Browse, search, upload and inspect retained versions through one revisionProvider binding.",
6
6
  "dependencies": [
7
- "@hachej/boring-ui-kit@^0.2.4",
8
- "@hachej/boring-files@^0.2.4",
7
+ "@hachej/boring-ui-kit@^0.3.0",
8
+ "@hachej/boring-files@^0.3.0",
9
9
  "react@^19.0.0",
10
10
  "radix-ui@^1.8.0",
11
11
  "lucide-react@^1.51.0"
12
12
  ],
13
13
  "registryDependencies": [
14
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.4/r/theme.json"
14
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.0/r/theme.json"
15
15
  ],
16
16
  "files": [
17
17
  {
@@ -4,8 +4,8 @@
4
4
  "title": "HTML source and passive preview",
5
5
  "description": "Exact HTML source editing with a reconstructed passive preview and borrowed controller. Browser security qualification remains separate.",
6
6
  "dependencies": [
7
- "@hachej/boring-ui-kit@^0.2.4",
8
- "@hachej/boring-files@^0.2.4",
7
+ "@hachej/boring-ui-kit@^0.3.0",
8
+ "@hachej/boring-files@^0.3.0",
9
9
  "react@^19.0.0",
10
10
  "react-dom@^19.0.0"
11
11
  ],
@@ -4,8 +4,8 @@
4
4
  "title": "Markdown editor",
5
5
  "description": "A host-owned styled wrapper over the concrete borrowed Markdown controller.",
6
6
  "dependencies": [
7
- "@hachej/boring-ui-kit@^0.2.4",
8
- "@hachej/boring-files@^0.2.4",
7
+ "@hachej/boring-ui-kit@^0.3.0",
8
+ "@hachej/boring-files@^0.3.0",
9
9
  "react@^19.0.0",
10
10
  "react-dom@^19.0.0",
11
11
  "marked@^17.0.6",
package/r/pi-ambient.json CHANGED
@@ -4,23 +4,23 @@
4
4
  "title": "Pi ambient agent",
5
5
  "description": "AmbientChat, a compact floating agent bar that grows into a chat window over an existing page, and AgentNotifications, toasts for finished, failed and waiting tasks (with the one guarded browser Notification helper). A block over the pi-chat item; its in-window artifact panel uses the pi-workspace layout.",
6
6
  "dependencies": [
7
- "@hachej/boring-ui-kit@^0.2.4",
7
+ "@hachej/boring-ui-kit@^0.3.0",
8
8
  "react@^19.0.0",
9
9
  "react-dom@^19.0.0",
10
- "@earendil-works/pi-durable@~1.1.0",
11
- "@earendil-works/pi-ai@~1.1.0",
10
+ "@earendil-works/pi-durable@~1.2.0",
11
+ "@earendil-works/pi-ai@~1.2.0",
12
12
  "lucide-react@^1.51.0"
13
13
  ],
14
14
  "registryDependencies": [
15
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.4/r/pi-chat.json",
16
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.4/r/pi-workspace.json",
17
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.4/r/utils.json",
18
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.4/r/button.json"
15
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.0/r/pi-chat.json",
16
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.0/r/pi-workspace.json",
17
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.0/r/utils.json",
18
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.0/r/button.json"
19
19
  ],
20
20
  "files": [
21
21
  {
22
22
  "path": "registry/pi-ambient/ambient.tsx",
23
- "content": "'use client';\n\nimport { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';\nimport type { KeyboardEvent, PointerEvent, ReactNode } from 'react';\nimport type { EntryRecord } from '@earendil-works/pi-durable';\nimport type { ImageContent, ToolCall, ToolResultMessage } from '@earendil-works/pi-ai';\nimport type { NativeChatController } from '@hachej/boring-ui-kit/native-chat';\nimport { BellIcon, ChevronDownIcon, ExternalLinkIcon, GripVerticalIcon, Loader2Icon, MinusIcon, PanelLeftIcon, ThumbsDownIcon, ThumbsUpIcon } from 'lucide-react';\nimport { Button } from '../button/button';\nimport { BlockActions } from '../button/actions';\nimport type { BlockAction, BlockIcon } from '../button/actions';\nimport { CopyButton } from '../pi-chat/code-block';\nimport { Composer } from '../pi-chat/composer';\nimport type { ComposerFeedback } from '../pi-chat/composer';\nimport type { AttachmentsConfig, ConversationsConfig, EffortConfig, MentionsConfig, ModelConfig, SlashConfig } from '../pi-chat/config';\nimport { notifyPermission, requestNotifyPermission } from './browser-notify';\nimport type { NotifyPermission } from './browser-notify';\nimport { ConversationHistory } from '../pi-chat/history';\nimport type { CommandMentions } from '../pi-chat/markdown';\nimport { RowView } from '../pi-chat/message';\nimport type { RowContext } from '../pi-chat/message';\nimport { ChatNotices, Notice } from '../pi-chat/notice';\nimport { AgentNotifications, KindIcon, createNotificationStore, defaultNotificationLabels, finishNotice, pendingQuestion, useNotifications } from './notifications';\nimport type { AgentNotification, NotificationLabels, NotificationStore } from './notifications';\nimport { ChatTextProvider, defaultChatIcons, defaultChatLabels, useMergedText } from '../pi-chat/labels';\nimport type { ChatIcons, ChatLabels } from '../pi-chat/labels';\nimport { MessageQueue } from '../pi-chat/queue';\nimport { artifactKey, collectArtifacts } from '../pi-chat/artifact';\nimport type { ArtifactDescriptor, ArtifactsConfig } from '../pi-chat/artifact';\nimport type { ChatCard, Mode } from '../pi-chat/rows';\nimport { useChatSession, useTranscript } from '../pi-chat/session';\nimport type { ChatFeatureProps, ChatSession } from '../pi-chat/session';\nimport { ArtifactWorkspace } from '../pi-workspace/workspace';\nimport type { WorkspacePanelApi } from '../pi-workspace/workspace';\nimport { cn } from '../utils/utils';\n\nexport { AgentNotifications, createNotificationStore, defaultNotificationLabels, summaryOf, useNotifications, watchConversation } from './notifications';\nexport type { AgentNotification, AgentNotificationsProps, NewNotification, NotificationKind, NotificationLabels, NotificationStore } from './notifications';\n\nconst elapsedText = (seconds: number) => seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m ${String(seconds % 60).padStart(2, '0')}s`;\n\n/** The floating window's own words, over the chat's (`defaultChatLabels`) and the toasts' (`defaultNotificationLabels`). */\nexport const defaultAmbientLabels = {\n ...defaultChatLabels,\n ...defaultNotificationLabels,\n /** Shown while the conversation has no title of its own (the active item of `conversations` provides one). */\n title: 'Agent',\n /** The input's placeholder while idle. */\n placeholder: 'Do anything',\n /** The input's placeholder while the agent works. */\n working: (seconds: number) => `Working for ${elapsedText(seconds)}`,\n region: (title: string) => `Agent: ${title}`,\n openAgent: (title: string, unread: boolean) => `Open agent: ${title}${unread ? ' (new activity)' : ''}`,\n openChat: (title: string) => `Open chat: ${title}`,\n minimize: 'Minimise',\n switchConversation: 'Switch conversation',\n statusWorking: 'Working',\n enableNotifications: 'Notify me when the page is in the background',\n dock: 'Dock chat',\n dockHint: 'Dock chat beside the panel',\n move: 'Move the agent window (arrow keys, Home resets)',\n moveHint: 'Drag to move',\n goodResponse: 'Good response',\n badResponse: 'Bad response',\n openFull: 'Open in full chat',\n empty: 'Ask the agent to do something.',\n loadFailedShort: 'Could not load the conversation.',\n disconnectedShort: 'Disconnected.',\n artifactPanel: 'Artifact panel',\n};\nexport type AmbientLabels = ChatLabels & NotificationLabels & typeof defaultAmbientLabels;\n\n/** The floating window's icons, over the chat's. */\nexport const defaultAmbientIcons = {\n ...defaultChatIcons,\n minimize: MinusIcon as BlockIcon,\n dock: PanelLeftIcon as BlockIcon,\n grip: GripVerticalIcon as BlockIcon,\n switchConversation: ChevronDownIcon as BlockIcon,\n notify: BellIcon as BlockIcon,\n openFull: ExternalLinkIcon as BlockIcon,\n goodResponse: ThumbsUpIcon as BlockIcon,\n badResponse: ThumbsDownIcon as BlockIcon,\n};\nexport type AmbientIcons = ChatIcons & typeof defaultAmbientIcons;\nconst AMBIENT_TEXT = { labels: defaultAmbientLabels as AmbientLabels, icons: defaultAmbientIcons as AmbientIcons };\n\n/** `bar` is the compact bar, `expanded` the same window grown into a chat, `minimized` a small pill. */\nexport type AmbientState = 'bar' | 'expanded' | 'minimized';\n\n/** What the artifact panel inside the window gets besides the full-screen and close controls: the versions of the open artifact and how to switch. */\nexport interface AmbientArtifactPanelApi extends WorkspacePanelApi {\n /** Every known version of the open artifact in this conversation, newest first. */\n readonly versions: readonly ArtifactDescriptor[];\n /** True while the panel follows the newest version; false when one older version is pinned. */\n readonly follow: boolean;\n /** `'latest'` follows the newest version; a descriptor from `versions` pins that one. */\n readonly select: (version: ArtifactDescriptor | 'latest') => void;\n}\n\nexport interface AmbientChatProps extends ChatFeatureProps {\n readonly controller: NativeChatController;\n /**\n * Every word the window shows, over `defaultAmbientLabels` (the chat's labels, the toasts' and its own): `title` while the conversation has\n * none of its own, `placeholder` while idle, `working(seconds)` while the agent works.\n */\n readonly labels?: Partial<AmbientLabels> | undefined;\n /** The window's icons (minimise, dock, grip, …) and the chat's, over `defaultAmbientIcons`. */\n readonly icons?: Partial<AmbientIcons> | undefined;\n /** The agent's avatar before the title in the bar. */\n readonly avatar?: ReactNode;\n /** Host actions in the bar, before Dock: `header` ones as buttons, `menu` ones in a \"…\" menu. Test ids `ambient-action-<id>`, the menu `ambient-action-more`. */\n readonly headerActions?: readonly BlockAction[] | undefined;\n /** `contrast` is a dark window on any page: the root carries `.dark`, so it shows the host's own dark tokens; `surface` follows the host's theme. */\n readonly variant?: 'contrast' | 'surface';\n /** Controlled window state; otherwise `defaultState` starts it and the window keeps it. */\n readonly state?: AmbientState;\n readonly defaultState?: AmbientState;\n readonly onStateChange?: (state: AmbientState) => void;\n /** Where the bar was dragged is remembered under this key. */\n readonly storageKey?: string;\n /** `session` (default) remembers the position for this browser tab's session; `local` keeps it in `localStorage` across sessions. */\n readonly storage?: 'session' | 'local';\n /** Host controls in the composer row, between the effort picker and Send (for example a voice button the host guards itself). Absent: nothing is shown. */\n readonly tools?: ReactNode;\n /** Hand the conversation to a full `PiChat` or `ArtifactWorkspace`. Adds an \"Open in full chat\" action to replies. */\n readonly onOpenFull?: (conversationId: string) => void;\n /** Adds thumbs up and down to replies. `value` is `null` when the person takes the rating back. */\n readonly onFeedback?: (reply: { readonly key: string; readonly text: string }, value: 'up' | 'down' | null) => void;\n /** Toasts for finished, failed and waiting tasks. Pass a store to feed it for background conversations too; `false` turns them off. */\n readonly notifications?: NotificationStore | false;\n readonly autoDismissMs?: number;\n readonly maxToasts?: number;\n /** System notifications while the page is hidden. A bell in the header asks for permission when the person chooses to; nothing is asked on load. */\n readonly systemNotifications?: boolean;\n /**\n * The viewer shown inside the window when an artifact card is opened: the window widens into chat on the left and this panel on the right\n * (`ArtifactWorkspace`: draggable divider, full screen, a full-screen sheet on a phone). Build it from the viewer registry (`ViewerFrame`, ...),\n * wrapped in `ViewerWindowProvider` with `api.fullscreen` / `api.onFullscreenChange` so its bar shows the full screen button.\n */\n readonly artifactPanel?: (artifact: ArtifactDescriptor, api: AmbientArtifactPanelApi) => ReactNode;\n /** `window` (default when `artifactPanel` is given) opens artifacts inside the window; `host` calls `artifacts.open` instead, for hosts that show them in their own viewer. */\n readonly artifactTarget?: 'window' | 'host';\n /** The title's ⌄ opens this list to switch conversations or tasks. */\n readonly conversations?: ConversationsConfig;\n /** Adds a \"Dock chat\" action to the header, for a host that floats this window out of a docked chat (`ArtifactWorkspace` with `floatBelow`). It shares the controller with the docked `PiChat`, so nothing is lost. */\n readonly onDock?: () => void;\n /** The optional Feedback button in the composer (the registry `feedback` item's `useComposerFeedback`). Omit it and nothing changes. */\n readonly feedback?: ComposerFeedback;\n readonly className?: string;\n}\n\ninterface Position { readonly right: number; readonly bottom: number }\nconst MARGIN = 8, DEFAULT_POSITION: Position = { right: 16, bottom: 16 };\n\nfunction storageFor(persist: 'session' | 'local'): Storage | undefined { try { return persist === 'local' ? globalThis.localStorage : globalThis.sessionStorage; } catch { return undefined; } }\nfunction readPosition(key: string, persist: 'session' | 'local'): Position | undefined {\n try {\n const value: unknown = JSON.parse(storageFor(persist)?.getItem(key) ?? 'null');\n const { right, bottom } = (value ?? {}) as { right?: unknown; bottom?: unknown };\n return typeof right === 'number' && typeof bottom === 'number' && Number.isFinite(right) && Number.isFinite(bottom) ? { right, bottom } : undefined;\n } catch { return undefined; }\n}\nfunction writePosition(key: string, persist: 'session' | 'local', position: Position) {\n try { storageFor(persist)?.setItem(key, JSON.stringify(position)); } catch { /* the position is a convenience */ }\n}\nconst clamp = (position: Position, size: { w: number; h: number }, viewport: { w: number; h: number }): Position => ({\n right: Math.round(Math.min(Math.max(MARGIN, position.right), Math.max(MARGIN, viewport.w - size.w - MARGIN))),\n bottom: Math.round(Math.min(Math.max(MARGIN, position.bottom), Math.max(MARGIN, viewport.h - size.h - MARGIN))),\n});\n\n// The layout viewport that fixed positioning uses (without scrollbars), not the window's outer size.\nconst viewportSize = () => { const root = globalThis.document?.documentElement; return { w: root?.clientWidth || globalThis.innerWidth || 1024, h: root?.clientHeight || globalThis.innerHeight || 768 }; };\nfunction usePhone(): boolean {\n const query = '(max-width: 639px)';\n const [phone, setPhone] = useState(() => globalThis.matchMedia?.(query).matches === true);\n useEffect(() => {\n const list = globalThis.matchMedia?.(query);\n if (!list) return;\n const update = () => setPhone(list.matches);\n update();\n list.addEventListener('change', update);\n return () => list.removeEventListener('change', update);\n }, []);\n return phone;\n}\n\n/** Whole seconds since `active` became true; 0 while inactive. */\nfunction useElapsed(active: boolean): number {\n const [seconds, setSeconds] = useState(0);\n useEffect(() => {\n if (!active) { setSeconds(0); return; }\n const started = Date.now();\n setSeconds(0);\n const timer = setInterval(() => setSeconds(Math.floor((Date.now() - started) / 1000)), 1000);\n return () => clearInterval(timer);\n }, [active]);\n return seconds;\n}\n\n/** Pushes a toast when a run finishes or fails and when a question starts waiting; takes it back when the question is answered or a new run starts. */\nfunction useRunFeed({ enabled, store, conversationId, title, session, suppress, onOutcome, waiting }: {\n readonly enabled: boolean; readonly store: NotificationStore; readonly conversationId: string; readonly title: string; readonly session: ChatSession; readonly waiting: string;\n readonly suppress: () => boolean; readonly onOutcome: (outcome: 'done' | 'error' | null) => void;\n}) {\n const { working, waitingForAnswer, rows, connected } = session;\n const before = useRef({ working, waiting: waitingForAnswer });\n const latest = useRef({ rows, title, suppress, onOutcome, connected, waiting });\n latest.current = { rows, title, suppress, onOutcome, connected, waiting };\n useEffect(() => {\n const was = before.current;\n before.current = { working, waiting: waitingForAnswer };\n const now = latest.current;\n if (working && !was.working) { now.onOutcome(null); store.dismiss(`${conversationId}:done`); }\n if (!enabled) return;\n if (waitingForAnswer && !was.waiting && !now.suppress()) store.push({ id: `${conversationId}:input`, kind: 'input', title: now.title, summary: pendingQuestion(now.rows) ?? now.waiting, conversationId });\n if (!waitingForAnswer && was.waiting) store.dismiss(`${conversationId}:input`);\n if (was.working && !working && now.connected) {\n const finished = finishNotice(now.rows);\n if (!finished) return;\n now.onOutcome(finished.kind);\n if (!now.suppress()) store.push({ id: `${conversationId}:done`, kind: finished.kind, title: now.title, summary: finished.summary, conversationId });\n }\n }, [working, waitingForAnswer, enabled, store, conversationId]);\n}\n\n/** A compact floating agent bar over an existing page; it grows into a chat window in place. Borrows a concrete native controller like `PiChat`. */\nexport function AmbientChat(props: AmbientChatProps) {\n const active = useRef(props.controller); active.current = props.controller;\n const [mount, setMount] = useState({ controller: props.controller, sequence: 0 });\n if (mount.controller !== props.controller) setMount({ controller: props.controller, sequence: mount.sequence + 1 });\n // The window state and the toasts belong to the bar, not to one conversation: they survive switching conversations.\n const [local, setLocal] = useState<AmbientState>(props.defaultState ?? 'bar');\n const internalStore = useMemo(createNotificationStore, []);\n const text = useMergedText<AmbientLabels, AmbientIcons>(props.labels, props.icons, AMBIENT_TEXT);\n return <ChatTextProvider value={text}><AmbientSession key={mount.sequence} {...props} labels={text.labels} icons={text.icons} activeController={active} windowState={props.state ?? local} store={props.notifications || internalStore}\n onWindowState={next => { if (props.state === undefined) setLocal(next); props.onStateChange?.(next); }} /></ChatTextProvider>;\n}\n\nconst iconButton = 'inline-flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-lg text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/60 max-sm:size-11 pointer-coarse:size-11 motion-reduce:transition-none';\n\ntype SessionProps = Omit<AmbientChatProps, 'labels' | 'icons'> & { readonly labels: AmbientLabels; readonly icons: AmbientIcons; readonly activeController: { readonly current: NativeChatController }; readonly windowState: AmbientState; readonly store: NotificationStore; readonly onWindowState: (state: AmbientState) => void };\n\nfunction AmbientSession({ controller, labels, icons, avatar, headerActions, messageActions, variant = 'contrast', mode = 'expert', actions,\n windowState, onWindowState, store, storageKey = 'boring.ambient.position', storage = 'session', tools, onOpenFull, onFeedback, notifications, autoDismissMs, maxToasts, systemNotifications = false,\n renderEntry, renderTool, groupTool, commandMentions, onOpenImage, onCopy, onComposerKeyDown, fileAccept = 'image/*', slash, mentions, attachments, model, effort, replyFooter, artifacts: hostArtifacts, artifactPanel, artifactTarget, conversations, onDock, feedback: composerFeedback, className,\n activeController }: SessionProps) {\n const { title } = labels;\n const stateRef = useRef(windowState); stateRef.current = windowState;\n // The artifact open inside the window, and whether it follows the newest version. It belongs to the expanded window: leaving that state closes it.\n const [opened, setOpened] = useState<{ readonly descriptor: ArtifactDescriptor; readonly follow: boolean } | null>(null);\n const [fullscreen, setFullscreen] = useState(false);\n const setWindowState = (next: AmbientState) => {\n if (next !== 'expanded') { setOpened(null); setFullscreen(false); }\n if (next !== stateRef.current) onWindowState(next);\n };\n const expanded = windowState === 'expanded', minimized = windowState === 'minimized';\n const phone = usePhone();\n const root = useRef<HTMLDivElement>(null), titleButton = useRef<HTMLButtonElement>(null), pill = useRef<HTMLButtonElement>(null);\n const [picking, setPicking] = useState(false);\n const [feedback, setFeedback] = useState<Readonly<Record<string, 'up' | 'down'>>>({});\n const [outcome, setOutcome] = useState<'done' | 'error' | null>(null);\n const [permission, setPermission] = useState<NotifyPermission>('unsupported');\n useEffect(() => { setPermission(notifyPermission()); }, []);\n useEffect(() => { if (!outcome) return; const timer = setTimeout(() => setOutcome(null), 8000); return () => clearTimeout(timer); }, [outcome]);\n\n // Replies get copy, optional feedback and the time; the host may hand the conversation to its full chat.\n const conversationRef = useRef('');\n const replyActions = useMemo<NonNullable<RowContext['replyActions']>>(() => (reply, host) => <div data-testid=\"reply-actions\" className=\"mt-2 -ml-2 flex items-center gap-0.5 text-muted-foreground\">\n <CopyButton text={reply.text} label={labels.copyResponse} iconOnly {...(onCopy ? { onCopy } : {})} className=\"size-8 p-0 text-muted-foreground max-sm:size-11\" />\n {onFeedback && <>\n {(['up', 'down'] as const).map(value => <button key={value} type=\"button\" data-testid={`feedback-${value}`} aria-pressed={feedback[reply.key] === value} aria-label={value === 'up' ? labels.goodResponse : labels.badResponse}\n title={value === 'up' ? labels.goodResponse : labels.badResponse}\n onClick={() => { const next = feedback[reply.key] === value ? null : value; setFeedback(current => { const { [reply.key]: _removed, ...rest } = current; return next ? { ...rest, [reply.key]: next } : rest; }); onFeedback({ key: reply.key, text: reply.text }, next); }}\n className={cn(iconButton, 'aria-pressed:text-foreground')}>{value === 'up' ? <icons.goodResponse className=\"size-3.5\" aria-hidden=\"true\" /> : <icons.badResponse className=\"size-3.5\" aria-hidden=\"true\" />}</button>)}\n </>}\n {onOpenFull && <button type=\"button\" data-testid=\"open-full\" aria-label={labels.openFull} title={labels.openFull} onClick={() => onOpenFull(conversationRef.current)} className={iconButton}><icons.openFull className=\"size-3.5\" aria-hidden=\"true\" /></button>}\n {host}\n {reply.timestamp !== undefined && <time dateTime={new Date(reply.timestamp).toISOString()} data-testid=\"reply-time\" className=\"ml-1.5 text-xs tabular-nums\">{new Date(reply.timestamp).toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' })}</time>}\n </div>, [onCopy, onFeedback, onOpenFull, feedback, labels, icons]);\n const scrollToBottom = useRef<() => void>(() => {});\n const rowExtras = useMemo(() => ({ replyActions }), [replyActions]);\n const inWindow = Boolean(artifactPanel) && artifactTarget !== 'host';\n const detect = hostArtifacts?.detect;\n const versionsRef = useRef<readonly ArtifactDescriptor[]>([]);\n const artifacts = useMemo<ArtifactsConfig | undefined>(() => !inWindow ? hostArtifacts : {\n open: descriptor => { const newest = versionsRef.current.filter(version => artifactKey(version) === artifactKey(descriptor)).reduce((top, version) => Math.max(top, version.ordinal ?? 0), 0); setOpened({ descriptor, follow: (descriptor.ordinal ?? 0) >= newest }); },\n isOpen: descriptor => opened !== null && artifactKey(opened.descriptor) === artifactKey(descriptor) && (opened.follow ? (descriptor.ordinal ?? 0) >= (versionsRef.current.find(version => artifactKey(version) === artifactKey(descriptor))?.ordinal ?? 0) : descriptor.revision === opened.descriptor.revision),\n ...(detect ? { detect } : {}),\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [inWindow, hostArtifacts, opened, detect]);\n const session = useChatSession({ controller, activeController, mode, actions, renderEntry, renderTool, groupTool, commandMentions, onOpenImage, onCopy, onComposerKeyDown, fileAccept,\n slash, mentions, attachments, model, effort, artifacts, messageActions, replyFooter, afterSend: () => scrollToBottom.current(), rowExtras, feedback: composerFeedback });\n const { state, derived, rows, queued, queueActions, working, waitingForAnswer, error, act, textarea, rowContext, composer, empty, loading } = session;\n const transcript = useTranscript(rows);\n const versions = useMemo(() => inWindow ? collectArtifacts(state.view, detect).sort((a, b) => (b.ordinal ?? 0) - (a.ordinal ?? 0)) : [], [inWindow, state.view, detect]);\n versionsRef.current = versions;\n const known = opened ? versions.filter(version => artifactKey(version) === artifactKey(opened.descriptor)) : [];\n const shownArtifact = opened ? (opened.follow ? known[0] ?? opened.descriptor : opened.descriptor) : undefined;\n const { stick, hidden } = transcript;\n scrollToBottom.current = () => { void stick.scrollToBottom('instant'); };\n const { barStart, barNote } = session.pickers();\n\n const conversationId = String(conversations?.activeId ?? state.conversationId);\n conversationRef.current = conversationId;\n const activeItem = conversations?.items.find(item => item.id === conversations.activeId);\n const shownTitle = activeItem?.title?.trim() || title;\n const toasts = useNotifications(store);\n useRunFeed({ enabled: notifications !== false, store, conversationId, title: shownTitle, session, onOutcome: setOutcome, waiting: labels.waitingForAnswer,\n suppress: () => stateRef.current === 'expanded' && globalThis.document?.visibilityState !== 'hidden' });\n const unread = !expanded && toasts.length > 0;\n const seconds = useElapsed(working);\n\n // Position: offsets from the bottom right corner, remembered for the session, clamped to the viewport at render so a smaller window restores them.\n const [position, setPosition] = useState<Position>(() => readPosition(storageKey, storage) ?? DEFAULT_POSITION);\n const [viewport, setViewport] = useState(viewportSize);\n const [size, setSize] = useState({ w: 0, h: 0 });\n useEffect(() => {\n const onResize = () => setViewport(viewportSize());\n globalThis.addEventListener('resize', onResize);\n return () => globalThis.removeEventListener('resize', onResize);\n }, []);\n useEffect(() => {\n const element = root.current;\n if (!element || typeof ResizeObserver === 'undefined') return;\n const observer = new ResizeObserver(() => setSize({ w: element.offsetWidth, h: element.offsetHeight }));\n observer.observe(element);\n setSize({ w: element.offsetWidth, h: element.offsetHeight });\n return () => observer.disconnect();\n }, [minimized, expanded]);\n // The size and the viewport also follow every render, so the clamp below never works from a stale measurement (the title or status icon can change the pill's width; a scrollbar can appear without a resize event).\n useLayoutEffect(() => {\n const element = root.current;\n const fresh = viewportSize();\n setViewport(current => current.w === fresh.w && current.h === fresh.h ? current : fresh);\n if (element) setSize(current => current.w === element.offsetWidth && current.h === element.offsetHeight ? current : { w: element.offsetWidth, h: element.offsetHeight });\n });\n const placed = clamp(position, size, viewport);\n // Moves and the release are followed on the window, so a drag keeps working when the pointer leaves the handle (and when pointer capture is unavailable).\n // `threshold` is how far the pointer must travel before it is a drag: a click on the pill (threshold > 0) still restores it.\n const dragged = useRef(false);\n const beginDrag = (event: PointerEvent<HTMLElement>, threshold: number) => {\n if (phone || event.button !== 0) return;\n dragged.current = false;\n const origin = { pointer: event.pointerId, x: event.clientX, y: event.clientY, start: placed };\n let last = origin.start, moving = threshold === 0;\n const move = (next: globalThis.PointerEvent) => {\n if (next.pointerId !== origin.pointer) return;\n if (!moving) { if (Math.hypot(next.clientX - origin.x, next.clientY - origin.y) < threshold) return; moving = true; dragged.current = true; }\n last = clamp({ right: origin.start.right - (next.clientX - origin.x), bottom: origin.start.bottom - (next.clientY - origin.y) }, size, viewport);\n setPosition(last);\n };\n const end = (next: globalThis.PointerEvent) => {\n if (next.pointerId !== origin.pointer) return;\n globalThis.removeEventListener('pointermove', move); globalThis.removeEventListener('pointerup', end); globalThis.removeEventListener('pointercancel', end);\n if (moving) writePosition(storageKey, storage, last);\n };\n globalThis.addEventListener('pointermove', move); globalThis.addEventListener('pointerup', end); globalThis.addEventListener('pointercancel', end);\n };\n const onGripDown = (event: PointerEvent<HTMLButtonElement>) => { event.preventDefault(); beginDrag(event, 0); };\n // Arrow keys move the window (Shift for big steps); Home puts it back in the default corner.\n const onGripKey = (event: KeyboardEvent<HTMLElement>) => {\n if (phone) return;\n const step = event.shiftKey ? 64 : 16;\n const delta = event.key === 'ArrowLeft' ? { right: step, bottom: 0 } : event.key === 'ArrowRight' ? { right: -step, bottom: 0 } : event.key === 'ArrowUp' ? { right: 0, bottom: step } : event.key === 'ArrowDown' ? { right: 0, bottom: -step } : undefined;\n if (!delta && event.key !== 'Home') return;\n event.preventDefault();\n const next = delta ? clamp({ right: placed.right + delta.right, bottom: placed.bottom + delta.bottom }, size, viewport) : clamp(DEFAULT_POSITION, size, viewport);\n setPosition(next); writePosition(storageKey, storage, next);\n };\n const grip = (className?: string) => <button type=\"button\" data-testid=\"ambient-grip\" aria-label={labels.move} title={labels.moveHint} onPointerDown={onGripDown} onKeyDown={onGripKey}\n className={cn(iconButton, 'cursor-grab touch-none active:cursor-grabbing', className)}><icons.grip className=\"size-4\" aria-hidden=\"true\" /></button>;\n\n const expand = () => { setOutcome(null); store.dismissConversation(conversationId); setWindowState('expanded'); };\n useEffect(() => {\n if (expanded && !globalThis.matchMedia?.('(pointer: coarse)').matches) textarea.current?.focus();\n }, [expanded, textarea]);\n // The title is disabled while the window is open, so focus goes back to it once the bar is showing again.\n const refocusTitle = useRef(false);\n useEffect(() => { if (!expanded && refocusTitle.current) { refocusTitle.current = false; titleButton.current?.focus(); } }, [expanded]);\n const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {\n if (event.key !== 'Escape' || event.defaultPrevented || !expanded || picking) return;\n event.preventDefault();\n // Escape steps back: full screen, then the artifact, then the window.\n if (fullscreen) return setFullscreen(false);\n if (opened) return setOpened(null);\n refocusTitle.current = true;\n setWindowState('bar');\n };\n const onActivate = useCallback((item: AgentNotification) => {\n if (item.conversationId !== undefined && item.conversationId !== conversationId) conversations?.onSelect(item.conversationId);\n expand();\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [conversationId, conversations]);\n\n const connectionKind = state.connection.kind;\n const status = waitingForAnswer ? 'input' : working ? 'working' : outcome;\n const label = waitingForAnswer ? labels.waitingForAnswer : labels.working(seconds);\n const panelOpen = expanded && shownArtifact !== undefined && artifactPanel !== undefined;\n const surface = cn('relative flex flex-col border border-border bg-background text-foreground shadow-lg', phone && expanded ? 'h-dvh rounded-none border-0 pt-[env(safe-area-inset-top)]' : 'rounded-3xl',\n !phone && expanded && (panelOpen ? 'h-full overflow-hidden' : 'max-h-[70dvh]'));\n // With an artifact open the window widens (most of the viewport, at most 72rem) and takes a fixed height so the panel fills it.\n const rootClass = cn('pi-chat pi-ambient fixed z-50', variant === 'contrast' && 'dark scheme-dark', phone ? (expanded ? 'inset-0' : 'inset-x-2 bottom-[max(0.5rem,env(safe-area-inset-bottom))]')\n : cn('transition-[width] motion-reduce:transition-none', minimized ? 'w-auto' : panelOpen ? 'h-[min(46rem,calc(100dvh-1rem))] w-[min(72rem,calc(100vw-1rem))]' : expanded ? 'w-[min(35rem,calc(100vw-1rem))]' : 'w-[min(26rem,calc(100vw-1rem))]'), className);\n const rootStyle = phone ? undefined : { right: placed.right, bottom: placed.bottom };\n\n if (minimized) return <div ref={root} data-boring=\"ambient-chat\" data-state=\"minimized\" data-variant={variant} className={cn(rootClass, phone && 'flex justify-end')} style={rootStyle} onKeyDown={onKeyDown}>\n {notifications !== false && <AgentNotifications store={store} variant={variant} placement=\"inline\" systemNotifications={systemNotifications} onActivate={onActivate} labels={labels}\n {...(autoDismissMs !== undefined ? { autoDismissMs } : {})} {...(maxToasts !== undefined ? { max: maxToasts } : {})} className=\"absolute right-0 bottom-full mb-3 w-[min(26rem,calc(100vw-1rem))]\" />}\n {/* Feedback mode keeps its bar (Stop, Done, ✕) when the window is minimised; otherwise a recording could not be stopped. */}\n {composerFeedback?.active && composerFeedback.bar && <div data-testid=\"ambient-feedback-bar\" className=\"absolute right-0 bottom-full mb-2 w-[min(26rem,calc(100vw-1rem))] rounded-2xl border border-border bg-background pt-2.5 shadow-lg\">{composerFeedback.bar}</div>}\n <div data-testid=\"ambient-pill-box\" onPointerDown={event => beginDrag(event, 4)} onKeyDown={onGripKey} onClickCapture={event => { if (dragged.current) { dragged.current = false; event.stopPropagation(); event.preventDefault(); } }}\n className={cn('relative flex h-11 max-w-[min(20rem,calc(100vw-1rem))] items-center rounded-full border border-border bg-background text-foreground shadow-lg transition-colors hover:bg-popover motion-reduce:transition-none', !phone && 'cursor-grab touch-none select-none active:cursor-grabbing')}>\n {!phone && grip('ml-1.5 size-8 rounded-lg')}\n <button ref={pill} type=\"button\" data-testid=\"ambient-pill\" onClick={() => setWindowState('bar')} aria-label={labels.openAgent(shownTitle, unread)}\n className={cn('relative flex h-full min-w-0 cursor-pointer items-center gap-2.5 rounded-full pr-4 text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/70', phone ? 'pl-3.5' : 'pl-1.5')}>\n {status === 'working' ? <Loader2Icon className=\"size-4 shrink-0 animate-spin text-muted-foreground motion-reduce:animate-none\" aria-hidden=\"true\" />\n : status ? <KindIcon kind={status === 'input' ? 'input' : status} className=\"size-4\" /> : null}\n <span className=\"min-w-0 truncate text-sm\">{shownTitle}</span>\n {unread && <span data-testid=\"ambient-unread\" aria-hidden=\"true\" className=\"absolute top-1.5 right-2 size-2.5 rounded-full border-2 border-background bg-primary\" />}\n </button>\n </div>\n </div>;\n\n const visibleRows = transcript.visible(rows);\n return <div ref={root} data-boring=\"ambient-chat\" data-state={windowState} data-variant={variant} data-phone={phone ? 'true' : undefined} role=\"region\" aria-label={labels.region(shownTitle)}\n className={rootClass} style={rootStyle} onKeyDown={onKeyDown}>\n {notifications !== false && !(phone && expanded) && <AgentNotifications store={store} variant={variant} placement=\"inline\" systemNotifications={systemNotifications} onActivate={onActivate} labels={labels}\n {...(autoDismissMs !== undefined ? { autoDismissMs } : {})} {...(maxToasts !== undefined ? { max: maxToasts } : {})} className=\"absolute right-0 bottom-full mb-3 w-full\" />}\n {picking && conversations && <ConversationHistory conversations={conversations} placement={expanded ? 'below' : 'above'} onClose={() => setPicking(false)} />}\n <div data-testid=\"ambient-surface\" className={surface}>\n <ArtifactWorkspace open={panelOpen} onClose={() => { setOpened(null); setFullscreen(false); }} fullscreen={fullscreen} onFullscreenChange={setFullscreen} storageKey=\"boring.ambient.panel-width\" panelLabel={labels.artifactPanel}\n defaultWidth={600} minPanel={320} minChat={300} sheetBelow={720}\n panel={api => shownArtifact && artifactPanel ? artifactPanel(shownArtifact, { ...api, versions: known, follow: opened?.follow ?? true,\n select: version => setOpened(current => current ? (version === 'latest' ? { descriptor: known[0] ?? current.descriptor, follow: true } : { descriptor: version, follow: false }) : current) }) : null}\n chat={<section className=\"flex min-h-0 min-w-0 flex-1 flex-col\">\n <header data-testid=\"ambient-header\" className={cn('flex h-12 shrink-0 items-center gap-0.5 px-2 max-sm:h-14', !expanded && 'border-b border-border')}>\n <button type=\"button\" data-testid=\"ambient-minimize\" aria-label={labels.minimize} title={labels.minimize} onClick={() => { setPicking(false); setWindowState(expanded ? 'bar' : 'minimized'); }} className={iconButton}><icons.minimize className=\"size-4\" aria-hidden=\"true\" /></button>\n <span data-testid=\"ambient-status\" data-status={status ?? 'idle'} role=\"img\" aria-label={status === 'working' ? labels.statusWorking : status === 'input' ? labels.needsInput : status === 'done' ? labels.finished : status === 'error' ? labels.failedRun : undefined}\n aria-hidden={status ? undefined : 'true'} className={cn('flex shrink-0 items-center justify-center', status ? 'mx-1.5 w-5' : 'w-1.5')}>\n {status === 'working' ? <Loader2Icon className=\"size-4 animate-spin text-muted-foreground motion-reduce:animate-none\" aria-hidden=\"true\" /> : status ? <KindIcon kind={status === 'input' ? 'input' : status} className=\"size-[1.125rem]\" /> : null}\n </span>\n {avatar && <span data-testid=\"ambient-avatar\" className=\"flex size-6 shrink-0 items-center justify-center overflow-hidden rounded-full [&>img]:size-full [&>img]:object-cover [&>svg]:size-4\">{avatar}</span>}\n <button ref={titleButton} type=\"button\" data-testid=\"ambient-title\" onClick={expand} disabled={expanded} aria-label={expanded ? shownTitle : labels.openChat(shownTitle)}\n className=\"min-w-0 max-w-full cursor-pointer truncate rounded-md px-1 py-1 text-left font-(family-name:--chat-display) text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/60 disabled:cursor-default max-sm:py-2.5\">{shownTitle}</button>\n {conversations && <button type=\"button\" data-testid=\"ambient-switch\" aria-label={labels.switchConversation} aria-haspopup=\"dialog\" aria-expanded={picking} title={labels.switchConversation} onClick={() => setPicking(open => !open)}\n className={cn(iconButton, 'size-7 max-sm:size-11')}><icons.switchConversation className=\"size-3.5\" aria-hidden=\"true\" /></button>}\n {expanded ? <span className=\"flex-1\" /> : <button type=\"button\" tabIndex={-1} aria-hidden=\"true\" data-testid=\"ambient-header-fill\" onClick={expand} className=\"h-full min-w-2 flex-1 cursor-pointer\" />}\n {systemNotifications && permission === 'default' && <button type=\"button\" data-testid=\"ambient-enable-notifications\" aria-label={labels.enableNotifications} title={labels.enableNotifications}\n onClick={() => { void requestNotifyPermission().then(setPermission); }} className={iconButton}><icons.notify className=\"size-4\" aria-hidden=\"true\" /></button>}\n <BlockActions actions={headerActions} testId=\"ambient-action\" menuLabel={labels.moreActions} className=\"[&_button]:max-sm:size-11 [&_button]:pointer-coarse:size-11\" />\n {onDock && !phone && <button type=\"button\" data-testid=\"ambient-dock\" aria-label={labels.dock} title={labels.dockHint} onClick={() => { setPicking(false); onDock(); }} className={iconButton}><icons.dock className=\"size-4\" aria-hidden=\"true\" /></button>}\n {!phone && grip()}\n </header>\n\n {expanded && <div className=\"flex min-h-0 flex-1 flex-col\">\n {(connectionKind === 'error' || connectionKind === 'closed') && <div className=\"px-4 pb-2\"><Notice tone={connectionKind === 'error' ? 'error' : 'info'} testid=\"connection-notice\">\n <span className=\"flex-1\">{connectionKind === 'error' ? labels.loadFailed : labels.disconnected}</span>\n {!state.disposed && <Button size=\"sm\" variant=\"outline\" onClick={() => act(controller.connect)}><icons.reconnect className=\"size-3.5\" aria-hidden=\"true\" />{labels.reconnect}</Button>}</Notice></div>}\n {derived.pinned.length > 0 && <aside aria-label={labels.requiredActions} className=\"shrink-0 space-y-2 px-4 pb-2\">{derived.pinned.map(card => <div key={card.key}>{card.content}</div>)}</aside>}\n <div className=\"relative flex min-h-0 flex-1 flex-col\">\n <div ref={stick.scrollRef} data-testid=\"transcript-scroll\" className=\"min-h-0 flex-1 overflow-x-hidden overflow-y-auto overscroll-contain\">\n <div ref={stick.contentRef} role=\"log\" aria-label={labels.messages} aria-live=\"polite\" data-testid=\"transcript\" className=\"flex min-h-28 flex-col gap-5 px-4 pt-1 pb-5\">\n {hidden > 0 && <div className=\"flex justify-center\"><Button size=\"sm\" variant=\"outline\" data-testid=\"show-earlier\" className=\"text-muted-foreground\" onClick={transcript.reveal}>{labels.showEarlier}</Button></div>}\n {visibleRows.map(row => <RowView key={row.key} row={row} context={rowContext} />)}\n {loading && <p role=\"status\" data-testid=\"loading\" className=\"m-0 flex items-center gap-2 text-sm text-muted-foreground\"><Loader2Icon className=\"size-4 animate-spin motion-reduce:animate-none\" aria-hidden=\"true\" />{labels.loading}</p>}\n {empty && !loading && <p data-testid=\"ambient-empty\" className=\"m-0 py-6 text-center text-sm text-muted-foreground\">{labels.empty}</p>}\n </div>\n </div>\n {!stick.isAtBottom && <Button variant=\"outline\" size=\"icon\" data-testid=\"jump-latest\" aria-label={labels.jumpToLatest} title={labels.jumpToLatest} onClick={() => { transcript.reset(); void stick.scrollToBottom(); }}\n className=\"absolute bottom-2 left-1/2 -translate-x-1/2 bg-background/95 shadow-md backdrop-blur\"><icons.jumpToLatest className=\"size-4\" aria-hidden=\"true\" /></Button>}\n </div>\n </div>}\n\n <div className={cn('shrink-0', expanded && 'border-t border-border', phone && 'pb-[env(safe-area-inset-bottom)]')}>\n <div className=\"space-y-2 px-3 empty:hidden [&:has(*)]:pt-2\">\n <ChatNotices state={state} error={error} onReconcile={() => act(controller.reconcile)} onRetry={() => act(controller.retrySameRequest)} />\n {!expanded && (connectionKind === 'error' || connectionKind === 'closed') && <Notice tone=\"info\" testid=\"connection-notice\">\n <span className=\"flex-1\">{connectionKind === 'error' ? labels.loadFailedShort : labels.disconnectedShort}</span>\n {!state.disposed && <Button size=\"sm\" variant=\"outline\" onClick={() => act(controller.connect)}>{labels.reconnect}</Button>}</Notice>}\n </div>\n <MessageQueue items={queued} sending={state.outbox} withdraw={actions?.withdraw} actions={queueActions} />\n <Composer {...composer} layout=\"inline\" placeholder={working || waitingForAnswer ? label : labels.placeholder} barStart={barStart} barNote={barNote} barEnd={tools} />\n </div>\n </section>} />\n </div>\n </div>;\n}\n",
23
+ "content": "'use client';\n\nimport { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';\nimport type { KeyboardEvent, PointerEvent, ReactNode } from 'react';\nimport type { EntryRecord } from '@earendil-works/pi-durable';\nimport type { ImageContent, ToolCall, ToolResultMessage } from '@earendil-works/pi-ai';\nimport type { NativeChatController } from '@hachej/boring-ui-kit/native-chat';\nimport { BellIcon, ChevronDownIcon, ExternalLinkIcon, GripVerticalIcon, Loader2Icon, MinusIcon, PanelLeftIcon, ThumbsDownIcon, ThumbsUpIcon } from 'lucide-react';\nimport { Button } from '../button/button';\nimport { BlockActions } from '../button/actions';\nimport type { BlockAction, BlockIcon } from '../button/actions';\nimport { CopyButton } from '../pi-chat/code-block';\nimport { Composer } from '../pi-chat/composer';\nimport type { ComposerFeedback } from '../pi-chat/composer';\nimport type { AttachmentsConfig, ConversationsConfig, EffortConfig, MentionsConfig, ModelConfig, SlashConfig } from '../pi-chat/config';\nimport { notifyPermission, requestNotifyPermission } from './browser-notify';\nimport type { NotifyPermission } from './browser-notify';\nimport { ConversationHistory } from '../pi-chat/history';\nimport type { CommandMentions } from '../pi-chat/markdown';\nimport { RowView } from '../pi-chat/message';\nimport type { RowContext } from '../pi-chat/message';\nimport { ChatNotices, Notice } from '../pi-chat/notice';\nimport { AgentNotifications, KindIcon, createNotificationStore, defaultNotificationLabels, finishNotice, pendingQuestion, useNotifications } from './notifications';\nimport type { AgentNotification, NotificationLabels, NotificationStore } from './notifications';\nimport { ChatTextProvider, defaultChatIcons, defaultChatLabels, useMergedText } from '../pi-chat/labels';\nimport type { ChatIcons, ChatLabels } from '../pi-chat/labels';\nimport { MessageQueue } from '../pi-chat/queue';\nimport { artifactKey, collectArtifacts } from '../pi-chat/artifact';\nimport type { ArtifactDescriptor, ArtifactsConfig } from '../pi-chat/artifact';\nimport type { ChatCard, Mode } from '../pi-chat/rows';\nimport { useChatSession, useTranscript } from '../pi-chat/session';\nimport type { ChatFeatureProps, ChatSession } from '../pi-chat/session';\nimport { ArtifactWorkspace } from '../pi-workspace/workspace';\nimport type { WorkspacePanelApi } from '../pi-workspace/workspace';\nimport { cn } from '../utils/utils';\n\nexport { AgentNotifications, createNotificationStore, defaultNotificationLabels, summaryOf, useNotifications, watchConversation } from './notifications';\nexport type { AgentNotification, AgentNotificationsProps, NewNotification, NotificationKind, NotificationLabels, NotificationStore } from './notifications';\n\nconst elapsedText = (seconds: number) => seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m ${String(seconds % 60).padStart(2, '0')}s`;\n\n/** The floating window's own words, over the chat's (`defaultChatLabels`) and the toasts' (`defaultNotificationLabels`). */\nexport const defaultAmbientLabels = {\n ...defaultChatLabels,\n ...defaultNotificationLabels,\n /** Shown while the conversation has no title of its own (the active item of `conversations` provides one). */\n title: 'Agent',\n /** The input's placeholder while idle. */\n placeholder: 'Do anything',\n /** The input's placeholder while the agent works. */\n working: (seconds: number) => `Working for ${elapsedText(seconds)}`,\n region: (title: string) => `Agent: ${title}`,\n openAgent: (title: string, unread: boolean) => `Open agent: ${title}${unread ? ' (new activity)' : ''}`,\n openChat: (title: string) => `Open chat: ${title}`,\n minimize: 'Minimise',\n switchConversation: 'Switch conversation',\n statusWorking: 'Working',\n enableNotifications: 'Notify me when the page is in the background',\n dock: 'Dock chat',\n dockHint: 'Dock chat beside the panel',\n move: 'Move the agent window (arrow keys, Home resets)',\n moveHint: 'Drag to move',\n goodResponse: 'Good response',\n badResponse: 'Bad response',\n openFull: 'Open in full chat',\n empty: 'Ask the agent to do something.',\n loadFailedShort: 'Could not load the conversation.',\n disconnectedShort: 'Disconnected.',\n artifactPanel: 'Artifact panel',\n};\nexport type AmbientLabels = ChatLabels & NotificationLabels & typeof defaultAmbientLabels;\n\n/** The floating window's icons, over the chat's. */\nexport const defaultAmbientIcons = {\n ...defaultChatIcons,\n minimize: MinusIcon as BlockIcon,\n dock: PanelLeftIcon as BlockIcon,\n grip: GripVerticalIcon as BlockIcon,\n switchConversation: ChevronDownIcon as BlockIcon,\n notify: BellIcon as BlockIcon,\n openFull: ExternalLinkIcon as BlockIcon,\n goodResponse: ThumbsUpIcon as BlockIcon,\n badResponse: ThumbsDownIcon as BlockIcon,\n};\nexport type AmbientIcons = ChatIcons & typeof defaultAmbientIcons;\nconst AMBIENT_TEXT = { labels: defaultAmbientLabels as AmbientLabels, icons: defaultAmbientIcons as AmbientIcons };\n\n/** `bar` is the compact bar, `expanded` the same window grown into a chat, `minimized` a small pill. */\nexport type AmbientState = 'bar' | 'expanded' | 'minimized';\n\n/** What the artifact panel inside the window gets besides the full-screen and close controls: the versions of the open artifact and how to switch. */\nexport interface AmbientArtifactPanelApi extends WorkspacePanelApi {\n /** Every known version of the open artifact in this conversation, newest first. */\n readonly versions: readonly ArtifactDescriptor[];\n /** True while the panel follows the newest version; false when one older version is pinned. */\n readonly follow: boolean;\n /** `'latest'` follows the newest version; a descriptor from `versions` pins that one. */\n readonly select: (version: ArtifactDescriptor | 'latest') => void;\n}\n\nexport interface AmbientChatProps extends ChatFeatureProps {\n readonly controller: NativeChatController;\n /**\n * Every word the window shows, over `defaultAmbientLabels` (the chat's labels, the toasts' and its own): `title` while the conversation has\n * none of its own, `placeholder` while idle, `working(seconds)` while the agent works.\n */\n readonly labels?: Partial<AmbientLabels> | undefined;\n /** The window's icons (minimise, dock, grip, …) and the chat's, over `defaultAmbientIcons`. */\n readonly icons?: Partial<AmbientIcons> | undefined;\n /** The agent's avatar before the title in the bar. */\n readonly avatar?: ReactNode;\n /** Host actions in the bar, before Dock: `header` ones as buttons, `menu` ones in a \"…\" menu. Test ids `ambient-action-<id>`, the menu `ambient-action-more`. */\n readonly headerActions?: readonly BlockAction[] | undefined;\n /** `contrast` is a dark window on any page: the root carries `.dark`, so it shows the host's own dark tokens; `surface` follows the host's theme. */\n readonly variant?: 'contrast' | 'surface';\n /** Controlled window state; otherwise `defaultState` starts it and the window keeps it. */\n readonly state?: AmbientState;\n readonly defaultState?: AmbientState;\n readonly onStateChange?: (state: AmbientState) => void;\n /** Where the bar was dragged is remembered under this key. */\n readonly storageKey?: string;\n /** `session` (default) remembers the position for this browser tab's session; `local` keeps it in `localStorage` across sessions. */\n readonly storage?: 'session' | 'local';\n /** Host controls in the composer row, between the effort picker and Send (for example a voice button the host guards itself). Absent: nothing is shown. */\n readonly tools?: ReactNode;\n /** Hand the conversation to a full `PiChat` or `ArtifactWorkspace`. Adds an \"Open in full chat\" action to replies. */\n readonly onOpenFull?: (conversationId: string) => void;\n /** Adds thumbs up and down to replies. `value` is `null` when the person takes the rating back. */\n readonly onFeedback?: (reply: { readonly key: string; readonly text: string }, value: 'up' | 'down' | null) => void;\n /** Toasts for finished, failed and waiting tasks. Pass a store to feed it for background conversations too; `false` turns them off. */\n readonly notifications?: NotificationStore | false;\n readonly autoDismissMs?: number;\n readonly maxToasts?: number;\n /** System notifications while the page is hidden. A bell in the header asks for permission when the person chooses to; nothing is asked on load. */\n readonly systemNotifications?: boolean;\n /**\n * The viewer shown inside the window when an artifact card is opened: the window widens into chat on the left and this panel on the right\n * (`ArtifactWorkspace`: draggable divider, full screen, a full-screen sheet on a phone). Build it from the viewer registry (`ViewerFrame`, ...),\n * wrapped in `ViewerWindowProvider` with `api.fullscreen` / `api.onFullscreenChange` so its bar shows the full screen button.\n */\n readonly artifactPanel?: (artifact: ArtifactDescriptor, api: AmbientArtifactPanelApi) => ReactNode;\n /** `window` (default when `artifactPanel` is given) opens artifacts inside the window; `host` calls `artifacts.open` instead, for hosts that show them in their own viewer. */\n readonly artifactTarget?: 'window' | 'host';\n /** The title's ⌄ opens this list to switch conversations or tasks. */\n readonly conversations?: ConversationsConfig;\n /** Adds a \"Dock chat\" action to the header, for a host that floats this window out of a docked chat (`ArtifactWorkspace` with `floatBelow`). It shares the controller with the docked `PiChat`, so nothing is lost. */\n readonly onDock?: () => void;\n /** The optional annotate toggle and notes chip in the composer (the registry `feedback` item's `useComposerFeedback`). Omit it and nothing changes. */\n readonly feedback?: ComposerFeedback;\n readonly className?: string;\n}\n\ninterface Position { readonly right: number; readonly bottom: number }\nconst MARGIN = 8, DEFAULT_POSITION: Position = { right: 16, bottom: 16 };\n\nfunction storageFor(persist: 'session' | 'local'): Storage | undefined { try { return persist === 'local' ? globalThis.localStorage : globalThis.sessionStorage; } catch { return undefined; } }\nfunction readPosition(key: string, persist: 'session' | 'local'): Position | undefined {\n try {\n const value: unknown = JSON.parse(storageFor(persist)?.getItem(key) ?? 'null');\n const { right, bottom } = (value ?? {}) as { right?: unknown; bottom?: unknown };\n return typeof right === 'number' && typeof bottom === 'number' && Number.isFinite(right) && Number.isFinite(bottom) ? { right, bottom } : undefined;\n } catch { return undefined; }\n}\nfunction writePosition(key: string, persist: 'session' | 'local', position: Position) {\n try { storageFor(persist)?.setItem(key, JSON.stringify(position)); } catch { /* the position is a convenience */ }\n}\nconst clamp = (position: Position, size: { w: number; h: number }, viewport: { w: number; h: number }): Position => ({\n right: Math.round(Math.min(Math.max(MARGIN, position.right), Math.max(MARGIN, viewport.w - size.w - MARGIN))),\n bottom: Math.round(Math.min(Math.max(MARGIN, position.bottom), Math.max(MARGIN, viewport.h - size.h - MARGIN))),\n});\n\n// The layout viewport that fixed positioning uses (without scrollbars), not the window's outer size.\nconst viewportSize = () => { const root = globalThis.document?.documentElement; return { w: root?.clientWidth || globalThis.innerWidth || 1024, h: root?.clientHeight || globalThis.innerHeight || 768 }; };\nfunction usePhone(): boolean {\n const query = '(max-width: 639px)';\n const [phone, setPhone] = useState(() => globalThis.matchMedia?.(query).matches === true);\n useEffect(() => {\n const list = globalThis.matchMedia?.(query);\n if (!list) return;\n const update = () => setPhone(list.matches);\n update();\n list.addEventListener('change', update);\n return () => list.removeEventListener('change', update);\n }, []);\n return phone;\n}\n\n/** Whole seconds since `active` became true; 0 while inactive. */\nfunction useElapsed(active: boolean): number {\n const [seconds, setSeconds] = useState(0);\n useEffect(() => {\n if (!active) { setSeconds(0); return; }\n const started = Date.now();\n setSeconds(0);\n const timer = setInterval(() => setSeconds(Math.floor((Date.now() - started) / 1000)), 1000);\n return () => clearInterval(timer);\n }, [active]);\n return seconds;\n}\n\n/** Pushes a toast when a run finishes or fails and when a question starts waiting; takes it back when the question is answered or a new run starts. */\nfunction useRunFeed({ enabled, store, conversationId, title, session, suppress, onOutcome, waiting }: {\n readonly enabled: boolean; readonly store: NotificationStore; readonly conversationId: string; readonly title: string; readonly session: ChatSession; readonly waiting: string;\n readonly suppress: () => boolean; readonly onOutcome: (outcome: 'done' | 'error' | null) => void;\n}) {\n const { working, waitingForAnswer, rows, connected } = session;\n const before = useRef({ working, waiting: waitingForAnswer });\n const latest = useRef({ rows, title, suppress, onOutcome, connected, waiting });\n latest.current = { rows, title, suppress, onOutcome, connected, waiting };\n useEffect(() => {\n const was = before.current;\n before.current = { working, waiting: waitingForAnswer };\n const now = latest.current;\n if (working && !was.working) { now.onOutcome(null); store.dismiss(`${conversationId}:done`); }\n if (!enabled) return;\n if (waitingForAnswer && !was.waiting && !now.suppress()) store.push({ id: `${conversationId}:input`, kind: 'input', title: now.title, summary: pendingQuestion(now.rows) ?? now.waiting, conversationId });\n if (!waitingForAnswer && was.waiting) store.dismiss(`${conversationId}:input`);\n if (was.working && !working && now.connected) {\n const finished = finishNotice(now.rows);\n if (!finished) return;\n now.onOutcome(finished.kind);\n if (!now.suppress()) store.push({ id: `${conversationId}:done`, kind: finished.kind, title: now.title, summary: finished.summary, conversationId });\n }\n }, [working, waitingForAnswer, enabled, store, conversationId]);\n}\n\n/** A compact floating agent bar over an existing page; it grows into a chat window in place. Borrows a concrete native controller like `PiChat`. */\nexport function AmbientChat(props: AmbientChatProps) {\n const active = useRef(props.controller); active.current = props.controller;\n const [mount, setMount] = useState({ controller: props.controller, sequence: 0 });\n if (mount.controller !== props.controller) setMount({ controller: props.controller, sequence: mount.sequence + 1 });\n // The window state and the toasts belong to the bar, not to one conversation: they survive switching conversations.\n const [local, setLocal] = useState<AmbientState>(props.defaultState ?? 'bar');\n const internalStore = useMemo(createNotificationStore, []);\n const text = useMergedText<AmbientLabels, AmbientIcons>(props.labels, props.icons, AMBIENT_TEXT);\n return <ChatTextProvider value={text}><AmbientSession key={mount.sequence} {...props} labels={text.labels} icons={text.icons} activeController={active} windowState={props.state ?? local} store={props.notifications || internalStore}\n onWindowState={next => { if (props.state === undefined) setLocal(next); props.onStateChange?.(next); }} /></ChatTextProvider>;\n}\n\nconst iconButton = 'inline-flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-lg text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/60 max-sm:size-11 pointer-coarse:size-11 motion-reduce:transition-none';\n\ntype SessionProps = Omit<AmbientChatProps, 'labels' | 'icons'> & { readonly labels: AmbientLabels; readonly icons: AmbientIcons; readonly activeController: { readonly current: NativeChatController }; readonly windowState: AmbientState; readonly store: NotificationStore; readonly onWindowState: (state: AmbientState) => void };\n\nfunction AmbientSession({ controller, labels, icons, avatar, headerActions, messageActions, variant = 'contrast', mode = 'expert', actions,\n windowState, onWindowState, store, storageKey = 'boring.ambient.position', storage = 'session', tools, onOpenFull, onFeedback, notifications, autoDismissMs, maxToasts, systemNotifications = false,\n renderEntry, renderTool, groupTool, commandMentions, onOpenImage, onCopy, onComposerKeyDown, fileAccept = 'image/*', slash, mentions, attachments, model, effort, replyFooter, artifacts: hostArtifacts, artifactPanel, artifactTarget, conversations, onDock, feedback: composerFeedback, className,\n activeController }: SessionProps) {\n const { title } = labels;\n const stateRef = useRef(windowState); stateRef.current = windowState;\n // The artifact open inside the window, and whether it follows the newest version. It belongs to the expanded window: leaving that state closes it.\n const [opened, setOpened] = useState<{ readonly descriptor: ArtifactDescriptor; readonly follow: boolean } | null>(null);\n const [fullscreen, setFullscreen] = useState(false);\n const setWindowState = (next: AmbientState) => {\n if (next !== 'expanded') { setOpened(null); setFullscreen(false); }\n if (next !== stateRef.current) onWindowState(next);\n };\n const expanded = windowState === 'expanded', minimized = windowState === 'minimized';\n const phone = usePhone();\n const root = useRef<HTMLDivElement>(null), titleButton = useRef<HTMLButtonElement>(null), pill = useRef<HTMLButtonElement>(null);\n const [picking, setPicking] = useState(false);\n const [feedback, setFeedback] = useState<Readonly<Record<string, 'up' | 'down'>>>({});\n const [outcome, setOutcome] = useState<'done' | 'error' | null>(null);\n const [permission, setPermission] = useState<NotifyPermission>('unsupported');\n useEffect(() => { setPermission(notifyPermission()); }, []);\n useEffect(() => { if (!outcome) return; const timer = setTimeout(() => setOutcome(null), 8000); return () => clearTimeout(timer); }, [outcome]);\n\n // Replies get copy, optional feedback and the time; the host may hand the conversation to its full chat.\n const conversationRef = useRef('');\n const replyActions = useMemo<NonNullable<RowContext['replyActions']>>(() => (reply, host) => <div data-testid=\"reply-actions\" className=\"mt-2 -ml-2 flex items-center gap-0.5 text-muted-foreground\">\n <CopyButton text={reply.text} label={labels.copyResponse} iconOnly {...(onCopy ? { onCopy } : {})} className=\"size-8 p-0 text-muted-foreground max-sm:size-11\" />\n {onFeedback && <>\n {(['up', 'down'] as const).map(value => <button key={value} type=\"button\" data-testid={`feedback-${value}`} aria-pressed={feedback[reply.key] === value} aria-label={value === 'up' ? labels.goodResponse : labels.badResponse}\n title={value === 'up' ? labels.goodResponse : labels.badResponse}\n onClick={() => { const next = feedback[reply.key] === value ? null : value; setFeedback(current => { const { [reply.key]: _removed, ...rest } = current; return next ? { ...rest, [reply.key]: next } : rest; }); onFeedback({ key: reply.key, text: reply.text }, next); }}\n className={cn(iconButton, 'aria-pressed:text-foreground')}>{value === 'up' ? <icons.goodResponse className=\"size-3.5\" aria-hidden=\"true\" /> : <icons.badResponse className=\"size-3.5\" aria-hidden=\"true\" />}</button>)}\n </>}\n {onOpenFull && <button type=\"button\" data-testid=\"open-full\" aria-label={labels.openFull} title={labels.openFull} onClick={() => onOpenFull(conversationRef.current)} className={iconButton}><icons.openFull className=\"size-3.5\" aria-hidden=\"true\" /></button>}\n {host}\n {reply.timestamp !== undefined && <time dateTime={new Date(reply.timestamp).toISOString()} data-testid=\"reply-time\" className=\"ml-1.5 text-xs tabular-nums\">{new Date(reply.timestamp).toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' })}</time>}\n </div>, [onCopy, onFeedback, onOpenFull, feedback, labels, icons]);\n const scrollToBottom = useRef<() => void>(() => {});\n const rowExtras = useMemo(() => ({ replyActions }), [replyActions]);\n const inWindow = Boolean(artifactPanel) && artifactTarget !== 'host';\n const detect = hostArtifacts?.detect;\n const versionsRef = useRef<readonly ArtifactDescriptor[]>([]);\n const artifacts = useMemo<ArtifactsConfig | undefined>(() => !inWindow ? hostArtifacts : {\n open: descriptor => { const newest = versionsRef.current.filter(version => artifactKey(version) === artifactKey(descriptor)).reduce((top, version) => Math.max(top, version.ordinal ?? 0), 0); setOpened({ descriptor, follow: (descriptor.ordinal ?? 0) >= newest }); },\n isOpen: descriptor => opened !== null && artifactKey(opened.descriptor) === artifactKey(descriptor) && (opened.follow ? (descriptor.ordinal ?? 0) >= (versionsRef.current.find(version => artifactKey(version) === artifactKey(descriptor))?.ordinal ?? 0) : descriptor.revision === opened.descriptor.revision),\n ...(detect ? { detect } : {}),\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [inWindow, hostArtifacts, opened, detect]);\n const session = useChatSession({ controller, activeController, mode, actions, renderEntry, renderTool, groupTool, commandMentions, onOpenImage, onCopy, onComposerKeyDown, fileAccept,\n slash, mentions, attachments, model, effort, artifacts, messageActions, replyFooter, afterSend: () => scrollToBottom.current(), rowExtras, feedback: composerFeedback });\n const { state, derived, rows, queued, queueActions, working, waitingForAnswer, error, act, textarea, rowContext, composer, empty, loading } = session;\n const transcript = useTranscript(rows);\n const versions = useMemo(() => inWindow ? collectArtifacts(state.view, detect).sort((a, b) => (b.ordinal ?? 0) - (a.ordinal ?? 0)) : [], [inWindow, state.view, detect]);\n versionsRef.current = versions;\n const known = opened ? versions.filter(version => artifactKey(version) === artifactKey(opened.descriptor)) : [];\n const shownArtifact = opened ? (opened.follow ? known[0] ?? opened.descriptor : opened.descriptor) : undefined;\n const { stick, hidden } = transcript;\n scrollToBottom.current = () => { void stick.scrollToBottom('instant'); };\n const { barStart, barNote } = session.pickers();\n\n const conversationId = String(conversations?.activeId ?? state.conversationId);\n conversationRef.current = conversationId;\n const activeItem = conversations?.items.find(item => item.id === conversations.activeId);\n const shownTitle = activeItem?.title?.trim() || title;\n const toasts = useNotifications(store);\n useRunFeed({ enabled: notifications !== false, store, conversationId, title: shownTitle, session, onOutcome: setOutcome, waiting: labels.waitingForAnswer,\n suppress: () => stateRef.current === 'expanded' && globalThis.document?.visibilityState !== 'hidden' });\n const unread = !expanded && toasts.length > 0;\n const seconds = useElapsed(working);\n\n // Position: offsets from the bottom right corner, remembered for the session, clamped to the viewport at render so a smaller window restores them.\n const [position, setPosition] = useState<Position>(() => readPosition(storageKey, storage) ?? DEFAULT_POSITION);\n const [viewport, setViewport] = useState(viewportSize);\n const [size, setSize] = useState({ w: 0, h: 0 });\n useEffect(() => {\n const onResize = () => setViewport(viewportSize());\n globalThis.addEventListener('resize', onResize);\n return () => globalThis.removeEventListener('resize', onResize);\n }, []);\n useEffect(() => {\n const element = root.current;\n if (!element || typeof ResizeObserver === 'undefined') return;\n const observer = new ResizeObserver(() => setSize({ w: element.offsetWidth, h: element.offsetHeight }));\n observer.observe(element);\n setSize({ w: element.offsetWidth, h: element.offsetHeight });\n return () => observer.disconnect();\n }, [minimized, expanded]);\n // The size and the viewport also follow every render, so the clamp below never works from a stale measurement (the title or status icon can change the pill's width; a scrollbar can appear without a resize event).\n useLayoutEffect(() => {\n const element = root.current;\n const fresh = viewportSize();\n setViewport(current => current.w === fresh.w && current.h === fresh.h ? current : fresh);\n if (element) setSize(current => current.w === element.offsetWidth && current.h === element.offsetHeight ? current : { w: element.offsetWidth, h: element.offsetHeight });\n });\n const placed = clamp(position, size, viewport);\n // Moves and the release are followed on the window, so a drag keeps working when the pointer leaves the handle (and when pointer capture is unavailable).\n // `threshold` is how far the pointer must travel before it is a drag: a click on the pill (threshold > 0) still restores it.\n const dragged = useRef(false);\n const beginDrag = (event: PointerEvent<HTMLElement>, threshold: number) => {\n if (phone || event.button !== 0) return;\n dragged.current = false;\n const origin = { pointer: event.pointerId, x: event.clientX, y: event.clientY, start: placed };\n let last = origin.start, moving = threshold === 0;\n const move = (next: globalThis.PointerEvent) => {\n if (next.pointerId !== origin.pointer) return;\n if (!moving) { if (Math.hypot(next.clientX - origin.x, next.clientY - origin.y) < threshold) return; moving = true; dragged.current = true; }\n last = clamp({ right: origin.start.right - (next.clientX - origin.x), bottom: origin.start.bottom - (next.clientY - origin.y) }, size, viewport);\n setPosition(last);\n };\n const end = (next: globalThis.PointerEvent) => {\n if (next.pointerId !== origin.pointer) return;\n globalThis.removeEventListener('pointermove', move); globalThis.removeEventListener('pointerup', end); globalThis.removeEventListener('pointercancel', end);\n if (moving) writePosition(storageKey, storage, last);\n };\n globalThis.addEventListener('pointermove', move); globalThis.addEventListener('pointerup', end); globalThis.addEventListener('pointercancel', end);\n };\n const onGripDown = (event: PointerEvent<HTMLButtonElement>) => { event.preventDefault(); beginDrag(event, 0); };\n // Arrow keys move the window (Shift for big steps); Home puts it back in the default corner.\n const onGripKey = (event: KeyboardEvent<HTMLElement>) => {\n if (phone) return;\n const step = event.shiftKey ? 64 : 16;\n const delta = event.key === 'ArrowLeft' ? { right: step, bottom: 0 } : event.key === 'ArrowRight' ? { right: -step, bottom: 0 } : event.key === 'ArrowUp' ? { right: 0, bottom: step } : event.key === 'ArrowDown' ? { right: 0, bottom: -step } : undefined;\n if (!delta && event.key !== 'Home') return;\n event.preventDefault();\n const next = delta ? clamp({ right: placed.right + delta.right, bottom: placed.bottom + delta.bottom }, size, viewport) : clamp(DEFAULT_POSITION, size, viewport);\n setPosition(next); writePosition(storageKey, storage, next);\n };\n const grip = (className?: string) => <button type=\"button\" data-testid=\"ambient-grip\" aria-label={labels.move} title={labels.moveHint} onPointerDown={onGripDown} onKeyDown={onGripKey}\n className={cn(iconButton, 'cursor-grab touch-none active:cursor-grabbing', className)}><icons.grip className=\"size-4\" aria-hidden=\"true\" /></button>;\n\n const expand = () => { setOutcome(null); store.dismissConversation(conversationId); setWindowState('expanded'); };\n useEffect(() => {\n if (expanded && !globalThis.matchMedia?.('(pointer: coarse)').matches) textarea.current?.focus();\n }, [expanded, textarea]);\n // The title is disabled while the window is open, so focus goes back to it once the bar is showing again.\n const refocusTitle = useRef(false);\n useEffect(() => { if (!expanded && refocusTitle.current) { refocusTitle.current = false; titleButton.current?.focus(); } }, [expanded]);\n const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {\n if (event.key !== 'Escape' || event.defaultPrevented || !expanded || picking) return;\n event.preventDefault();\n // Escape steps back: full screen, then the artifact, then the window.\n if (fullscreen) return setFullscreen(false);\n if (opened) return setOpened(null);\n refocusTitle.current = true;\n setWindowState('bar');\n };\n const onActivate = useCallback((item: AgentNotification) => {\n if (item.conversationId !== undefined && item.conversationId !== conversationId) conversations?.onSelect(item.conversationId);\n expand();\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [conversationId, conversations]);\n\n const connectionKind = state.connection.kind;\n const status = waitingForAnswer ? 'input' : working ? 'working' : outcome;\n const label = waitingForAnswer ? labels.waitingForAnswer : labels.working(seconds);\n const panelOpen = expanded && shownArtifact !== undefined && artifactPanel !== undefined;\n const surface = cn('relative flex flex-col border border-border bg-background text-foreground shadow-lg', phone && expanded ? 'h-dvh rounded-none border-0 pt-[env(safe-area-inset-top)]' : 'rounded-3xl',\n !phone && expanded && (panelOpen ? 'h-full overflow-hidden' : 'max-h-[70dvh]'));\n // With an artifact open the window widens (most of the viewport, at most 72rem) and takes a fixed height so the panel fills it.\n const rootClass = cn('pi-chat pi-ambient fixed z-50', variant === 'contrast' && 'dark scheme-dark', phone ? (expanded ? 'inset-0' : 'inset-x-2 bottom-[max(0.5rem,env(safe-area-inset-bottom))]')\n : cn('transition-[width] motion-reduce:transition-none', minimized ? 'w-auto' : panelOpen ? 'h-[min(46rem,calc(100dvh-1rem))] w-[min(72rem,calc(100vw-1rem))]' : expanded ? 'w-[min(35rem,calc(100vw-1rem))]' : 'w-[min(26rem,calc(100vw-1rem))]'), className);\n const rootStyle = phone ? undefined : { right: placed.right, bottom: placed.bottom };\n\n if (minimized) return <div ref={root} data-boring=\"ambient-chat\" data-state=\"minimized\" data-variant={variant} className={cn(rootClass, phone && 'flex justify-end')} style={rootStyle} onKeyDown={onKeyDown}>\n {notifications !== false && <AgentNotifications store={store} variant={variant} placement=\"inline\" systemNotifications={systemNotifications} onActivate={onActivate} labels={labels}\n {...(autoDismissMs !== undefined ? { autoDismissMs } : {})} {...(maxToasts !== undefined ? { max: maxToasts } : {})} className=\"absolute right-0 bottom-full mb-3 w-[min(26rem,calc(100vw-1rem))]\" />}\n <div data-testid=\"ambient-pill-box\" onPointerDown={event => beginDrag(event, 4)} onKeyDown={onGripKey} onClickCapture={event => { if (dragged.current) { dragged.current = false; event.stopPropagation(); event.preventDefault(); } }}\n className={cn('relative flex h-11 max-w-[min(20rem,calc(100vw-1rem))] items-center rounded-full border border-border bg-background text-foreground shadow-lg transition-colors hover:bg-popover motion-reduce:transition-none', !phone && 'cursor-grab touch-none select-none active:cursor-grabbing')}>\n {!phone && grip('ml-1.5 size-8 rounded-lg')}\n <button ref={pill} type=\"button\" data-testid=\"ambient-pill\" onClick={() => setWindowState('bar')} aria-label={labels.openAgent(shownTitle, unread)}\n className={cn('relative flex h-full min-w-0 cursor-pointer items-center gap-2.5 rounded-full pr-4 text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/70', phone ? 'pl-3.5' : 'pl-1.5')}>\n {status === 'working' ? <Loader2Icon className=\"size-4 shrink-0 animate-spin text-muted-foreground motion-reduce:animate-none\" aria-hidden=\"true\" />\n : status ? <KindIcon kind={status === 'input' ? 'input' : status} className=\"size-4\" /> : null}\n <span className=\"min-w-0 truncate text-sm\">{shownTitle}</span>\n {unread && <span data-testid=\"ambient-unread\" aria-hidden=\"true\" className=\"absolute top-1.5 right-2 size-2.5 rounded-full border-2 border-background bg-primary\" />}\n </button>\n </div>\n </div>;\n\n const visibleRows = transcript.visible(rows);\n return <div ref={root} data-boring=\"ambient-chat\" data-state={windowState} data-variant={variant} data-phone={phone ? 'true' : undefined} role=\"region\" aria-label={labels.region(shownTitle)}\n className={rootClass} style={rootStyle} onKeyDown={onKeyDown}>\n {notifications !== false && !(phone && expanded) && <AgentNotifications store={store} variant={variant} placement=\"inline\" systemNotifications={systemNotifications} onActivate={onActivate} labels={labels}\n {...(autoDismissMs !== undefined ? { autoDismissMs } : {})} {...(maxToasts !== undefined ? { max: maxToasts } : {})} className=\"absolute right-0 bottom-full mb-3 w-full\" />}\n {picking && conversations && <ConversationHistory conversations={conversations} placement={expanded ? 'below' : 'above'} onClose={() => setPicking(false)} />}\n <div data-testid=\"ambient-surface\" className={surface}>\n <ArtifactWorkspace open={panelOpen} onClose={() => { setOpened(null); setFullscreen(false); }} fullscreen={fullscreen} onFullscreenChange={setFullscreen} storageKey=\"boring.ambient.panel-width\" panelLabel={labels.artifactPanel}\n defaultWidth={600} minPanel={320} minChat={300} sheetBelow={720}\n panel={api => shownArtifact && artifactPanel ? artifactPanel(shownArtifact, { ...api, versions: known, follow: opened?.follow ?? true,\n select: version => setOpened(current => current ? (version === 'latest' ? { descriptor: known[0] ?? current.descriptor, follow: true } : { descriptor: version, follow: false }) : current) }) : null}\n chat={<section className=\"flex min-h-0 min-w-0 flex-1 flex-col\">\n <header data-testid=\"ambient-header\" className={cn('flex h-12 shrink-0 items-center gap-0.5 px-2 max-sm:h-14', !expanded && 'border-b border-border')}>\n <button type=\"button\" data-testid=\"ambient-minimize\" aria-label={labels.minimize} title={labels.minimize} onClick={() => { setPicking(false); setWindowState(expanded ? 'bar' : 'minimized'); }} className={iconButton}><icons.minimize className=\"size-4\" aria-hidden=\"true\" /></button>\n <span data-testid=\"ambient-status\" data-status={status ?? 'idle'} role=\"img\" aria-label={status === 'working' ? labels.statusWorking : status === 'input' ? labels.needsInput : status === 'done' ? labels.finished : status === 'error' ? labels.failedRun : undefined}\n aria-hidden={status ? undefined : 'true'} className={cn('flex shrink-0 items-center justify-center', status ? 'mx-1.5 w-5' : 'w-1.5')}>\n {status === 'working' ? <Loader2Icon className=\"size-4 animate-spin text-muted-foreground motion-reduce:animate-none\" aria-hidden=\"true\" /> : status ? <KindIcon kind={status === 'input' ? 'input' : status} className=\"size-[1.125rem]\" /> : null}\n </span>\n {avatar && <span data-testid=\"ambient-avatar\" className=\"flex size-6 shrink-0 items-center justify-center overflow-hidden rounded-full [&>img]:size-full [&>img]:object-cover [&>svg]:size-4\">{avatar}</span>}\n <button ref={titleButton} type=\"button\" data-testid=\"ambient-title\" onClick={expand} disabled={expanded} aria-label={expanded ? shownTitle : labels.openChat(shownTitle)}\n className=\"min-w-0 max-w-full cursor-pointer truncate rounded-md px-1 py-1 text-left font-(family-name:--chat-display) text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/60 disabled:cursor-default max-sm:py-2.5\">{shownTitle}</button>\n {conversations && <button type=\"button\" data-testid=\"ambient-switch\" aria-label={labels.switchConversation} aria-haspopup=\"dialog\" aria-expanded={picking} title={labels.switchConversation} onClick={() => setPicking(open => !open)}\n className={cn(iconButton, 'size-7 max-sm:size-11')}><icons.switchConversation className=\"size-3.5\" aria-hidden=\"true\" /></button>}\n {expanded ? <span className=\"flex-1\" /> : <button type=\"button\" tabIndex={-1} aria-hidden=\"true\" data-testid=\"ambient-header-fill\" onClick={expand} className=\"h-full min-w-2 flex-1 cursor-pointer\" />}\n {systemNotifications && permission === 'default' && <button type=\"button\" data-testid=\"ambient-enable-notifications\" aria-label={labels.enableNotifications} title={labels.enableNotifications}\n onClick={() => { void requestNotifyPermission().then(setPermission); }} className={iconButton}><icons.notify className=\"size-4\" aria-hidden=\"true\" /></button>}\n <BlockActions actions={headerActions} testId=\"ambient-action\" menuLabel={labels.moreActions} className=\"[&_button]:max-sm:size-11 [&_button]:pointer-coarse:size-11\" />\n {onDock && !phone && <button type=\"button\" data-testid=\"ambient-dock\" aria-label={labels.dock} title={labels.dockHint} onClick={() => { setPicking(false); onDock(); }} className={iconButton}><icons.dock className=\"size-4\" aria-hidden=\"true\" /></button>}\n {!phone && grip()}\n </header>\n\n {expanded && <div className=\"flex min-h-0 flex-1 flex-col\">\n {(connectionKind === 'error' || connectionKind === 'closed') && <div className=\"px-4 pb-2\"><Notice tone={connectionKind === 'error' ? 'error' : 'info'} testid=\"connection-notice\">\n <span className=\"flex-1\">{connectionKind === 'error' ? labels.loadFailed : labels.disconnected}</span>\n {!state.disposed && <Button size=\"sm\" variant=\"outline\" onClick={() => act(controller.connect)}><icons.reconnect className=\"size-3.5\" aria-hidden=\"true\" />{labels.reconnect}</Button>}</Notice></div>}\n {derived.pinned.length > 0 && <aside aria-label={labels.requiredActions} className=\"shrink-0 space-y-2 px-4 pb-2\">{derived.pinned.map(card => <div key={card.key}>{card.content}</div>)}</aside>}\n <div className=\"relative flex min-h-0 flex-1 flex-col\">\n <div ref={stick.scrollRef} data-testid=\"transcript-scroll\" className=\"min-h-0 flex-1 overflow-x-hidden overflow-y-auto overscroll-contain\">\n <div ref={stick.contentRef} role=\"log\" aria-label={labels.messages} aria-live=\"polite\" data-testid=\"transcript\" className=\"flex min-h-28 flex-col gap-5 px-4 pt-1 pb-5\">\n {hidden > 0 && <div className=\"flex justify-center\"><Button size=\"sm\" variant=\"outline\" data-testid=\"show-earlier\" className=\"text-muted-foreground\" onClick={transcript.reveal}>{labels.showEarlier}</Button></div>}\n {visibleRows.map(row => <RowView key={row.key} row={row} context={rowContext} />)}\n {loading && <p role=\"status\" data-testid=\"loading\" className=\"m-0 flex items-center gap-2 text-sm text-muted-foreground\"><Loader2Icon className=\"size-4 animate-spin motion-reduce:animate-none\" aria-hidden=\"true\" />{labels.loading}</p>}\n {empty && !loading && <p data-testid=\"ambient-empty\" className=\"m-0 py-6 text-center text-sm text-muted-foreground\">{labels.empty}</p>}\n </div>\n </div>\n {!stick.isAtBottom && <Button variant=\"outline\" size=\"icon\" data-testid=\"jump-latest\" aria-label={labels.jumpToLatest} title={labels.jumpToLatest} onClick={() => { transcript.reset(); void stick.scrollToBottom(); }}\n className=\"absolute bottom-2 left-1/2 -translate-x-1/2 bg-background/95 shadow-md backdrop-blur\"><icons.jumpToLatest className=\"size-4\" aria-hidden=\"true\" /></Button>}\n </div>\n </div>}\n\n <div className={cn('shrink-0', expanded && 'border-t border-border', phone && 'pb-[env(safe-area-inset-bottom)]')}>\n <div className=\"space-y-2 px-3 empty:hidden [&:has(*)]:pt-2\">\n <ChatNotices state={state} error={error} onReconcile={() => act(controller.reconcile)} onRetry={() => act(controller.retrySameRequest)} />\n {!expanded && (connectionKind === 'error' || connectionKind === 'closed') && <Notice tone=\"info\" testid=\"connection-notice\">\n <span className=\"flex-1\">{connectionKind === 'error' ? labels.loadFailedShort : labels.disconnectedShort}</span>\n {!state.disposed && <Button size=\"sm\" variant=\"outline\" onClick={() => act(controller.connect)}>{labels.reconnect}</Button>}</Notice>}\n </div>\n <MessageQueue items={queued} sending={state.outbox} withdraw={actions?.withdraw} actions={queueActions} />\n <Composer {...composer} layout=\"inline\" placeholder={working || waitingForAnswer ? label : labels.placeholder} barStart={barStart} barNote={barNote} barEnd={tools} />\n </div>\n </section>} />\n </div>\n </div>;\n}\n",
24
24
  "type": "registry:component",
25
25
  "target": "components/pi-ambient/ambient.tsx"
26
26
  },