@hachej/boring-ui-kit 0.4.45 → 0.4.47

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/README.md +0 -4
  2. package/dist/chat-react-session.d.ts +1 -0
  3. package/dist/chat-react-session.js +12 -3
  4. package/dist/chat-view.d.ts +2 -1
  5. package/dist/native-chat.d.ts +39 -2
  6. package/dist/native-chat.js +135 -9
  7. package/dist/remote-chat.d.ts +6 -0
  8. package/dist/remote-chat.js +22 -0
  9. package/dist/shared-chat/index.d.ts +1 -1
  10. package/package.json +2 -6
  11. package/r/agent-target.json +4 -4
  12. package/r/app-shell.json +20 -15
  13. package/r/board-viewer.json +6 -6
  14. package/r/button.json +1 -1
  15. package/r/dock.json +1 -1
  16. package/r/documents.json +12 -12
  17. package/r/feedback.json +24 -12
  18. package/r/file-tree.json +4 -4
  19. package/r/highlights.json +3 -3
  20. package/r/html-viewer.json +3 -3
  21. package/r/looks-viewer.json +2 -2
  22. package/r/maquette-viewer.json +1 -1
  23. package/r/markdown-editor.json +3 -3
  24. package/r/mockup-viewer.json +1 -1
  25. package/r/pi-ambient.json +9 -9
  26. package/r/pi-app-kinds.json +10 -10
  27. package/r/pi-app.json +12 -12
  28. package/r/pi-artifacts.json +2 -2
  29. package/r/pi-chat-approval.json +2 -2
  30. package/r/pi-chat-attachments.json +1 -1
  31. package/r/pi-chat-markdown.json +2 -2
  32. package/r/pi-chat-mentions.json +1 -1
  33. package/r/pi-chat-pickers.json +1 -1
  34. package/r/pi-chat-plugins.json +11 -11
  35. package/r/pi-chat-question.json +1 -1
  36. package/r/pi-chat-slash.json +2 -2
  37. package/r/pi-chat-subagent.json +1 -1
  38. package/r/pi-chat-voice.json +9 -3
  39. package/r/pi-chat.json +11 -24
  40. package/r/pi-workspace.json +3 -3
  41. package/r/placed-agent.json +4 -4
  42. package/r/provider-setup.json +1 -1
  43. package/r/registry.json +1222 -1388
  44. package/r/shared-chat.json +6 -6
  45. package/r/spaces.json +44 -0
  46. package/r/task-list-viewer.json +2 -2
  47. package/r/version-history.json +2 -2
  48. package/r/viewer-code.json +2 -2
  49. package/r/viewer-frame.json +4 -4
  50. package/r/viewer-html.json +4 -4
  51. package/r/viewer-image.json +2 -2
  52. package/r/viewer-markdown.json +5 -5
  53. package/r/viewer-pdf.json +3 -3
  54. package/r/viewers.json +6 -6
  55. package/r/windows.json +1 -1
  56. package/dist/task-board.d.ts +0 -96
  57. package/dist/task-board.js +0 -48
  58. package/r/feedback-preview.json +0 -20
  59. package/r/pm-panel.json +0 -122
  60. package/r/requests-board.json +0 -33
  61. package/r/task-board.json +0 -41
  62. package/r/task-mentions.json +0 -25
package/r/documents.json CHANGED
@@ -4,22 +4,22 @@
4
4
  "title": "Documents: the Library, a file and its revisions",
5
5
  "description": "The workspace's Library as the list, the chosen file in its viewer and its retained revisions beside it (an older one opens read-only). DocumentSheet opens one document over the app for a host's « Voir la fiche », with an optional « Toutes les fiches ». Every read goes through one revisionProvider binding.",
6
6
  "dependencies": [
7
- "@hachej/boring-ui-kit@^0.4.45",
8
- "@hachej/boring-files@^0.4.45",
7
+ "@hachej/boring-ui-kit@^0.4.47",
8
+ "@hachej/boring-files@^0.4.47",
9
9
  "react@^19.0.0",
10
10
  "lucide-react@^1.51.0"
11
11
  ],
12
12
  "registryDependencies": [
13
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.45/r/utils.json",
14
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.45/r/button.json",
15
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.45/r/pi-artifacts.json",
16
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.45/r/pi-app.json",
17
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.45/r/pi-app-kinds.json",
18
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.45/r/app-shell.json",
19
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.45/r/file-tree.json",
20
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.45/r/viewer-frame.json",
21
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.45/r/viewer-image.json",
22
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.45/r/viewer-pdf.json"
13
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.47/r/utils.json",
14
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.47/r/button.json",
15
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.47/r/pi-artifacts.json",
16
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.47/r/pi-app.json",
17
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.47/r/pi-app-kinds.json",
18
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.47/r/app-shell.json",
19
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.47/r/file-tree.json",
20
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.47/r/viewer-frame.json",
21
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.47/r/viewer-image.json",
22
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.47/r/viewer-pdf.json"
23
23
  ],
24
24
  "files": [
25
25
  {
package/r/feedback.json CHANGED
@@ -4,17 +4,17 @@
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.4.45",
8
- "@hachej/boring-ui-kit@^0.4.45",
9
- "@hachej/boring-files@^0.4.45",
7
+ "@hachej/boring-feedback@^0.4.47",
8
+ "@hachej/boring-ui-kit@^0.4.47",
9
+ "@hachej/boring-files@^0.4.47",
10
10
  "react@^19.0.0",
11
11
  "react-dom@^19.0.0",
12
12
  "lucide-react@^1.51.0"
13
13
  ],
14
14
  "registryDependencies": [
15
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.45/r/pi-chat.json",
16
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.45/r/pi-chat-voice.json",
17
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.45/r/utils.json"
15
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.47/r/pi-chat.json",
16
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.47/r/pi-chat-voice.json",
17
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.47/r/utils.json"
18
18
  ],
19
19
  "files": [
20
20
  {
@@ -65,12 +65,6 @@
65
65
  "type": "registry:component",
66
66
  "target": "components/feedback/feedback-card.tsx"
67
67
  },
68
- {
69
- "path": "registry/feedback/voice-input.tsx",
70
- "content": "// A microphone button for the composer: records on the first click, stops on the second, transcribes the recording and appends the\n// text to the composer draft. It never sends: the person reads the transcript and presses Send. Pass it to `PiChat` as `tools`.\nimport { useDictation } from '@hachej/boring-ui-kit/chat-react/voice';\nimport { Loader2Icon, MicIcon, SquareIcon, XIcon } from 'lucide-react';\nimport type { NativeChatController } from '@hachej/boring-ui-kit/native-chat';\nimport { appendToDraft } from '@hachej/boring-ui-kit/chat-text';\nimport { useChatText } from '../pi-chat/labels';\nimport { Button } from '@/components/ui/button';\nimport { Toggle } from '@/components/ui/toggle';\n\nexport interface VoiceInputProps {\n /** The chat whose composer draft receives the transcript. */\n readonly controller: NativeChatController;\n /** Turns the recording into text. `signal` aborts when the person cancels or leaves the page; a rejection shows an error. */\n readonly transcribe: (audio: Blob, signal: AbortSignal) => Promise<string>;\n /** Capture limit (default five minutes); the person still finishes before text is appended. */\n readonly maxMs?: number | undefined;\n}\n\nexport function VoiceInput({ controller, transcribe, maxMs }: VoiceInputProps) {\n const { labels } = useChatText();\n const { phase, problem, start, finish, cancel } = useDictation({ transcribe, labels, maxMs, target: controller,\n onText: (spoken, signal) => {\n if (!signal.aborted) controller.setText(appendToDraft(controller.getSnapshot().draft.text, spoken));\n },\n });\n const onClick = () => { if (phase === 'idle') start(); else finish(); };\n const recording = phase === 'recording';\n const busy = phase === 'transcribing';\n return (\n <div className=\"flex items-center gap-1.5\">\n {(phase === 'starting' || recording || busy) && <Button variant=\"ghost\" size=\"icon\" data-testid=\"composer-voice-cancel\" aria-label={labels.voiceCancel} title={labels.voiceCancel} onClick={cancel}>\n <XIcon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>}\n <Toggle data-testid=\"composer-voice\" data-state={phase} pressed={recording} aria-label={phase === 'starting' ? labels.voiceCancel : recording ? labels.voiceStop : labels.voiceRecord}\n title={phase === 'starting' ? labels.voiceCancel : recording ? labels.voiceStop : labels.voiceRecord} disabled={busy} onPressedChange={onClick}>\n {busy ? <Loader2Icon data-icon=\"inline-start\" className=\"animate-spin motion-reduce:animate-none\" role=\"status\" aria-label={labels.voiceTranscribing} />\n : recording ? <SquareIcon data-icon=\"inline-start\" aria-hidden=\"true\" />\n : <MicIcon data-icon=\"inline-start\" aria-hidden=\"true\" />}\n </Toggle>\n {problem && <span role=\"alert\" data-testid=\"composer-voice-problem\" className=\"max-w-56 text-xs leading-4 text-destructive\">{problem}</span>}\n </div>\n );\n}\n",
71
- "type": "registry:component",
72
- "target": "components/feedback/voice-input.tsx"
73
- },
74
68
  {
75
69
  "path": "registry/feedback/feedback-labels.ts",
76
70
  "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 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 /**\n * The comment-mode banner (CommentBanner): its title, the count of notes, the on/off switch's name, what to do while on (a tap on a\n * phone, a click with a mouse), what to do while off (use the app, then switch back on: the notes stay), and Done.\n */\n readonly commentMode: string;\n readonly pointCount: (count: number) => string;\n readonly commentSwitch: string;\n readonly commentTap: string;\n readonly commentClick: string;\n readonly commentPaused: string;\n /** Once notes went to the agent one by one (`useSendEachNote`): how many, and that the reply comes in the conversation. */\n readonly commentSent: (count: number) => string;\n readonly pointDone: string;\n /** The picked element in the note bubble, in plain words: its kind (by ARIA role or tag) and its visible name when it has one. */\n readonly element: (kind: string | undefined, name: string | undefined) => string;\n}\n\n/** The roles and tags people can name; anything else is « this element ». */\nconst KINDS_EN: Record<string, string> = { button: 'Button', link: 'Link', a: 'Link', heading: 'Title', img: 'Image', textbox: 'Field', input: 'Field', checkbox: 'Checkbox',\n combobox: 'List', listbox: 'List', option: 'Choice', tab: 'Tab', row: 'Row', listitem: 'Row', li: 'Row', cell: 'Cell', dialog: 'Window', navigation: 'Menu', menuitem: 'Menu item',\n article: 'Card', region: 'Card', group: 'Card', meter: 'Gauge', progressbar: 'Gauge', list: 'List', paragraph: 'Text', p: 'Text', span: 'Text' };\nconst KINDS_FR: Record<string, string> = { button: 'Bouton', link: 'Lien', a: 'Lien', heading: 'Titre', img: 'Image', textbox: 'Champ', input: 'Champ', checkbox: 'Case à cocher',\n combobox: 'Liste', listbox: 'Liste', option: 'Choix', tab: 'Onglet', row: 'Ligne', listitem: 'Ligne', li: 'Ligne', cell: 'Cellule', dialog: 'Fenêtre', navigation: 'Menu', menuitem: 'Entrée du menu',\n article: 'Carte', region: 'Carte', group: 'Carte', meter: 'Jauge', progressbar: 'Jauge', list: 'Liste', paragraph: 'Texte', p: 'Texte', span: 'Texte' };\nconst named = (kinds: Record<string, string>, unknown: string) => (kind: string | undefined, name: string | undefined): string => {\n const word = kind ? kinds[kind] : undefined;\n return name ? `${word ?? unknown} « ${name} »` : word ?? unknown;\n};\n\n/**\n * The picker's label (`button «Save»`, `button · masked`, `Form.tsx · link «Help»`, `masked`) in plain words: `Button « Save »`,\n * `Button`, `Link « Help »`, `This element`. Nothing technical (no component, no « masked »): what the policy hides stays hidden.\n */\nexport function plainElement(label: string, words: Pick<FeedbackLabels, 'element'>): string {\n const match = /(?:^|· )([a-z][a-z0-9-]*)(?: · masked| «(.*)»)$/.exec(label.trim());\n return words.element(match?.[1], match?.[2]?.trim() || undefined);\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 add: 'Add note',\n voiceRecord: 'Hold to speak, release to send',\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 commentMode: 'Comment mode',\n pointCount: count => `${count} ${count === 1 ? 'note' : 'notes'}`,\n commentSwitch: 'Comment',\n commentTap: 'Tap an element to comment on it',\n commentClick: 'Click an element to comment on it',\n commentPaused: 'Paused: use the app, then switch back on',\n commentSent: count => `${count === 1 ? 'Note sent' : `${count} notes sent`}: the reply comes in the conversation (Done)`,\n pointDone: 'Done',\n element: named(KINDS_EN, 'This element'),\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 add: 'Ajouter la note',\n voiceRecord: 'Maintenez pour parler, relâchez pour envoyer',\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 commentMode: 'Mode commentaire',\n pointCount: count => `${count} note${count > 1 ? 's' : ''}`,\n commentSwitch: 'Commenter',\n commentTap: 'Touchez un élément pour le commenter',\n commentClick: 'Cliquez sur un élément pour le commenter',\n commentPaused: 'En pause : utilisez l’app, puis réactivez',\n commentSent: count => `${count === 1 ? 'Note envoyée' : `${count} notes envoyées`} : la réponse arrive dans la conversation (Terminé)`,\n pointDone: 'Terminé',\n element: named(KINDS_FR, 'Cet élément'),\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",
@@ -82,6 +76,24 @@
82
76
  "content": "// Comment mode's banner (FEEDBACK.md, \"Point mode\"). While a session annotates, CommentBanner says so: « Comment mode · 2 notes », what to\n// do now, an on/off switch (off, the app works as usual and the notes stay) and Done. On a phone the assistant's panel covers the app, so\n// « Show in app » in the composer (`useComposerFeedback(session)`) starts the session in point mode: the host binds `usePointing(session)`\n// to hide its own panel (a Sheet's `open`, AmbientChat's `state`) and the banner sits at the bottom of the screen; each tap picks an\n// element with the same picker, pins, NoteBubble and draft as feedback mode. With a mouse the banner sits at the top (`useCommenting`).\n// Done leaves the draft as the composer's chip (or, with `useSendEachNote`, each note already went to the chat) and the panel comes back.\n//\n// The kit never owns the host's panel. The host keeps its own open state; while `usePointing` is true it shows the panel as closed or\n// minimised, and the panel returns as it was when pointing ends. Mount CommentBanner (and the one NoteBubble) outside the panel, because the\n// panel may unmount while it is hidden.\nimport { createPortal } from 'react-dom';\nimport { CircleIcon } from 'lucide-react';\nimport { Button } from '@/components/ui/button';\nimport { Card } from '@/components/ui/card';\nimport { Switch } from '@/components/ui/switch';\nimport { isPointing, type FeedbackSession, type FeedbackSessionSnapshot } from '@hachej/boring-feedback/ui';\nimport { cn } from '../utils/utils';\nimport { useExternal } from './use-external';\nimport { withLabels, type FeedbackLabels } from './feedback-labels';\nimport { noteCount, notesSent } from './feedback-session';\n\nconst NO_STATE = { getSnapshot: () => undefined, subscribe: () => () => {} };\n\n/** True while the session is in point mode: the host hides its panel then, and shows it again when this turns false. */\nexport function usePointing(session: FeedbackSession | undefined): boolean {\n const state = useExternal<FeedbackSessionSnapshot | undefined>(session ?? NO_STATE);\n return state !== undefined && isPointing(state);\n}\n\n/** True while the session is in comment mode, either way (point mode on a phone, the hover targeting with a mouse). */\nexport function useCommenting(session: FeedbackSession | undefined): boolean {\n const state = useExternal<FeedbackSessionSnapshot | undefined>(session ?? NO_STATE);\n return state !== undefined && (state.phase === 'active' || state.phase === 'processing');\n}\n\nexport interface CommentBannerProps {\n readonly session: FeedbackSession;\n /** The height of the app's own bottom chrome (a tab bar) as a CSS length; on a phone the banner sits above it and above the safe area. */\n readonly bottomOffset?: string;\n /** Words of the banner (`feedbackLabelsFr` for French); what is left out keeps the English default. */\n readonly labels?: Partial<FeedbackLabels>;\n /**\n * Rendered where it is mounted instead of over the whole window (the default, portalled to the body). For comments on one surface, such\n * as a version open in a side panel: mounted in a positioned layer over that surface (a `transform` makes it the containing block), the\n * banner sits over the surface and keeps clear of the panel's own bar and the rest of the app.\n */\n readonly inline?: boolean;\n readonly className?: string;\n}\n\n/**\n * Comment mode's banner, while the session annotates: « Comment mode · 2 notes », what to do now, an on/off switch and Done.\n * On, a tap (point mode, at the bottom of a phone above its tab bar) or a click (the hover targeting, at the top of a wide screen) picks\n * an element and opens its note. Off, the app works as usual (menus, links, another screen) and the notes stay; on again, the person\n * comments further. Done ends comment mode: with `useSendEachNote` each note already went to the agent (the hint says so, and Done shows the\n * conversation with its reply); otherwise the notes wait for the next message. There is no Cancel here: a note is removed on its own.\n * It is `data-feedback-ignore`, so it is never picked, and the note bubble keeps clear of it (`data-feedback-point-bar`).\n */\nexport function CommentBanner({ session, bottomOffset = '0px', labels, inline = false, className }: CommentBannerProps) {\n const state = useExternal(session);\n const words = withLabels(labels);\n if ((state.phase !== 'active' && state.phase !== 'processing') || typeof document === 'undefined') return null;\n const processing = state.phase === 'processing';\n const on = !state.useToggle;\n const phone = isPointing(state);\n const sent = notesSent(session);\n const hint = state.problem ?? (!on ? words.commentPaused : sent > 0 && !state.bubble ? words.commentSent(sent) : phone ? words.commentTap : words.commentClick);\n // Notes that say something: a pin still being typed is not one yet.\n const notes = noteCount(state);\n const banner = <Card size=\"sm\" data-boring=\"feedback\" data-feedback-ignore=\"\" data-feedback-point-bar={phone ? 'bottom' : 'top'} role=\"region\" aria-label={words.commentMode}\n data-testid=\"feedback-point-bar\" data-commenting={on ? 'true' : 'false'} className={cn('boring-feedback-banner', className)}\n style={phone ? { bottom: `calc(${bottomOffset} + env(safe-area-inset-bottom, 0px) + 8px)` } : { top: '8px' }}>\n <CircleIcon className={cn('size-2 shrink-0 fill-current', on ? 'text-primary' : 'text-muted-foreground')} aria-hidden=\"true\" />\n <span className=\"flex min-w-0 flex-1 flex-col\">\n <span className=\"truncate text-sm font-semibold\">{words.commentMode}{notes > 0 && <span className=\"font-normal text-muted-foreground tabular-nums\" data-testid=\"feedback-point-count\"> · {words.pointCount(notes)}</span>}</span>\n <span className=\"text-xs text-pretty text-muted-foreground\" role=\"status\" aria-live=\"polite\" data-testid=\"feedback-point-hint\">{hint}</span>\n </span>\n <span className=\"flex min-h-11 shrink-0 items-center px-2\">\n <Switch data-testid=\"feedback-comment-switch\" checked={on} disabled={processing} aria-label={words.commentSwitch}\n onCheckedChange={checked => session.setUseToggle(!checked)} />\n </span>\n <Button className=\"min-h-11 shrink-0 sm:min-h-9\" data-testid=\"feedback-point-done\" disabled={processing} onClick={() => { void session.done(); }}>{words.pointDone}</Button>\n </Card>;\n return inline ? banner : createPortal(banner, document.body);\n}\n",
83
77
  "type": "registry:component",
84
78
  "target": "components/feedback/point-mode.tsx"
79
+ },
80
+ {
81
+ "path": "registry/feedback/feedback-content.ts",
82
+ "content": "import { isHostObserved, parseFeedback } from '@hachej/boring-feedback/format';\nimport type { FeedbackReport } from '@hachej/boring-feedback/format';\n\nfunction pageReport(text: string): FeedbackReport | undefined {\n if (!text.startsWith('---\\n')) return;\n const parsed = parseFeedback(new TextEncoder().encode(text));\n if (parsed.ok && isHostObserved(parsed.report.observed) && parsed.report.notes?.some(note => note.text.trim())) return parsed.report;\n}\n\n/** Only presentation changes: the native message retains the complete report and any words around its composer fence. */\nexport function feedbackContent(text: string): { readonly before: string; readonly after: string; readonly report: FeedbackReport } | undefined {\n const report = pageReport(text);\n if (report) return { before: '', after: '', report };\n let open: { fence: string; start: number; body: number; report: boolean } | undefined;\n // Track other code fences too: a report quoted inside an example is still ordinary user text.\n for (const line of text.matchAll(/^ {0,3}(`{3,}|~{3,})([^\\n]*)(?:\\n|$)/gm)) {\n const fence = line[1]!, info = line[2]!;\n if (!open) {\n open = { fence, start: line.index, body: line.index + line[0].length, report: line[0].startsWith('`') && info === 'markdown' };\n } else if (fence[0] === open.fence[0] && fence.length >= open.fence.length && !info.trim()) {\n const parsed = open.report ? pageReport(text.slice(open.body, line.index)) : undefined;\n if (parsed) return { before: text.slice(0, open.start).trim(), after: text.slice(line.index + line[0].length).trim(), report: parsed };\n open = undefined;\n }\n }\n}\n",
83
+ "type": "registry:lib",
84
+ "target": "components/feedback/feedback-content.ts"
85
+ },
86
+ {
87
+ "path": "registry/feedback/sent-feedback.tsx",
88
+ "content": "'use client';\n\nimport { ChevronDownIcon } from 'lucide-react';\nimport { readableElement } from '@hachej/boring-feedback/format';\nimport type { FeedbackReport } from '@hachej/boring-feedback/format';\nimport { Button } from '@/components/ui/button';\nimport { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';\nimport { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';\nimport { cn } from '../utils/utils';\nimport { useChatText } from '../pi-chat/labels';\n\n/** Sent page comments are inert text, not live page targets or evidence that a change was accepted. */\nexport function SentFeedback({ report, align = 'end' }: { readonly report: FeedbackReport; readonly align?: 'start' | 'end' }) {\n const { labels } = useChatText();\n const notes = (report.notes ?? []).filter(note => note.text.trim());\n if (!notes.length) return null;\n const title = labels.feedbackSent(notes.length);\n const long = notes.length > 3 || report.said.length + notes.reduce((length, note) => length + note.text.length, 0) > 400;\n const content = <CardContent className=\"flex flex-col gap-3\">\n {report.said && <p className=\"m-0 whitespace-pre-wrap [overflow-wrap:anywhere]\">{report.said}</p>}\n <ol className=\"m-0 flex list-none flex-col gap-3 p-0\">\n {notes.map((note, at) => {\n const anchor = note.anchor === undefined ? undefined : report.anchors[note.anchor];\n const target = typeof anchor?.fallback === 'string' ? readableElement(anchor.fallback).name : undefined;\n return <li key={at} data-testid=\"message-feedback-note\" className=\"flex min-w-0 flex-col gap-1 [overflow-wrap:anywhere]\">\n {target && <CardDescription>{target}</CardDescription>}<p className=\"m-0 whitespace-pre-wrap\">{note.text}</p>\n </li>;\n })}\n </ol>\n </CardContent>;\n // CardHeader is an inline-size container; a collapsed card cannot size itself from its title.\n const card = { size: 'sm' as const, 'data-testid': 'message-feedback', role: 'group', 'aria-label': title, className: cn('w-80 max-w-[85%]', align === 'end' && 'ml-auto') };\n if (!long) return <Card {...card}><CardHeader><CardTitle>{title}</CardTitle></CardHeader>{content}</Card>;\n return <Collapsible data-slot=\"card\" render={<Card {...card} />}>\n <CardHeader><CardTitle><CollapsibleTrigger data-slot=\"button\" render={<Button variant=\"ghost\" className=\"group w-full justify-between whitespace-normal text-left\" />}>\n {title}<ChevronDownIcon data-icon=\"inline-end\" aria-hidden=\"true\" className=\"group-aria-expanded:rotate-180\" />\n </CollapsibleTrigger></CardTitle></CardHeader>\n <CollapsibleContent>{content}</CollapsibleContent>\n </Collapsible>;\n}\n",
89
+ "type": "registry:component",
90
+ "target": "components/feedback/sent-feedback.tsx"
91
+ },
92
+ {
93
+ "path": "registry/feedback/sent-content.tsx",
94
+ "content": "'use client';\n\n// pi-chat's `sentContent` seam, filled: a sent page-comments report shows as its card instead of the raw report.\nimport type { SentContent } from '../pi-chat/config';\nimport { feedbackContent } from './feedback-content';\nimport { SentFeedback } from './sent-feedback';\n\nexport const sentFeedbackContent: SentContent = (text, align) => {\n const found = feedbackContent(text);\n return found && { before: found.before, after: found.after, node: <SentFeedback report={found.report} align={align} /> };\n};\n",
95
+ "type": "registry:component",
96
+ "target": "components/feedback/sent-content.tsx"
85
97
  }
86
98
  ],
87
99
  "css": {
package/r/file-tree.json CHANGED
@@ -4,14 +4,14 @@
4
4
  "title": "File tree",
5
5
  "description": "Browse, search, upload, rename, move, delete and inspect retained versions through one revisionProvider binding. Uses the consumer's shadcn Button, dropdown-menu and dialog with shared version-history rows; Base UI style only.",
6
6
  "dependencies": [
7
- "@hachej/boring-ui-kit@^0.4.45",
8
- "@hachej/boring-files@^0.4.45",
7
+ "@hachej/boring-ui-kit@^0.4.47",
8
+ "@hachej/boring-files@^0.4.47",
9
9
  "react@^19.0.0",
10
10
  "lucide-react@^1.51.0"
11
11
  ],
12
12
  "registryDependencies": [
13
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.45/r/utils.json",
14
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.45/r/version-history.json"
13
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.47/r/utils.json",
14
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.47/r/version-history.json"
15
15
  ],
16
16
  "files": [
17
17
  {
package/r/highlights.json CHANGED
@@ -4,13 +4,13 @@
4
4
  "title": "Anchored highlights",
5
5
  "description": "Exact Markdown highlights and comments in a conditional workspace sidecar.",
6
6
  "dependencies": [
7
- "@hachej/boring-ui-kit@^0.4.45",
8
- "@hachej/boring-files@^0.4.45",
7
+ "@hachej/boring-ui-kit@^0.4.47",
8
+ "@hachej/boring-files@^0.4.47",
9
9
  "react@^19.0.0",
10
10
  "react-dom@^19.0.0"
11
11
  ],
12
12
  "registryDependencies": [
13
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.45/r/markdown-editor.json"
13
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.47/r/markdown-editor.json"
14
14
  ],
15
15
  "files": [
16
16
  {
@@ -4,13 +4,13 @@
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.4.45",
8
- "@hachej/boring-files@^0.4.45",
7
+ "@hachej/boring-ui-kit@^0.4.47",
8
+ "@hachej/boring-files@^0.4.47",
9
9
  "react@^19.0.0",
10
10
  "react-dom@^19.0.0"
11
11
  ],
12
12
  "registryDependencies": [
13
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.45/r/button.json"
13
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.47/r/button.json"
14
14
  ],
15
15
  "files": [
16
16
  {
@@ -8,8 +8,8 @@
8
8
  "lucide-react@^1.51.0"
9
9
  ],
10
10
  "registryDependencies": [
11
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.45/r/mockup-viewer.json",
12
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.45/r/utils.json"
11
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.47/r/mockup-viewer.json",
12
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.47/r/utils.json"
13
13
  ],
14
14
  "files": [
15
15
  {
@@ -8,7 +8,7 @@
8
8
  "lucide-react@^1.51.0"
9
9
  ],
10
10
  "registryDependencies": [
11
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.45/r/diagram-viewer.json"
11
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.47/r/diagram-viewer.json"
12
12
  ],
13
13
  "files": [
14
14
  {
@@ -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.4.45",
8
- "@hachej/boring-files@^0.4.45",
7
+ "@hachej/boring-ui-kit@^0.4.47",
8
+ "@hachej/boring-files@^0.4.47",
9
9
  "react@^19.0.0",
10
10
  "react-dom@^19.0.0",
11
11
  "marked@^17.0.6",
@@ -24,7 +24,7 @@
24
24
  "lucide-react@^1.51.0"
25
25
  ],
26
26
  "registryDependencies": [
27
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.45/r/button.json"
27
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.47/r/button.json"
28
28
  ],
29
29
  "files": [
30
30
  {
@@ -10,7 +10,7 @@
10
10
  "zod@^4.6.5"
11
11
  ],
12
12
  "registryDependencies": [
13
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.45/r/viewer-frame.json"
13
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.47/r/viewer-frame.json"
14
14
  ],
15
15
  "files": [
16
16
  {
package/r/pi-ambient.json CHANGED
@@ -4,7 +4,7 @@
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 over the kit's shared chat-react run notifications (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.4.45",
7
+ "@hachej/boring-ui-kit@^0.4.47",
8
8
  "react@^19.0.0",
9
9
  "react-dom@^19.0.0",
10
10
  "@earendil-works/pi-durable@~1.4.0",
@@ -12,18 +12,18 @@
12
12
  "lucide-react@^1.51.0"
13
13
  ],
14
14
  "registryDependencies": [
15
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.45/r/pi-chat.json",
16
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.45/r/pi-chat-plugins.json",
17
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.45/r/pi-chat-question.json",
18
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.45/r/pi-artifacts.json",
19
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.45/r/pi-workspace.json",
20
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.45/r/utils.json",
21
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.45/r/button.json"
15
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.47/r/pi-chat.json",
16
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.47/r/pi-chat-plugins.json",
17
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.47/r/pi-chat-question.json",
18
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.47/r/pi-artifacts.json",
19
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.47/r/pi-workspace.json",
20
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.47/r/utils.json",
21
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.47/r/button.json"
22
22
  ],
23
23
  "files": [
24
24
  {
25
25
  "path": "registry/pi-ambient/ambient.tsx",
26
- "content": "'use client';\n\nimport { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';\nimport type { KeyboardEvent, PointerEvent, ReactNode } from 'react';\nimport type { NativeChatController } from '@hachej/boring-ui-kit/native-chat';\nimport { BellIcon, ChevronDownIcon, CircleAlertIcon, CircleCheckIcon, ExternalLinkIcon, GripVerticalIcon, Loader2Icon, MessageCircleQuestionIcon, MinusIcon, PanelLeftIcon, ThumbsDownIcon, ThumbsUpIcon } from 'lucide-react';\nimport { Button } from '@/components/ui/button';\nimport { MessageScroller, MessageScrollerProvider, MessageScrollerViewport, MessageScrollerContent, MessageScrollerButton } from '@/components/ui/message-scroller';\nimport { Toggle } from '@/components/ui/toggle';\nimport { Avatar, AvatarFallback } from '@/components/ui/avatar';\nimport { Badge } from '@/components/ui/badge';\nimport { Card } from '@/components/ui/card';\nimport { Separator } from '@/components/ui/separator';\nimport { BlockActions } from '../button/actions';\nimport type { BlockAction, BlockIcon } from '../button/actions';\nimport { useChatPlacement } from '../pi-chat/use-remote-chat';\nimport { CopyButton } from '../pi-chat/code-block';\nimport { Composer } from '../pi-chat/composer';\nimport type { ComposerFeedback, ComposerProps } from '../pi-chat/composer';\nimport { ConversationFiles } from '../pi-chat/conversation-files';\nimport type { ConversationsConfig } from '../pi-chat/config';\nimport { notifyPermission, requestNotifyPermission } from './browser-notify';\nimport type { NotifyPermission } from './browser-notify';\nimport { ConversationHistory, TranscriptHistory } from '../pi-chat/history';\nimport { withChatPlugins } from '../pi-chat-plugins/chat-plugins';\nimport { TranscriptChunk } from '../pi-chat/message';\nimport type { RowContext } from '../pi-chat/message';\nimport { ChatNotices, ChatConnectionNotice, ChatStatusLine } from '../pi-chat/notice';\nimport { AgentNotifications, KindIcon, createNotificationStore, defaultNotificationLabels, noticeTextOf, useRunNotifications, 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 { useChatView, useArtifactFollow } from '@hachej/boring-ui-kit/chat-react';\nimport type { ArtifactShown, ArtifactFailed } from '@hachej/boring-ui-kit/chat-react';\nimport { createTaskArtifact, pendingPresents, taskSubjectKey } from '../pi-artifacts/artifact';\nimport type { ArtifactDescriptor, PresentedArtifact, ArtifactsConfig } from '../pi-artifacts/artifact';\nimport { artifactCards } from '../pi-artifacts/artifact-card';\nimport { PAGE_FIRST, PAGE_MORE, useChatSession, useRowChunks, useTranscript } from '../pi-chat/session';\nimport type { ChatFeatureProps } from '../pi-chat/session';\nimport { ArtifactWorkspace } from '../pi-workspace/workspace';\nimport type { WorkspacePanelApi } from '../pi-workspace/workspace';\nimport { cn } from '../utils/utils';\nimport { usePhone } from '../utils/use-media';\nimport { readPersistedJson, writePersistedJson } from '../utils/persisted';\nexport { usePhone } from '../utils/use-media';\n\nexport { AgentNotifications, createNotificationStore, defaultNotificationLabels, frenchNotificationLabels, noticeTextOf, 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 /** Forward the viewer's actual loaded revision mount/unmount report. Opening or rendering the panel is not evidence. */\n readonly onShown: (shown: ArtifactShown) => void;\n /** Forward a viewer load/render failure; the waiting present is refused. */\n readonly onFailed: (failed: ArtifactFailed) => void;\n}\n\nexport interface AmbientChatProps extends Omit<ChatFeatureProps, 'artifacts'> {\n readonly controller: NativeChatController;\n /** Host recovery content, shared with PiChat in expanded and compact connection notices. */\n readonly renderConnectionError?: ((error: unknown) => ReactNode) | undefined;\n /** Artifact cards (`ArtifactsConfig` of `pi-artifacts`); the ambient bar plugs in their card itself. */\n readonly artifacts?: ArtifactsConfig | undefined;\n /** Embed the expanded conversation in a host workspace. Switching this flag changes layout without remounting the transcript or composer. The host supplies the header. */\n readonly embedded?: boolean;\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 /** Host-owned header controls, such as icon-anchored popovers, before the actions and Dock button. */\n readonly headerTools?: ReactNode;\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 /** Host content under the composer (for example a collapsed list of the person's requests). Absent: nothing is shown. */\n readonly belowComposer?: ReactNode;\n /** Two-row reference composer by default; older layouts require an explicit choice. */\n readonly composerLayout?: ComposerProps['layout'];\n /** Host-shared composer presentation, also accepted by PiChat. */\n readonly renderComposer?: (props: ComposerProps) => ReactNode;\n readonly sendContext?: import('@hachej/boring-ui-kit/chat-react').ChatSessionFeedback;\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 readPosition(key: string, persist: 'session' | 'local'): Position | undefined {\n try {\n const value: unknown = readPersistedJson(key, persist);\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 writePersistedJson(key, position, persist);\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 }; };\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 // eslint-disable-next-line react-hooks/set-state-in-effect -- the count restarts at 0 whenever `active` flips; the interval below owns it after that\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/** A compact floating agent bar over an existing page; it grows into a chat window in place. Borrows a concrete native controller like `PiChat`. */\n/** The ambient bar comes with every composer feature item (`withChatPlugins`); a component the host passes is kept. */\nexport function AmbientChat(given: AmbientChatProps) {\n const props = withChatPlugins(given);\n // eslint-disable-next-line react-hooks/refs -- the session's async callbacks compare against the controller of the latest render; assigned in render so a child's effects never see the previous one\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 if (props.embedded && local !== 'expanded') setLocal('expanded');\n const internalStore = useMemo(() => createNotificationStore(), []);\n const text = useMergedText<AmbientLabels, AmbientIcons>(props.labels, props.icons, AMBIENT_TEXT);\n return <ChatTextProvider value={text}><MessageScrollerProvider key={mount.sequence} autoScroll><AmbientSession {...props} labels={text.labels} icons={text.icons} activeController={active} windowState={props.embedded ? 'expanded' : props.state ?? local} store={props.notifications || internalStore}\n onWindowState={next => { if (props.state === undefined) setLocal(next); props.onStateChange?.(next); }} /></MessageScrollerProvider></ChatTextProvider>;\n}\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, embedded = false, labels, icons, avatar, headerActions, headerTools, messageActions, variant = 'contrast', mode = 'expert', presentation, voiceReplay, targetLabel, actions,\n windowState, onWindowState, store, storageKey = 'boring.ambient.position', storage = 'session', tools, belowComposer, composerChips, composerLayout = 'card', renderComposer, sendContext, onOpenFull, onFeedback, notifications, autoDismissMs, maxToasts, systemNotifications = false,\n renderConnectionError, renderEntry, hideLine, displayLimit, paging, historicalMessageActions, unread: replyUnread, replyError, renderTool, toolRenderers, renderText, cards, groupTool, commandMentions, onOpenImage, onCopy, onComposerKeyDown, fileAccept = 'image/*', slash, mentions, attachments, onOpenFile, fileChanges, pasteToFile, model, effort, replyFooter, artifacts: hostArtifacts, artifactPanel, artifactTarget, conversations, onDock, feedback: composerFeedback, className,\n activeController }: SessionProps) {\n const { title } = labels;\n useChatPlacement(controller, embedded ? 'docked' : 'floating');\n const stateRef = useRef(windowState);\n useLayoutEffect(() => { 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: PresentedArtifact; 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 // eslint-disable-next-line react-hooks/set-state-in-effect -- the browser's permission is read once after mount, so the server render and hydration agree on '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 opacity-0 transition-opacity group-hover/message:opacity-100 group-focus-within/message:opacity-100 max-md:opacity-100 pointer-coarse:opacity-100 motion-reduce:transition-none\">\n <CopyButton text={reply.text} label={labels.copyResponse} iconOnly {...(onCopy ? { onCopy } : {})} />\n {onFeedback && <>\n {(['up', 'down'] as const).map(value => <Toggle key={value} data-testid={`feedback-${value}`} pressed={feedback[reply.key] === value} aria-label={value === 'up' ? labels.goodResponse : labels.badResponse}\n title={value === 'up' ? labels.goodResponse : labels.badResponse}\n onPressedChange={pressed => { const next = pressed ? value : null; setFeedback(current => { const { [reply.key]: _removed, ...rest } = current; return next ? { ...rest, [reply.key]: next } : rest; }); onFeedback({ key: reply.key, text: reply.text }, next); }}>\n {value === 'up' ? <icons.goodResponse data-icon=\"inline-start\" aria-hidden=\"true\" /> : <icons.badResponse data-icon=\"inline-start\" aria-hidden=\"true\" />}</Toggle>)}\n </>}\n {onOpenFull && <Button variant=\"ghost\" size=\"icon\" data-testid=\"open-full\" aria-label={labels.openFull} title={labels.openFull} onClick={() => onOpenFull(conversationRef.current)}><icons.openFull data-icon=\"inline-start\" 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 text-muted-foreground\">{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 follow = useArtifactFollow({ controller, conversationId: String(conversations?.activeId ?? controller.getSnapshot().conversationId), detect,\n active: opened, onOpen: setOpened, autoOpen: false, narrow: () => phone,\n showing: path => !inWindow || (expanded && opened?.descriptor.type !== 'task' && opened?.descriptor.target.resource.path === path),\n showingTask: subject => !inWindow || (expanded && opened?.descriptor.type === 'task' && taskSubjectKey(opened.descriptor.subject) === taskSubjectKey(subject)),\n openPresent: () => false, answer: actions?.answer });\n const { onShown, onFailed, open: openArtifact, isOpen: isArtifactOpen } = follow;\n const pendingTasks = useChatView(controller, pendingPresents);\n const requestedTasks = useRef(new WeakMap<NativeChatController, Set<string>>());\n useEffect(() => {\n if (inWindow || !hostArtifacts?.autoOpenTasks) return;\n let requested = requestedTasks.current.get(controller);\n if (!requested) { requested = new Set(); requestedTasks.current.set(controller, requested); }\n for (const call of pendingTasks) {\n if (!('subject' in call) || requested.has(call.id)) continue;\n requested.add(call.id);\n hostArtifacts.open(createTaskArtifact(call.subject), { onShown, onFailed });\n }\n }, [controller, inWindow, hostArtifacts, pendingTasks, onShown, onFailed]);\n const closeArtifact = () => { follow.recordClosed(); setOpened(null); setFullscreen(false); };\n const artifacts = useMemo<ArtifactsConfig | undefined>(() => !inWindow ? hostArtifacts && { ...hostArtifacts,\n open: artifact => hostArtifacts.open(artifact, { onShown, onFailed }) } : {\n open: artifact => artifact.type === 'task' ? hostArtifacts?.open(artifact, { onShown, onFailed }) : openArtifact(artifact), isOpen: isArtifactOpen,\n ...(detect ? { detect } : {}),\n ...(hostArtifacts?.kinds ? { kinds: hostArtifacts.kinds } : {}),\n recognise: hostArtifacts?.recognise ?? artifactCards.recognise, Card: hostArtifacts?.Card ?? artifactCards.Card,\n }, [inWindow, hostArtifacts, openArtifact, isArtifactOpen, onShown, onFailed, detect]);\n const session = useChatSession({ controller, activeController, mode, presentation, voiceReplay, targetLabel, actions, renderEntry, hideLine, historicalMessageActions, unread: replyUnread, replyError, renderTool, toolRenderers, renderText, cards, groupTool, commandMentions, onOpenImage, onCopy, onComposerKeyDown, fileAccept,\n slash, mentions, attachments, model, effort, artifacts, messageActions, replyFooter, afterSend: () => scrollToBottom.current(), rowExtras, feedback: composerFeedback, sendContext, onOpenFile, fileChanges, pasteToFile, composerChips });\n const { state, derived, rows, queued, queueActions, working, waitingForAnswer, connected, error, act, textarea, rowContext, composer, files, loading } = session;\n const empty = session.empty;\n const transcript = useTranscript(rows, displayLimit ?? paging?.first ?? PAGE_FIRST, paging?.more ?? PAGE_MORE, { state: state.history, connected: !state.disposed && connected, load: controller.loadEarlier }, displayLimit === undefined);\n const { stick: { scrollRef, isAtBottom, scrollToBottom: stickToBottom }, hidden } = transcript;\n const chunks = useRowChunks(useMemo(() => hidden ? rows.slice(hidden) : rows, [rows, hidden]));\n const known = follow.known, shownArtifact = follow.shown;\n const { reset } = transcript;\n useLayoutEffect(() => { scrollToBottom.current = () => { reset(); void stickToBottom('instant'); }; });\n const { barStart, barNote } = session.pickers();\n\n const conversationId = String(conversations?.activeId ?? state.conversationId);\n useLayoutEffect(() => { 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 useRunNotifications(controller, store, { enabled: notifications !== false, conversationId, title: shownTitle, onOutcome: setOutcome, text: noticeTextOf(labels),\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 // eslint-disable-next-line react-hooks/exhaustive-deps -- measures after every commit on purpose; both setters return the current value when nothing changed, so it settles\n useLayoutEffect(() => {\n const element = root.current;\n const fresh = viewportSize();\n // eslint-disable-next-line react-hooks/set-state-in-effect -- layout reads (viewport, own size) exist only after commit and must land before paint\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 variant=\"ghost\" size=\"icon\" data-testid=\"ambient-grip\" aria-label={labels.move} title={labels.moveHint} onPointerDown={onGripDown} onKeyDown={onGripKey}\n className={cn('touch-none', className)}><icons.grip data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>;\n\n const expand = () => { setOutcome(null); store.dismissConversation(conversationId); setWindowState('expanded'); };\n const latestExpand = useRef(expand);\n useLayoutEffect(() => { latestExpand.current = expand; });\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 (embedded || 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 closeArtifact();\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 latestExpand.current();\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 // The floating window is drawn by the host's Card behind it (a sibling layer, so switching placement never remounts the chat);\n // embedded and full-screen phone windows are plain page regions.\n const card = !embedded && !(phone && expanded);\n const surface = embedded ? 'relative flex min-h-0 flex-1 flex-col overflow-clip bg-transparent text-foreground' : cn('relative flex flex-col', card ? 'isolate' : 'h-dvh bg-background pt-[env(safe-area-inset-top)] text-foreground',\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 = embedded ? cn('pi-chat relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden', className) : 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 || embedded ? 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 max-w-[min(20rem,calc(100vw-1rem))] items-center gap-0.5', !phone && 'cursor-grab touch-none select-none active:cursor-grabbing')}>\n {!phone && grip('ml-1.5')}\n <Button ref={pill} variant=\"outline\" size=\"lg\" data-testid=\"ambient-pill\" onClick={() => setWindowState('bar')} aria-label={labels.openAgent(shownTitle, unread)}\n className=\"relative min-w-0 gap-2.5\">\n {status === 'working' ? <Loader2Icon data-icon=\"inline-start\" className=\"animate-spin motion-reduce:animate-none\" aria-hidden=\"true\" />\n : status === 'input' ? <MessageCircleQuestionIcon data-icon=\"inline-start\" aria-hidden=\"true\" />\n : status === 'done' ? <CircleCheckIcon data-icon=\"inline-start\" aria-hidden=\"true\" />\n : status === 'error' ? <CircleAlertIcon data-icon=\"inline-start\" aria-hidden=\"true\" /> : null}\n <span className=\"min-w-0 truncate\">{shownTitle}</span>\n {unread && <Badge data-testid=\"ambient-unread\" aria-hidden=\"true\" className=\"absolute -top-1.5 -right-1.5\" />}\n </Button>\n </div>\n </div>;\n\n const conversation = <section className=\"flex min-h-0 min-w-0 flex-1 flex-col\">\n <header hidden={embedded} data-testid=\"ambient-header\" className={cn(embedded && '!hidden','flex h-12 shrink-0 items-center gap-0.5 px-2 max-md:h-14 max-md:px-3')}>\n <Button variant=\"ghost\" size=\"icon\" data-testid=\"ambient-minimize\" aria-label={labels.minimize} title={labels.minimize} onClick={() => { setPicking(false); setWindowState(expanded ? 'bar' : 'minimized'); }}><icons.minimize data-icon=\"inline-start\" 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 && <Avatar size=\"sm\" data-testid=\"ambient-avatar\" className=\"overflow-hidden\"><AvatarFallback className=\"*:max-h-full *:max-w-full\">{avatar}</AvatarFallback></Avatar>}\n <Button ref={titleButton} variant=\"ghost\" data-testid=\"ambient-title\" onClick={expand} disabled={expanded} aria-label={expanded ? shownTitle : labels.openChat(shownTitle)}\n className=\"min-w-0 max-w-full shrink\"><span className=\"truncate\">{shownTitle}</span></Button>\n {conversations && <Button variant=\"ghost\" size=\"icon\" data-testid=\"ambient-switch\" aria-label={labels.switchConversation} aria-haspopup=\"dialog\" aria-expanded={picking} title={labels.switchConversation} onClick={() => setPicking(open => !open)}>\n <icons.switchConversation data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>}\n {expanded ? <span className=\"flex-1\" /> : <Button variant=\"ghost\" tabIndex={-1} aria-hidden=\"true\" data-testid=\"ambient-header-fill\" onClick={expand} className=\"min-w-2 flex-1\" />}\n {systemNotifications && permission === 'default' && <Button variant=\"ghost\" size=\"icon\" data-testid=\"ambient-enable-notifications\" aria-label={labels.enableNotifications} title={labels.enableNotifications}\n onClick={() => { void requestNotifyPermission().then(setPermission); }}><icons.notify data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>}\n {headerTools}\n <BlockActions actions={headerActions} testId=\"ambient-action\" menuLabel={labels.moreActions} />\n {onDock && !phone && <Button variant=\"ghost\" size=\"icon\" data-testid=\"ambient-dock\" aria-label={labels.dock} title={labels.dockHint} onClick={() => { setPicking(false); onDock(); }}><icons.dock data-icon=\"inline-start\" 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\"><ChatConnectionNotice state={state} renderConnectionError={renderConnectionError} onReconnect={() => act(controller.connect)} /></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 <MessageScroller>\n <MessageScrollerViewport ref={scrollRef} data-testid=\"transcript-scroll\" aria-label={labels.messages} className=\"scrollbar-thumb-border scrollbar-track-transparent\">\n {!empty && <TranscriptHistory history={state.history} connected={!state.disposed && connected} onLoad={transcript.reveal} sentinelRef={transcript.sentinelRef} hidden={hidden} loaded={transcript.loaded} />}\n <div className=\"px-4 pt-4 pb-6\">\n <MessageScrollerContent aria-label={labels.messages} aria-live=\"polite\" data-testid=\"transcript\">\n {chunks.map(run => <TranscriptChunk key={run[0]!.key} rows={run} 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 max-w-sm py-8 text-sm leading-6 text-muted-foreground\">{labels.empty}</p>}\n </MessageScrollerContent>\n </div>\n </MessageScrollerViewport>\n {!isAtBottom && <MessageScrollerButton data-testid=\"jump-latest\" aria-label={labels.jumpToLatest} title={labels.jumpToLatest} onClick={transcript.reset}><icons.jumpToLatest aria-hidden=\"true\" /></MessageScrollerButton>}\n </MessageScroller>\n </div>\n </div>}\n\n {(expanded || !embedded) && <Separator />}\n <div className={cn('shrink-0', phone && 'pb-[env(safe-area-inset-bottom)]')}>\n <div className=\"space-y-2 px-3 empty:hidden [&:has(*)]:pt-2\">\n <ChatStatusLine natural={session.natural} working={working} waitingForAnswer={waitingForAnswer} derived={derived} stop={state.stop} />\n <ChatNotices natural={session.natural} state={state} error={error} onReconcile={() => act(controller.reconcile)} onRetry={() => act(controller.retrySameRequest)} />\n {!expanded && <ChatConnectionNotice compact fallback={{ error: labels.loadFailedShort, closed: labels.disconnectedShort }} state={state} renderConnectionError={renderConnectionError} onReconnect={() => act(controller.connect)} />}\n </div>\n <div className=\"px-3 pt-2 pb-3\">\n <ConversationFiles files={files} />\n <MessageQueue items={queued} sending={state.outbox} withdraw={actions?.withdraw} actions={queueActions} />\n {(renderComposer ?? (value => <Composer {...value} />))({ ...composer, layout: composerLayout, placeholder: working || waitingForAnswer ? label : labels.placeholder, barStart, barNote, barEnd: tools })}\n {belowComposer !== undefined && <div data-testid=\"below-composer\" className=\"pt-2\">{belowComposer}</div>}\n </div>\n </div>\n </section>;\n return <div ref={root} data-boring={embedded ? 'pi-chat' : '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 {!embedded && 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 {card && <Card aria-hidden=\"true\" data-boring=\"chrome\" className=\"pointer-events-none absolute inset-0 -z-10\" />}\n {artifactPanel ? <ArtifactWorkspace open={panelOpen} onClose={closeArtifact} fullscreen={fullscreen} onFullscreenChange={setFullscreen} storageKey=\"boring.ambient.panel-width\" panelLabel={labels.artifactPanel}\n defaultWidth={600} minPanel={320} minChat={300}\n panel={api => shownArtifact && shownArtifact.type !== 'task' ? artifactPanel(shownArtifact, { ...api, versions: known.filter((artifact): artifact is ArtifactDescriptor => artifact.type !== 'task'), follow: opened?.follow ?? true,\n select: follow.select, onShown: follow.onShown, onFailed: follow.onFailed }) : null}\n chat={conversation} /> : conversation}\n </div>\n </div>;\n}\n",
26
+ "content": "'use client';\n\nimport { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';\nimport type { KeyboardEvent, PointerEvent, ReactNode } from 'react';\nimport type { NativeChatController } from '@hachej/boring-ui-kit/native-chat';\nimport { BellIcon, ChevronDownIcon, CircleAlertIcon, CircleCheckIcon, ExternalLinkIcon, GripVerticalIcon, Loader2Icon, MessageCircleQuestionIcon, MinusIcon, PanelLeftIcon, ThumbsDownIcon, ThumbsUpIcon } from 'lucide-react';\nimport { Button } from '@/components/ui/button';\nimport { MessageScroller, MessageScrollerProvider, MessageScrollerViewport, MessageScrollerContent, MessageScrollerButton } from '@/components/ui/message-scroller';\nimport { Toggle } from '@/components/ui/toggle';\nimport { Avatar, AvatarFallback } from '@/components/ui/avatar';\nimport { Badge } from '@/components/ui/badge';\nimport { Card } from '@/components/ui/card';\nimport { Separator } from '@/components/ui/separator';\nimport { BlockActions } from '../button/actions';\nimport type { BlockAction, BlockIcon } from '../button/actions';\nimport { useChatPlacement } from '../pi-chat/use-remote-chat';\nimport { CopyButton } from '../pi-chat/code-block';\nimport { Composer } from '../pi-chat/composer';\nimport type { ComposerFeedback, ComposerProps } from '../pi-chat/composer';\nimport { ConversationFiles } from '../pi-chat/conversation-files';\nimport type { ConversationsConfig } from '../pi-chat/config';\nimport { notifyPermission, requestNotifyPermission } from './browser-notify';\nimport type { NotifyPermission } from './browser-notify';\nimport { ConversationHistory, TranscriptHistory } from '../pi-chat/history';\nimport { withChatPlugins } from '../pi-chat-plugins/chat-plugins';\nimport { TranscriptChunk } from '../pi-chat/message';\nimport type { RowContext } from '../pi-chat/message';\nimport { ChatNotices, ChatConnectionNotice, ChatStatusLine } from '../pi-chat/notice';\nimport { AgentNotifications, KindIcon, createNotificationStore, defaultNotificationLabels, noticeTextOf, useRunNotifications, 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 { useChatView, useArtifactFollow } from '@hachej/boring-ui-kit/chat-react';\nimport type { ArtifactShown, ArtifactFailed } from '@hachej/boring-ui-kit/chat-react';\nimport { createTaskArtifact, pendingPresents, taskSubjectKey } from '../pi-artifacts/artifact';\nimport type { ArtifactDescriptor, PresentedArtifact, ArtifactsConfig } from '../pi-artifacts/artifact';\nimport { artifactCards } from '../pi-artifacts/artifact-card';\nimport { PAGE_FIRST, PAGE_MORE, useChatSession, useRowChunks, useTranscript } from '../pi-chat/session';\nimport type { ChatFeatureProps } from '../pi-chat/session';\nimport { ArtifactWorkspace } from '../pi-workspace/workspace';\nimport type { WorkspacePanelApi } from '../pi-workspace/workspace';\nimport { cn } from '../utils/utils';\nimport { usePhone } from '../utils/use-media';\nimport { readPersistedJson, writePersistedJson } from '../utils/persisted';\nexport { usePhone } from '../utils/use-media';\n\nexport { AgentNotifications, createNotificationStore, defaultNotificationLabels, frenchNotificationLabels, noticeTextOf, 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 /** Forward the viewer's actual loaded revision mount/unmount report. Opening or rendering the panel is not evidence. */\n readonly onShown: (shown: ArtifactShown) => void;\n /** Forward a viewer load/render failure; the waiting present is refused. */\n readonly onFailed: (failed: ArtifactFailed) => void;\n}\n\nexport interface AmbientChatProps extends Omit<ChatFeatureProps, 'artifacts'> {\n readonly controller: NativeChatController;\n /** Host recovery content, shared with PiChat in expanded and compact connection notices. */\n readonly renderConnectionError?: ((error: unknown) => ReactNode) | undefined;\n /** Artifact cards (`ArtifactsConfig` of `pi-artifacts`); the ambient bar plugs in their card itself. */\n readonly artifacts?: ArtifactsConfig | undefined;\n /** Embed the expanded conversation in a host workspace. Switching this flag changes layout without remounting the transcript or composer. The host supplies the header. */\n readonly embedded?: boolean;\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 /** Host-owned header controls, such as icon-anchored popovers, before the actions and Dock button. */\n readonly headerTools?: ReactNode;\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 /** Host content under the composer (for example a collapsed list of the person's requests). Absent: nothing is shown. */\n readonly belowComposer?: ReactNode;\n /** Two-row reference composer by default; older layouts require an explicit choice. */\n readonly composerLayout?: ComposerProps['layout'];\n /** Host-shared composer presentation, also accepted by PiChat. */\n readonly renderComposer?: (props: ComposerProps) => ReactNode;\n readonly sendContext?: import('@hachej/boring-ui-kit/chat-react').ChatSessionFeedback;\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 readPosition(key: string, persist: 'session' | 'local'): Position | undefined {\n try {\n const value: unknown = readPersistedJson(key, persist);\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 writePersistedJson(key, position, persist);\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 }; };\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 // eslint-disable-next-line react-hooks/set-state-in-effect -- the count restarts at 0 whenever `active` flips; the interval below owns it after that\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/** A compact floating agent bar over an existing page; it grows into a chat window in place. Borrows a concrete native controller like `PiChat`. */\n/** The ambient bar comes with every composer feature item (`withChatPlugins`); a component the host passes is kept. */\nexport function AmbientChat(given: AmbientChatProps) {\n const props = withChatPlugins(given);\n // eslint-disable-next-line react-hooks/refs -- the session's async callbacks compare against the controller of the latest render; assigned in render so a child's effects never see the previous one\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 if (props.embedded && local !== 'expanded') setLocal('expanded');\n const internalStore = useMemo(() => createNotificationStore(), []);\n const text = useMergedText<AmbientLabels, AmbientIcons>(props.labels, props.icons, AMBIENT_TEXT);\n return <ChatTextProvider value={text}><MessageScrollerProvider key={mount.sequence} autoScroll><AmbientSession {...props} labels={text.labels} icons={text.icons} activeController={active} windowState={props.embedded ? 'expanded' : props.state ?? local} store={props.notifications || internalStore}\n onWindowState={next => { if (props.state === undefined) setLocal(next); props.onStateChange?.(next); }} /></MessageScrollerProvider></ChatTextProvider>;\n}\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, embedded = false, labels, icons, avatar, headerActions, headerTools, messageActions, variant = 'contrast', mode = 'expert', presentation, voiceReplay, targetLabel, actions,\n windowState, onWindowState, store, storageKey = 'boring.ambient.position', storage = 'session', tools, belowComposer, composerChips, composerLayout = 'card', renderComposer, sendContext, onOpenFull, onFeedback, notifications, autoDismissMs, maxToasts, systemNotifications = false,\n renderConnectionError, renderEntry, hideLine, displayLimit, paging, historicalMessageActions, unread: replyUnread, replyError, renderTool, toolRenderers, renderText, cards, groupTool, commandMentions, onOpenImage, onCopy, onComposerKeyDown, fileAccept = 'image/*', slash, mentions, attachments, onOpenFile, fileChanges, pasteToFile, model, effort, replyFooter, artifacts: hostArtifacts, artifactPanel, artifactTarget, conversations, onDock, feedback: composerFeedback, className,\n activeController }: SessionProps) {\n const { title } = labels;\n useChatPlacement(controller, embedded ? 'docked' : 'floating');\n const stateRef = useRef(windowState);\n useLayoutEffect(() => { 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: PresentedArtifact; 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 // eslint-disable-next-line react-hooks/set-state-in-effect -- the browser's permission is read once after mount, so the server render and hydration agree on '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 opacity-0 transition-opacity group-hover/message:opacity-100 group-focus-within/message:opacity-100 max-md:opacity-100 pointer-coarse:opacity-100 motion-reduce:transition-none\">\n <CopyButton text={reply.text} label={labels.copyResponse} iconOnly {...(onCopy ? { onCopy } : {})} />\n {onFeedback && <>\n {(['up', 'down'] as const).map(value => <Toggle key={value} data-testid={`feedback-${value}`} pressed={feedback[reply.key] === value} aria-label={value === 'up' ? labels.goodResponse : labels.badResponse}\n title={value === 'up' ? labels.goodResponse : labels.badResponse}\n onPressedChange={pressed => { const next = pressed ? value : null; setFeedback(current => { const { [reply.key]: _removed, ...rest } = current; return next ? { ...rest, [reply.key]: next } : rest; }); onFeedback({ key: reply.key, text: reply.text }, next); }}>\n {value === 'up' ? <icons.goodResponse data-icon=\"inline-start\" aria-hidden=\"true\" /> : <icons.badResponse data-icon=\"inline-start\" aria-hidden=\"true\" />}</Toggle>)}\n </>}\n {onOpenFull && <Button variant=\"ghost\" size=\"icon\" data-testid=\"open-full\" aria-label={labels.openFull} title={labels.openFull} onClick={() => onOpenFull(conversationRef.current)}><icons.openFull data-icon=\"inline-start\" 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 text-muted-foreground\">{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 follow = useArtifactFollow({ controller, conversationId: String(conversations?.activeId ?? controller.getSnapshot().conversationId), detect,\n active: opened, onOpen: setOpened, autoOpen: false, narrow: () => phone,\n showing: path => !inWindow || (expanded && opened?.descriptor.type !== 'task' && opened?.descriptor.target.resource.path === path),\n showingTask: subject => !inWindow || (expanded && opened?.descriptor.type === 'task' && taskSubjectKey(opened.descriptor.subject) === taskSubjectKey(subject)),\n openPresent: () => false, answer: actions?.answer });\n const { onShown, onFailed, open: openArtifact, isOpen: isArtifactOpen } = follow;\n const pendingTasks = useChatView(controller, pendingPresents);\n const requestedTasks = useRef(new WeakMap<NativeChatController, Set<string>>());\n useEffect(() => {\n if (inWindow || !hostArtifacts?.autoOpenTasks) return;\n let requested = requestedTasks.current.get(controller);\n if (!requested) { requested = new Set(); requestedTasks.current.set(controller, requested); }\n for (const call of pendingTasks) {\n if (!('subject' in call) || requested.has(call.id)) continue;\n requested.add(call.id);\n hostArtifacts.open(createTaskArtifact(call.subject), { onShown, onFailed });\n }\n }, [controller, inWindow, hostArtifacts, pendingTasks, onShown, onFailed]);\n const closeArtifact = () => { follow.recordClosed(); setOpened(null); setFullscreen(false); };\n const artifacts = useMemo<ArtifactsConfig | undefined>(() => !inWindow ? hostArtifacts && { ...hostArtifacts,\n open: artifact => hostArtifacts.open(artifact, { onShown, onFailed }) } : {\n open: artifact => artifact.type === 'task' ? hostArtifacts?.open(artifact, { onShown, onFailed }) : openArtifact(artifact), isOpen: isArtifactOpen,\n ...(detect ? { detect } : {}),\n ...(hostArtifacts?.kinds ? { kinds: hostArtifacts.kinds } : {}),\n recognise: hostArtifacts?.recognise ?? artifactCards.recognise, Card: hostArtifacts?.Card ?? artifactCards.Card,\n }, [inWindow, hostArtifacts, openArtifact, isArtifactOpen, onShown, onFailed, detect]);\n const session = useChatSession({ controller, activeController, mode, presentation, voiceReplay, targetLabel, actions, renderEntry, hideLine, historicalMessageActions, unread: replyUnread, replyError, renderTool, toolRenderers, renderText, cards, groupTool, commandMentions, onOpenImage, onCopy, onComposerKeyDown, fileAccept,\n slash, mentions, attachments, model, effort, artifacts, messageActions, replyFooter, afterSend: () => scrollToBottom.current(), rowExtras, feedback: composerFeedback, sendContext, onOpenFile, fileChanges, pasteToFile, composerChips });\n const { state, derived, rows, queued, queueActions, working, waitingForAnswer, connected, error, act, textarea, rowContext, composer, files, loading } = session;\n const empty = session.empty;\n const transcript = useTranscript(rows, displayLimit ?? paging?.first ?? PAGE_FIRST, paging?.more ?? PAGE_MORE, { state: state.history, connected: !state.disposed && connected, load: controller.loadEarlier }, displayLimit === undefined);\n const { stick: { scrollRef, isAtBottom, scrollToBottom: stickToBottom }, hidden } = transcript;\n const chunks = useRowChunks(useMemo(() => hidden ? rows.slice(hidden) : rows, [rows, hidden]));\n const known = follow.known, shownArtifact = follow.shown;\n const { reset } = transcript;\n useLayoutEffect(() => { scrollToBottom.current = () => { reset(); void stickToBottom('instant'); }; });\n const { barStart, barNote } = session.pickers();\n\n const conversationId = String(conversations?.activeId ?? state.conversationId);\n useLayoutEffect(() => { 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 useRunNotifications(controller, store, { enabled: notifications !== false, conversationId, title: shownTitle, onOutcome: setOutcome, text: noticeTextOf(labels),\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 // eslint-disable-next-line react-hooks/exhaustive-deps -- measures after every commit on purpose; both setters return the current value when nothing changed, so it settles\n useLayoutEffect(() => {\n const element = root.current;\n const fresh = viewportSize();\n // eslint-disable-next-line react-hooks/set-state-in-effect -- layout reads (viewport, own size) exist only after commit and must land before paint\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 variant=\"ghost\" size=\"icon\" data-testid=\"ambient-grip\" aria-label={labels.move} title={labels.moveHint} onPointerDown={onGripDown} onKeyDown={onGripKey}\n className={cn('touch-none', className)}><icons.grip data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>;\n\n const expand = () => { setOutcome(null); store.dismissConversation(conversationId); setWindowState('expanded'); };\n const latestExpand = useRef(expand);\n useLayoutEffect(() => { latestExpand.current = expand; });\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 (embedded || 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 closeArtifact();\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 latestExpand.current();\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 // The floating window is drawn by the host's Card behind it (a sibling layer, so switching placement never remounts the chat);\n // embedded and full-screen phone windows are plain page regions.\n const card = !embedded && !(phone && expanded);\n const surface = embedded ? 'relative flex min-h-0 flex-1 flex-col overflow-clip bg-transparent text-foreground' : cn('relative flex flex-col', card ? 'isolate' : 'h-dvh bg-background pt-[env(safe-area-inset-top)] text-foreground',\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 = embedded ? cn('pi-chat relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden', className) : 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 || embedded ? 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 max-w-[min(20rem,calc(100vw-1rem))] items-center gap-0.5', !phone && 'cursor-grab touch-none select-none active:cursor-grabbing')}>\n {!phone && grip('ml-1.5')}\n <Button ref={pill} variant=\"outline\" size=\"lg\" data-testid=\"ambient-pill\" onClick={() => setWindowState('bar')} aria-label={labels.openAgent(shownTitle, unread)}\n className=\"relative min-w-0 gap-2.5\">\n {status === 'working' ? <Loader2Icon data-icon=\"inline-start\" className=\"animate-spin motion-reduce:animate-none\" aria-hidden=\"true\" />\n : status === 'input' ? <MessageCircleQuestionIcon data-icon=\"inline-start\" aria-hidden=\"true\" />\n : status === 'done' ? <CircleCheckIcon data-icon=\"inline-start\" aria-hidden=\"true\" />\n : status === 'error' ? <CircleAlertIcon data-icon=\"inline-start\" aria-hidden=\"true\" /> : null}\n <span className=\"min-w-0 truncate\">{shownTitle}</span>\n {unread && <Badge data-testid=\"ambient-unread\" aria-hidden=\"true\" className=\"absolute -top-1.5 -right-1.5\" />}\n </Button>\n </div>\n </div>;\n\n const conversation = <section className=\"flex min-h-0 min-w-0 flex-1 flex-col\">\n <header hidden={embedded} data-testid=\"ambient-header\" className={cn(embedded && '!hidden','flex h-12 shrink-0 items-center gap-0.5 px-2 max-md:h-14 max-md:px-3')}>\n <Button variant=\"ghost\" size=\"icon\" data-testid=\"ambient-minimize\" aria-label={labels.minimize} title={labels.minimize} onClick={() => { setPicking(false); setWindowState(expanded ? 'bar' : 'minimized'); }}><icons.minimize data-icon=\"inline-start\" 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 && <Avatar size=\"sm\" data-testid=\"ambient-avatar\" className=\"overflow-hidden\"><AvatarFallback className=\"*:max-h-full *:max-w-full\">{avatar}</AvatarFallback></Avatar>}\n <Button ref={titleButton} variant=\"ghost\" data-testid=\"ambient-title\" onClick={expand} disabled={expanded} aria-label={expanded ? shownTitle : labels.openChat(shownTitle)}\n className=\"min-w-0 max-w-full shrink\"><span className=\"truncate\">{shownTitle}</span></Button>\n {conversations && <Button variant=\"ghost\" size=\"icon\" data-testid=\"ambient-switch\" aria-label={labels.switchConversation} aria-haspopup=\"dialog\" aria-expanded={picking} title={labels.switchConversation} onClick={() => setPicking(open => !open)}>\n <icons.switchConversation data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>}\n {expanded ? <span className=\"flex-1\" /> : <Button variant=\"ghost\" tabIndex={-1} aria-hidden=\"true\" data-testid=\"ambient-header-fill\" onClick={expand} className=\"min-w-2 flex-1\" />}\n {systemNotifications && permission === 'default' && <Button variant=\"ghost\" size=\"icon\" data-testid=\"ambient-enable-notifications\" aria-label={labels.enableNotifications} title={labels.enableNotifications}\n onClick={() => { void requestNotifyPermission().then(setPermission); }}><icons.notify data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>}\n {headerTools}\n <BlockActions actions={headerActions} testId=\"ambient-action\" menuLabel={labels.moreActions} />\n {onDock && !phone && <Button variant=\"ghost\" size=\"icon\" data-testid=\"ambient-dock\" aria-label={labels.dock} title={labels.dockHint} onClick={() => { setPicking(false); onDock(); }}><icons.dock data-icon=\"inline-start\" 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\"><ChatConnectionNotice state={state} renderConnectionError={renderConnectionError} onReconnect={() => act(controller.connect)} /></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 <MessageScroller>\n <MessageScrollerViewport ref={scrollRef} data-testid=\"transcript-scroll\" aria-label={labels.messages} className=\"scrollbar-thumb-border scrollbar-track-transparent\">\n {!empty && <TranscriptHistory history={state.history} connected={!state.disposed && connected} onLoad={transcript.reveal} sentinelRef={transcript.sentinelRef} hidden={hidden} loaded={transcript.loaded} />}\n <div className=\"px-4 pt-4 pb-6\">\n <MessageScrollerContent aria-label={labels.messages} aria-live=\"polite\" data-testid=\"transcript\">\n {chunks.map(run => <TranscriptChunk key={run[0]!.key} rows={run} 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 max-w-sm py-8 text-sm leading-6 text-muted-foreground\">{labels.empty}</p>}\n </MessageScrollerContent>\n </div>\n </MessageScrollerViewport>\n {!isAtBottom && <MessageScrollerButton data-testid=\"jump-latest\" aria-label={labels.jumpToLatest} title={labels.jumpToLatest} onClick={transcript.reset}><icons.jumpToLatest aria-hidden=\"true\" /></MessageScrollerButton>}\n </MessageScroller>\n </div>\n </div>}\n\n {(expanded || !embedded) && <Separator />}\n <div className={cn('shrink-0', phone && 'pb-[env(safe-area-inset-bottom)]')}>\n <div className=\"space-y-2 px-3 empty:hidden [&:has(*)]:pt-2\">\n <ChatStatusLine natural={session.natural} working={working} waitingForAnswer={waitingForAnswer} derived={derived} stop={state.stop} sending={session.sending} />\n <ChatNotices natural={session.natural} state={state} error={error} onReconcile={() => act(controller.reconcile)} onRetry={() => act(controller.retrySameRequest)} />\n {!expanded && <ChatConnectionNotice compact fallback={{ error: labels.loadFailedShort, closed: labels.disconnectedShort }} state={state} renderConnectionError={renderConnectionError} onReconnect={() => act(controller.connect)} />}\n </div>\n <div className=\"px-3 pt-2 pb-3\">\n <ConversationFiles files={files} />\n <MessageQueue items={queued} sending={state.outbox} withdraw={actions?.withdraw} actions={queueActions} />\n {(renderComposer ?? (value => <Composer {...value} />))({ ...composer, layout: composerLayout, placeholder: working || waitingForAnswer ? label : labels.placeholder, barStart, barNote, barEnd: tools })}\n {belowComposer !== undefined && <div data-testid=\"below-composer\" className=\"pt-2\">{belowComposer}</div>}\n </div>\n </div>\n </section>;\n return <div ref={root} data-boring={embedded ? 'pi-chat' : '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 {!embedded && 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 {card && <Card aria-hidden=\"true\" data-boring=\"chrome\" className=\"pointer-events-none absolute inset-0 -z-10\" />}\n {artifactPanel ? <ArtifactWorkspace open={panelOpen} onClose={closeArtifact} fullscreen={fullscreen} onFullscreenChange={setFullscreen} storageKey=\"boring.ambient.panel-width\" panelLabel={labels.artifactPanel}\n defaultWidth={600} minPanel={320} minChat={300}\n panel={api => shownArtifact && shownArtifact.type !== 'task' ? artifactPanel(shownArtifact, { ...api, versions: known.filter((artifact): artifact is ArtifactDescriptor => artifact.type !== 'task'), follow: opened?.follow ?? true,\n select: follow.select, onShown: follow.onShown, onFailed: follow.onFailed }) : null}\n chat={conversation} /> : conversation}\n </div>\n </div>;\n}\n",
27
27
  "type": "registry:component",
28
28
  "target": "components/pi-ambient/ambient.tsx"
29
29
  },
@@ -4,16 +4,16 @@
4
4
  "title": "Pi agent app default kinds",
5
5
  "description": "defaultKinds({ locale, comments }): the artifact kinds the pi-app item draws with by default, in order: maquette, diagram, mock-up and records, then Markdown, HTML, SVG, CSV, image and PDF (documentKinds). Pass it as AgentWorkspace's artifactKinds (or ViewerOptions.kinds), with an app's own kinds before it. Each kind loads its viewer on first open.",
6
6
  "registryDependencies": [
7
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.45/r/pi-app.json",
8
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.45/r/viewer-markdown.json",
9
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.45/r/viewer-html.json",
10
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.45/r/viewer-image.json",
11
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.45/r/viewer-pdf.json",
12
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.45/r/viewer-code.json",
13
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.45/r/maquette-viewer.json",
14
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.45/r/diagram-viewer.json",
15
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.45/r/mockup-viewer.json",
16
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.45/r/record-viewer.json"
7
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.47/r/pi-app.json",
8
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.47/r/viewer-markdown.json",
9
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.47/r/viewer-html.json",
10
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.47/r/viewer-image.json",
11
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.47/r/viewer-pdf.json",
12
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.47/r/viewer-code.json",
13
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.47/r/maquette-viewer.json",
14
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.47/r/diagram-viewer.json",
15
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.47/r/mockup-viewer.json",
16
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.47/r/record-viewer.json"
17
17
  ],
18
18
  "files": [
19
19
  {