@hachej/boring-ui-kit 0.4.24 → 0.4.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/README.md +26 -2
  2. package/bin/boring-ui-blocks.mjs +50 -13
  3. package/dist/chat-artifacts.d.ts +157 -0
  4. package/dist/chat-artifacts.js +274 -0
  5. package/dist/chat-react-controller.d.ts +25 -0
  6. package/dist/chat-react-controller.js +72 -0
  7. package/dist/chat-react-conversations.d.ts +40 -0
  8. package/dist/chat-react-conversations.js +68 -0
  9. package/dist/chat-react-draft.d.ts +11 -0
  10. package/dist/chat-react-draft.js +91 -0
  11. package/dist/chat-react-remote.d.ts +33 -0
  12. package/dist/chat-react-remote.js +72 -0
  13. package/dist/chat-react-transcript.d.ts +39 -0
  14. package/dist/chat-react-transcript.js +123 -0
  15. package/dist/chat-react.d.ts +60 -0
  16. package/dist/chat-react.js +94 -0
  17. package/dist/chat-text.d.ts +122 -0
  18. package/dist/chat-text.js +216 -0
  19. package/dist/chat-view.d.ts +2 -17
  20. package/dist/chat-view.js +1 -0
  21. package/dist/file-tree.js +5 -0
  22. package/dist/run-notifications.d.ts +42 -0
  23. package/dist/run-notifications.js +67 -0
  24. package/dist/shared-chat/index.d.ts +1 -1
  25. package/dist/shared-chat/index.js +5 -4
  26. package/dist/voice-capture.d.ts +12 -0
  27. package/dist/voice-capture.js +17 -2
  28. package/package.json +10 -2
  29. package/r/agent-target.json +5 -9
  30. package/r/app-shell.json +16 -25
  31. package/r/board-viewer.json +7 -11
  32. package/r/button.json +2 -9
  33. package/r/diagram-viewer.json +0 -5
  34. package/r/dock.json +2 -2
  35. package/r/documents.json +14 -19
  36. package/r/feedback-preview.json +1 -7
  37. package/r/feedback.json +12 -19
  38. package/r/file-tree.json +4 -14
  39. package/r/highlights.json +4 -9
  40. package/r/html-viewer.json +3 -3
  41. package/r/looks-viewer.json +2 -6
  42. package/r/maquette-viewer.json +2 -20
  43. package/r/markdown-editor.json +4 -7
  44. package/r/mockup-viewer.json +2 -5
  45. package/r/pi-ambient.json +11 -19
  46. package/r/pi-app-kinds.json +10 -10
  47. package/r/pi-app.json +16 -26
  48. package/r/pi-artifacts.json +3 -4
  49. package/r/pi-chat-approval.json +3 -5
  50. package/r/pi-chat-attachments.json +2 -4
  51. package/r/pi-chat-markdown.json +3 -5
  52. package/r/pi-chat-mentions.json +2 -3
  53. package/r/pi-chat-pickers.json +2 -4
  54. package/r/pi-chat-plugins.json +11 -11
  55. package/r/pi-chat-question.json +1 -7
  56. package/r/pi-chat-slash.json +3 -6
  57. package/r/pi-chat-subagent.json +3 -8
  58. package/r/pi-chat-voice.json +6 -9
  59. package/r/pi-chat.json +17 -45
  60. package/r/pi-workspace.json +4 -7
  61. package/r/placed-agent.json +4 -5
  62. package/r/provider-setup.json +2 -8
  63. package/r/record-viewer.json +0 -9
  64. package/r/registry.json +163 -395
  65. package/r/requests-board.json +2 -10
  66. package/r/shared-chat.json +9 -23
  67. package/r/task-list-viewer.json +3 -9
  68. package/r/version-history.json +3 -5
  69. package/r/viewer-code.json +2 -3
  70. package/r/viewer-frame.json +7 -15
  71. package/r/viewer-html.json +4 -5
  72. package/r/viewer-image.json +3 -4
  73. package/r/viewer-markdown.json +6 -8
  74. package/r/viewer-pdf.json +5 -7
  75. package/r/viewers.json +6 -6
  76. package/r/windows.json +2 -7
@@ -7,20 +7,12 @@
7
7
  "react@^19.0.0"
8
8
  ],
9
9
  "registryDependencies": [
10
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.24/r/utils.json",
11
- "badge",
12
- "button",
13
- "input",
14
- "toggle-group",
15
- "item",
16
- "empty",
17
- "textarea",
18
- "card"
10
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.26/r/utils.json"
19
11
  ],
20
12
  "files": [
21
13
  {
22
14
  "path": "registry/requests-board/requests-board.tsx",
23
- "content": "'use client';\n\nimport { useEffect, useRef, useState } from 'react';\nimport type { KeyboardEvent } from 'react';\nimport { Badge } from '@/components/ui/badge';\nimport { Button } from '@/components/ui/button';\nimport { Input } from '@/components/ui/input';\nimport { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';\nimport { Item, ItemContent, ItemDescription, ItemGroup, ItemTitle } from '@/components/ui/item';\nimport { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from '@/components/ui/empty';\nimport { Textarea } from '@/components/ui/textarea';\nimport { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';\nimport { cn } from '../utils/utils';\nimport { compactRows, filterViews, requestCounts, requestFilters } from './requests';\nimport type { RequestAction, RequestFilter, RequestView } from './requests';\nimport { requestsLabelsFor } from './requests-labels';\nimport type { RequestsLabels, RequestsLocale } from './requests-labels';\n\nexport interface RequestsBoardProps {\n readonly views: readonly RequestView[];\n readonly mode: 'compact' | 'full';\n readonly repos?: readonly string[];\n readonly labels?: Partial<RequestsLabels>;\n readonly locale?: RequestsLocale;\n readonly changeToken?: string | number;\n /** Presentation only: the host must authorize every decision again. */\n readonly canApproveRelease?: boolean;\n readonly onOpenAll: () => void;\n readonly onOpen: (number: string) => void;\n readonly onAnswer?: (number: string, answer: string) => void;\n readonly onDecide?: (number: string, decision: 'approve' | 'later') => void;\n readonly onTry?: (number: string, action: RequestAction) => void;\n}\n\n/** Host presentation concern: no Pi execution, network, polling or subscription. Only local navigation and drafts live here. */\nexport function RequestsBoard(props: RequestsBoardProps) {\n const { views, mode, changeToken, onOpen, onOpenAll } = props;\n const labels = requestsLabelsFor(props.locale, props.labels);\n const [filter, setFilter] = useState<RequestFilter>('active');\n const [query, setQuery] = useState('');\n const [repo, setRepo] = useState<string>();\n const [selected, setSelected] = useState<string>();\n const [announcement, setAnnouncement] = useState('');\n const observed = useRef({ token: changeToken, views: views.map(view => ({ number: view.number, state: view.state, line: view.line, since: view.since })) });\n const rows = useRef(new Map<string, HTMLButtonElement>());\n const heading = useRef<HTMLHeadingElement>(null);\n const returning = useRef<string | undefined>(undefined);\n const detail = views.find(view => view.number === selected);\n const shown = mode === 'compact' ? compactRows(views) : filterViews(views, filter, query, repo);\n const counts = requestCounts(views, query, repo);\n useEffect(() => {\n if (Object.is(observed.current.token, changeToken)) return;\n const previous = new Map(observed.current.views.map(view => [view.number, view]));\n const moved = views.filter(view => {\n const before = previous.get(view.number);\n return !before || before.state !== view.state || before.line !== view.line || before.since !== view.since;\n });\n setAnnouncement(moved.map(view => labels.changed(view.number, labels.states[view.state])).join('; '));\n observed.current = { token: changeToken, views: views.map(view => ({ number: view.number, state: view.state, line: view.line, since: view.since })) };\n }, [changeToken, views, labels]);\n useEffect(() => {\n if (detail) heading.current?.focus();\n else if (returning.current !== undefined) {\n (rows.current.get(returning.current) ?? rows.current.values().next().value)?.focus();\n returning.current = undefined;\n }\n }, [detail?.number]);\n const open = (number: string) => { if (mode === 'full') setSelected(number); onOpen(number); };\n const back = () => { returning.current = selected; setSelected(undefined); };\n const navigate = (event: KeyboardEvent<HTMLButtonElement>, index: number) => {\n const next = event.key === 'ArrowDown' ? Math.min(shown.length - 1, index + 1) : event.key === 'ArrowUp' ? Math.max(0, index - 1) : event.key === 'Home' ? 0 : event.key === 'End' ? shown.length - 1 : undefined;\n if (next === undefined) return;\n event.preventDefault(); rows.current.get(shown[next]!.number)?.focus();\n };\n const list = <ItemGroup aria-label={labels.title} data-testid=\"requests-list\">\n {shown.map((view, index) => <Item key={view.number} role=\"listitem\" variant={selected === view.number ? 'muted' : 'default'}>\n <ItemContent className=\"min-w-0\">\n <Button ref={node => { if (node) rows.current.set(view.number, node); else rows.current.delete(view.number); }} variant=\"ghost\" className=\"w-full justify-start whitespace-normal text-start\"\n data-request={view.number} aria-current={selected === view.number ? 'true' : undefined} onKeyDown={event => navigate(event, index)} onClick={() => open(view.number)}>\n <span>{view.number} · {view.title}</span>\n </Button>\n </ItemContent>\n <Badge variant=\"secondary\">{labels.states[view.state]}</Badge>\n </Item>)}\n {shown.length === 0 && <Empty><EmptyHeader><EmptyTitle>{labels.empty}</EmptyTitle><EmptyDescription>{labels.emptyDescription}</EmptyDescription></EmptyHeader></Empty>}\n </ItemGroup>;\n return <section data-boring=\"requests-board\" data-mode={mode} className=\"flex h-full min-h-0 min-w-0 flex-col\" aria-label={labels.title}>\n <div role=\"status\" aria-live=\"polite\" aria-atomic=\"true\" className=\"sr-only\" data-testid=\"requests-updates\">{announcement}</div>\n {mode === 'compact' ? <>{list}<Button variant=\"link\" onClick={onOpenAll}>{labels.openAll(views.length)}</Button></> : <>\n <div className={cn('min-w-0 flex-col gap-3 p-4', detail ? 'hidden md:flex' : 'flex')}>\n <div className=\"overflow-x-auto\"><ToggleGroup value={[filter]} onValueChange={value => { if (value[0]) setFilter(value[0] as RequestFilter); }} aria-label={labels.title} variant=\"outline\">\n {requestFilters.map(value => <ToggleGroupItem key={value} value={value} data-filter={value}>{labels.filters[value]} · {counts[value]}</ToggleGroupItem>)}\n </ToggleGroup></div>\n <Input aria-label={labels.search} placeholder={labels.search} value={query} onChange={event => setQuery(event.currentTarget.value)} />\n {!!props.repos?.length && <div className=\"overflow-x-auto\"><ToggleGroup variant=\"outline\" aria-label={labels.repositories} value={[repo === undefined ? 'all' : `repo:${repo}`]} onValueChange={value => setRepo(value[0]?.startsWith('repo:') ? value[0].slice(5) : undefined)}>\n <ToggleGroupItem value=\"all\">{labels.allRepos}</ToggleGroupItem>\n {props.repos.map(value => <ToggleGroupItem value={`repo:${value}`} key={value}>{value}</ToggleGroupItem>)}\n </ToggleGroup></div>}\n </div>\n <div className=\"flex min-h-0 min-w-0 flex-1\">\n <div data-testid=\"requests-list-pane\" className={cn('min-h-0 w-full shrink-0 overflow-y-auto overscroll-contain md:w-80', detail ? 'hidden md:block' : 'block')}>{list}</div>\n <div data-testid=\"requests-detail-pane\" className={cn('min-h-0 min-w-0 flex-1 overflow-y-auto overscroll-contain p-4', detail ? 'block' : 'hidden md:block')}>\n {detail ? <>\n <div className=\"mb-4 md:hidden\"><Button variant=\"ghost\" onClick={back}>{labels.back}</Button></div>\n <h2 ref={heading} tabIndex={-1} className=\"m-0 mb-4 break-words\">{detail.number} · {detail.title}</h2>\n <RequestDetail key={detail.number} view={detail} labels={labels} canApproveRelease={props.canApproveRelease ?? false} onOpen={open} onAnswer={props.onAnswer} onDecide={props.onDecide} onTry={props.onTry} />\n </> : <Empty><EmptyHeader><EmptyTitle>{labels.choose}</EmptyTitle></EmptyHeader></Empty>}\n </div>\n </div>\n </>}\n </section>;\n}\n\nfunction RequestDetail({ view, labels, canApproveRelease, onOpen, onAnswer, onDecide, onTry }: {\n readonly view: RequestView; readonly labels: RequestsLabels; readonly canApproveRelease: boolean;\n readonly onOpen: (number: string) => void;\n readonly onAnswer: RequestsBoardProps['onAnswer']; readonly onDecide: RequestsBoardProps['onDecide']; readonly onTry: RequestsBoardProps['onTry'];\n}) {\n const question = view.question && (view.question.kind !== 'release' || canApproveRelease) ? view.question : undefined;\n return <div className=\"flex min-w-0 flex-col gap-4\">\n <Item variant=\"outline\"><ItemContent className=\"min-w-0\"><ItemTitle><Badge variant=\"secondary\">{labels.states[view.state]}</Badge></ItemTitle>\n <ItemDescription>{view.line}</ItemDescription><ItemDescription>{labels.since} <time dateTime={view.since}>{labels.formatDate(view.since)}</time>{view.waitsOn && ` · ${labels.waitsOn} ${view.waitsOn}`}</ItemDescription>\n {view.repo && !view.line.includes(view.repo) && <ItemDescription>{view.repo}</ItemDescription>}\n {view.master && <ItemDescription>{labels.master} · {view.master}</ItemDescription>}\n </ItemContent></Item>\n {view.action && <div><Button disabled={!onTry} onClick={() => { if (view.action) onTry?.(view.number, view.action); }}>{labels.tryVersion}</Button></div>}\n {question && <Question key={`${question.kind}:${question.text}`} view={view} labels={labels} onAnswer={onAnswer} onDecide={onDecide} />}\n <Card><CardHeader><CardTitle>{labels.history}</CardTitle></CardHeader><CardContent>\n <ItemGroup>{view.history.map((event, index) => <Item key={index} role=\"listitem\"><ItemContent><ItemTitle>{event.text}</ItemTitle><ItemDescription><time dateTime={event.at}>{labels.formatDate(event.at)}</time></ItemDescription></ItemContent></Item>)}</ItemGroup>\n </CardContent></Card>\n {view.links.length > 0 && <Card><CardHeader><CardTitle>{labels.related}</CardTitle></CardHeader><CardContent className=\"flex flex-wrap gap-2\">\n {view.links.map(link => <Button variant=\"link\" key={`${link.kind}:${link.number}`} onClick={() => onOpen(link.number)}>{labels.links[link.kind]} · {link.number}</Button>)}\n </CardContent></Card>}\n </div>;\n}\nfunction Question({ view, labels, onAnswer, onDecide }: { readonly view: RequestView; readonly labels: RequestsLabels; readonly onAnswer: RequestsBoardProps['onAnswer']; readonly onDecide: RequestsBoardProps['onDecide'] }) {\n const [text, setText] = useState('');\n return <Card data-testid=\"request-question\"><CardHeader><CardDescription>{labels.states.question}</CardDescription><CardTitle>{view.question!.text}</CardTitle></CardHeader>\n <CardContent className=\"flex flex-col gap-3\">\n {view.question!.kind === 'answer' ? <form className=\"flex flex-col gap-3\" onSubmit={event => { event.preventDefault(); if (text.trim()) onAnswer?.(view.number, text.trim()); }}>\n <Textarea aria-label={labels.yourAnswer} value={text} disabled={!onAnswer} onChange={event => setText(event.currentTarget.value)} />\n <div><Button type=\"submit\" disabled={!onAnswer || !text.trim()}>{labels.answer}</Button></div>\n </form> : <div><Button disabled={!onDecide} onClick={() => onDecide?.(view.number, 'approve')}>{labels.approve}</Button></div>}\n <div><Button variant=\"outline\" disabled={!onDecide} onClick={() => onDecide?.(view.number, 'later')}>{labels.later}</Button></div>\n </CardContent>\n </Card>;\n}\n",
15
+ "content": "'use client';\n\nimport { useEffect, useRef, useState } from 'react';\nimport type { KeyboardEvent } from 'react';\nimport { Badge } from '@/components/ui/badge';\nimport { Button } from '@/components/ui/button';\nimport { Input } from '@/components/ui/input';\nimport { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';\nimport { Item, ItemContent, ItemDescription, ItemGroup, ItemTitle } from '@/components/ui/item';\nimport { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from '@/components/ui/empty';\nimport { Textarea } from '@/components/ui/textarea';\nimport { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';\nimport { cn } from '../utils/utils';\nimport { compactRows, filterViews, requestCounts, requestFilters } from './requests';\nimport type { RequestAction, RequestFilter, RequestView } from './requests';\nimport { requestsLabelsFor } from './requests-labels';\nimport type { RequestsLabels, RequestsLocale } from './requests-labels';\n\nexport interface RequestsBoardProps {\n readonly views: readonly RequestView[];\n readonly mode: 'compact' | 'full';\n readonly repos?: readonly string[];\n readonly labels?: Partial<RequestsLabels>;\n readonly locale?: RequestsLocale;\n readonly changeToken?: string | number;\n /** Presentation only: the host must authorize every decision again. */\n readonly canApproveRelease?: boolean;\n readonly onOpenAll: () => void;\n readonly onOpen: (number: string) => void;\n readonly onAnswer?: (number: string, answer: string) => void;\n readonly onDecide?: (number: string, decision: 'approve' | 'later') => void;\n readonly onTry?: (number: string, action: RequestAction) => void;\n}\n\n/** Host presentation concern: no Pi execution, network, polling or subscription. Only local navigation and drafts live here. */\nexport function RequestsBoard(props: RequestsBoardProps) {\n const { views, mode, changeToken, onOpen, onOpenAll } = props;\n const labels = requestsLabelsFor(props.locale, props.labels);\n const [filter, setFilter] = useState<RequestFilter>('active');\n const [query, setQuery] = useState('');\n const [repo, setRepo] = useState<string>();\n const [selected, setSelected] = useState<string>();\n const [announcement, setAnnouncement] = useState('');\n const observed = useRef({ token: changeToken, views: views.map(view => ({ number: view.number, state: view.state, line: view.line, since: view.since })) });\n const rows = useRef(new Map<string, HTMLButtonElement>());\n const heading = useRef<HTMLHeadingElement>(null);\n const returning = useRef<string | undefined>(undefined);\n const detail = views.find(view => view.number === selected);\n const shown = mode === 'compact' ? compactRows(views) : filterViews(views, filter, query, repo);\n const counts = requestCounts(views, query, repo);\n useEffect(() => {\n if (Object.is(observed.current.token, changeToken)) return;\n const previous = new Map(observed.current.views.map(view => [view.number, view]));\n const moved = views.filter(view => {\n const before = previous.get(view.number);\n return !before || before.state !== view.state || before.line !== view.line || before.since !== view.since;\n });\n setAnnouncement(moved.map(view => labels.changed(view.number, labels.states[view.state])).join('; '));\n observed.current = { token: changeToken, views: views.map(view => ({ number: view.number, state: view.state, line: view.line, since: view.since })) };\n }, [changeToken, views, labels]);\n const detailNumber = detail?.number;\n useEffect(() => {\n if (detailNumber !== undefined) heading.current?.focus();\n else if (returning.current !== undefined) {\n (rows.current.get(returning.current) ?? rows.current.values().next().value)?.focus();\n returning.current = undefined;\n }\n }, [detailNumber]);\n const open = (number: string) => { if (mode === 'full') setSelected(number); onOpen(number); };\n const back = () => { returning.current = selected; setSelected(undefined); };\n const navigate = (event: KeyboardEvent<HTMLButtonElement>, index: number) => {\n const next = event.key === 'ArrowDown' ? Math.min(shown.length - 1, index + 1) : event.key === 'ArrowUp' ? Math.max(0, index - 1) : event.key === 'Home' ? 0 : event.key === 'End' ? shown.length - 1 : undefined;\n if (next === undefined) return;\n event.preventDefault(); rows.current.get(shown[next]!.number)?.focus();\n };\n const list = <ItemGroup aria-label={labels.title} data-testid=\"requests-list\">\n {shown.map((view, index) => <Item key={view.number} role=\"listitem\" variant={selected === view.number ? 'muted' : 'default'}>\n <ItemContent className=\"min-w-0\">\n <Button ref={node => { if (node) rows.current.set(view.number, node); else rows.current.delete(view.number); }} variant=\"ghost\" className=\"w-full justify-start whitespace-normal text-start\"\n data-request={view.number} aria-current={selected === view.number ? 'true' : undefined} onKeyDown={event => navigate(event, index)} onClick={() => open(view.number)}>\n <span>{view.number} · {view.title}</span>\n </Button>\n </ItemContent>\n <Badge variant=\"secondary\">{labels.states[view.state]}</Badge>\n </Item>)}\n {shown.length === 0 && <Empty><EmptyHeader><EmptyTitle>{labels.empty}</EmptyTitle><EmptyDescription>{labels.emptyDescription}</EmptyDescription></EmptyHeader></Empty>}\n </ItemGroup>;\n return <section data-boring=\"requests-board\" data-mode={mode} className=\"flex h-full min-h-0 min-w-0 flex-col\" aria-label={labels.title}>\n <div role=\"status\" aria-live=\"polite\" aria-atomic=\"true\" className=\"sr-only\" data-testid=\"requests-updates\">{announcement}</div>\n {mode === 'compact' ? <>{list}<Button variant=\"link\" onClick={onOpenAll}>{labels.openAll(views.length)}</Button></> : <>\n <div className={cn('min-w-0 flex-col gap-3 p-4', detail ? 'hidden md:flex' : 'flex')}>\n <div className=\"overflow-x-auto\"><ToggleGroup value={[filter]} onValueChange={value => { if (value[0]) setFilter(value[0] as RequestFilter); }} aria-label={labels.title} variant=\"outline\">\n {requestFilters.map(value => <ToggleGroupItem key={value} value={value} data-filter={value}>{labels.filters[value]} · {counts[value]}</ToggleGroupItem>)}\n </ToggleGroup></div>\n <Input aria-label={labels.search} placeholder={labels.search} value={query} onChange={event => setQuery(event.currentTarget.value)} />\n {!!props.repos?.length && <div className=\"overflow-x-auto\"><ToggleGroup variant=\"outline\" aria-label={labels.repositories} value={[repo === undefined ? 'all' : `repo:${repo}`]} onValueChange={value => setRepo(value[0]?.startsWith('repo:') ? value[0].slice(5) : undefined)}>\n <ToggleGroupItem value=\"all\">{labels.allRepos}</ToggleGroupItem>\n {props.repos.map(value => <ToggleGroupItem value={`repo:${value}`} key={value}>{value}</ToggleGroupItem>)}\n </ToggleGroup></div>}\n </div>\n <div className=\"flex min-h-0 min-w-0 flex-1\">\n <div data-testid=\"requests-list-pane\" className={cn('min-h-0 w-full shrink-0 overflow-y-auto overscroll-contain md:w-80', detail ? 'hidden md:block' : 'block')}>{list}</div>\n <div data-testid=\"requests-detail-pane\" className={cn('min-h-0 min-w-0 flex-1 overflow-y-auto overscroll-contain p-4', detail ? 'block' : 'hidden md:block')}>\n {detail ? <>\n <div className=\"mb-4 md:hidden\"><Button variant=\"ghost\" onClick={back}>{labels.back}</Button></div>\n <h2 ref={heading} tabIndex={-1} className=\"m-0 mb-4 break-words\">{detail.number} · {detail.title}</h2>\n <RequestDetail key={detail.number} view={detail} labels={labels} canApproveRelease={props.canApproveRelease ?? false} onOpen={open} onAnswer={props.onAnswer} onDecide={props.onDecide} onTry={props.onTry} />\n </> : <Empty><EmptyHeader><EmptyTitle>{labels.choose}</EmptyTitle></EmptyHeader></Empty>}\n </div>\n </div>\n </>}\n </section>;\n}\n\nfunction RequestDetail({ view, labels, canApproveRelease, onOpen, onAnswer, onDecide, onTry }: {\n readonly view: RequestView; readonly labels: RequestsLabels; readonly canApproveRelease: boolean;\n readonly onOpen: (number: string) => void;\n readonly onAnswer: RequestsBoardProps['onAnswer']; readonly onDecide: RequestsBoardProps['onDecide']; readonly onTry: RequestsBoardProps['onTry'];\n}) {\n const question = view.question && (view.question.kind !== 'release' || canApproveRelease) ? view.question : undefined;\n return <div className=\"flex min-w-0 flex-col gap-4\">\n <Item variant=\"outline\"><ItemContent className=\"min-w-0\"><ItemTitle><Badge variant=\"secondary\">{labels.states[view.state]}</Badge></ItemTitle>\n <ItemDescription>{view.line}</ItemDescription><ItemDescription>{labels.since} <time dateTime={view.since}>{labels.formatDate(view.since)}</time>{view.waitsOn && ` · ${labels.waitsOn} ${view.waitsOn}`}</ItemDescription>\n {view.repo && !view.line.includes(view.repo) && <ItemDescription>{view.repo}</ItemDescription>}\n {view.master && <ItemDescription>{labels.master} · {view.master}</ItemDescription>}\n </ItemContent></Item>\n {view.action && <div><Button disabled={!onTry} onClick={() => { if (view.action) onTry?.(view.number, view.action); }}>{labels.tryVersion}</Button></div>}\n {question && <Question key={`${question.kind}:${question.text}`} view={view} labels={labels} onAnswer={onAnswer} onDecide={onDecide} />}\n <Card><CardHeader><CardTitle>{labels.history}</CardTitle></CardHeader><CardContent>\n <ItemGroup>{view.history.map((event, index) => <Item key={index} role=\"listitem\"><ItemContent><ItemTitle>{event.text}</ItemTitle><ItemDescription><time dateTime={event.at}>{labels.formatDate(event.at)}</time></ItemDescription></ItemContent></Item>)}</ItemGroup>\n </CardContent></Card>\n {view.links.length > 0 && <Card><CardHeader><CardTitle>{labels.related}</CardTitle></CardHeader><CardContent className=\"flex flex-wrap gap-2\">\n {view.links.map(link => <Button variant=\"link\" key={`${link.kind}:${link.number}`} onClick={() => onOpen(link.number)}>{labels.links[link.kind]} · {link.number}</Button>)}\n </CardContent></Card>}\n </div>;\n}\nfunction Question({ view, labels, onAnswer, onDecide }: { readonly view: RequestView; readonly labels: RequestsLabels; readonly onAnswer: RequestsBoardProps['onAnswer']; readonly onDecide: RequestsBoardProps['onDecide'] }) {\n const [text, setText] = useState('');\n return <Card data-testid=\"request-question\"><CardHeader><CardDescription>{labels.states.question}</CardDescription><CardTitle>{view.question!.text}</CardTitle></CardHeader>\n <CardContent className=\"flex flex-col gap-3\">\n {view.question!.kind === 'answer' ? <form className=\"flex flex-col gap-3\" onSubmit={event => { event.preventDefault(); if (text.trim()) onAnswer?.(view.number, text.trim()); }}>\n <Textarea aria-label={labels.yourAnswer} value={text} disabled={!onAnswer} onChange={event => setText(event.currentTarget.value)} />\n <div><Button type=\"submit\" disabled={!onAnswer || !text.trim()}>{labels.answer}</Button></div>\n </form> : <div><Button disabled={!onDecide} onClick={() => onDecide?.(view.number, 'approve')}>{labels.approve}</Button></div>}\n <div><Button variant=\"outline\" disabled={!onDecide} onClick={() => onDecide?.(view.number, 'later')}>{labels.later}</Button></div>\n </CardContent>\n </Card>;\n}\n",
24
16
  "type": "registry:component",
25
17
  "target": "components/requests-board/requests-board.tsx"
26
18
  },
@@ -4,30 +4,16 @@
4
4
  "title": "Shared chat",
5
5
  "description": "Optional multi-party host log presentation over PiChat's generic composition seams. No agent runtime or speaking policy.",
6
6
  "dependencies": [
7
- "@hachej/boring-ui-kit@^0.4.24",
7
+ "@hachej/boring-ui-kit@^0.4.26",
8
8
  "react@^19.0.0",
9
9
  "lucide-react@^1.51.0"
10
10
  ],
11
11
  "registryDependencies": [
12
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.24/r/pi-chat.json",
13
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.24/r/pi-chat-plugins.json",
14
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.24/r/pi-chat-markdown.json",
15
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.24/r/pi-chat-mentions.json",
16
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.24/r/theme.json",
17
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.24/r/utils.json",
18
- "avatar",
19
- "badge",
20
- "button",
21
- "drawer",
22
- "popover",
23
- "separator",
24
- "tooltip",
25
- "trace",
26
- "spinner",
27
- "item",
28
- "message",
29
- "bubble",
30
- "message-scroller"
12
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.26/r/pi-chat.json",
13
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.26/r/pi-chat-plugins.json",
14
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.26/r/pi-chat-markdown.json",
15
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.26/r/pi-chat-mentions.json",
16
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.26/r/utils.json"
31
17
  ],
32
18
  "files": [
33
19
  {
@@ -38,7 +24,7 @@
38
24
  },
39
25
  {
40
26
  "path": "registry/shared-chat/use-shared-chat.tsx",
41
- "content": "'use client';\nimport { useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';\nimport type { ReactNode } from 'react';\nimport { UserIcon } from 'lucide-react';\nimport { createSharedChat } from '@hachej/boring-ui-kit/shared-chat';\nimport type { ChannelSource, Conversation, SharedChatController } from '@hachej/boring-ui-kit/shared-chat';\nimport type { PiChatProps } from '../pi-chat/pi-chat';\nimport type { ConversationsConfig, MentionsConfig } from '../pi-chat/config';\nimport { MentionPicker } from '../pi-chat-mentions/mention-menu';\nimport { Badge } from '@/components/ui/badge';\nimport { Button } from '@/components/ui/button';\nimport { ItemContent, ItemDescription, ItemMedia, ItemTitle } from '@/components/ui/item';\nimport { namedMember } from './names';\nimport { MemberAvatar, SharedMembers, ReplyQuote, sharedQuote } from './messages';\nimport { SharedTimeline } from './timeline';\nimport type { TimelineApi } from './timeline';\nimport { SharedTextProvider } from './text';\nimport type { SharedTextOptions } from './text';\nimport { defaultSharedLabels } from './shared-labels';\nimport { frenchSharedLabels } from './shared-labels-fr';\n\nconst subscribeNothing = () => () => {};\n/** Explicit host composition through PiChat's generic transcript, composer and catalog seams. */\nexport function useSharedChatProps(controller: SharedChatController | undefined, options: SharedTextOptions = {}): Omit<PiChatProps, 'controller'> {\n const state = useSyncExternalStore(controller?.subscribe ?? subscribeNothing, () => controller?.getSnapshot(), () => controller?.getSnapshot());\n const api = useRef<TimelineApi | undefined>(undefined);\n const raw = state?.shared.members;\n // Plain names only: an id or a slug the host sent in a name's place never reaches the picker, a chip or a mention.\n const meId = state?.shared.me.id;\n const labels = { ...(options.locale === 'fr' ? frenchSharedLabels : defaultSharedLabels), ...options.labels };\n const nameOf = labels.sharedName;\n const members = useMemo(() => (raw ?? []).map(member => namedMember(member, nameOf)), [raw, nameOf]);\n const mentions = useMemo<MentionsConfig>(() => ({\n listLabel: labels.sharedMembers, icon: <UserIcon aria-hidden=\"true\" />, Picker: MentionPicker,\n label: id => members.find(member => member.id === id)?.name ?? namedMember({ id, name: id, kind: 'person' }, nameOf).name,\n search: async query => members.filter(member => member.id !== meId && member.name.toLowerCase().includes(query.toLowerCase()))\n .sort((a, b) => Number(b.kind === 'agent') - Number(a.kind === 'agent')).map(member => ({ path: member.id, label: member.name })),\n render: id => <Badge variant=\"secondary\">@{(members.find(member => member.id === id) ?? namedMember({ id, name: id, kind: 'person' }, nameOf)).name}</Badge>,\n renderSent: id => {\n const member = members.find(member => member.id === id) ?? namedMember({ id, name: id, kind: 'person' }, nameOf);\n return <span data-testid=\"message-mention\" data-path={id} className=\"inline-flex items-baseline gap-1 align-baseline font-medium\"><span aria-hidden=\"true\"><MemberAvatar member={member} compact interactive={false} /></span><span data-testid=\"mention-name\">{member.name}</span></span>;\n },\n renderOption: item => {\n const member = members.find(member => member.id === item.path);\n const role = member ? labels.sharedRole(member) : undefined;\n return <><ItemMedia variant=\"icon\">{member ? <MemberAvatar member={member} compact interactive={false} indicator={false} /> : <UserIcon aria-hidden=\"true\" />}</ItemMedia><ItemContent className=\"min-w-0\"><ItemTitle>{item.label}</ItemTitle>{role && <ItemDescription className=\"m-0 truncate\">{role}</ItemDescription>}</ItemContent></>;\n },\n }), [members, meId, nameOf, labels.sharedMembers, labels.sharedRole]);\n const wrap = (children: ReactNode) => <SharedTextProvider {...options}>{children}</SharedTextProvider>;\n return {\n locale: options.locale,\n timeline: { count: state?.shared.messages.length ?? 0,\n render: context => controller ? wrap(<SharedTimeline controller={controller} api={api} mentions={mentions} {...context} />) : null,\n afterSend: () => api.current?.reset() },\n controls: controller && options.presence !== false ? wrap(<SharedMembers controller={controller} />) : undefined,\n mentions,\n composerHeader: controller && state?.shared.replyTo ? wrap(<ReplyQuote targetId={state.shared.replyTo} quote={sharedQuote(state.shared, state.shared.replyTo, nameOf)} onReveal={id => api.current?.reveal(id)} onRemove={() => controller.setReplyTo(undefined)} />) : undefined,\n composerHasContent: Boolean(state?.shared.files.length),\n onPickFiles: files => { if (controller) controller.setFiles([...controller.getSnapshot().shared.files, ...files]); },\n composerChips: controller && wrap(state?.shared.files.map((file, index) => <Badge key={index} variant=\"secondary\">{file.name}<Button variant=\"ghost\" size=\"icon\" aria-label={`Remove ${file.name}`} onClick={() => controller.setFiles(controller.getSnapshot().shared.files.filter((_, at) => at !== index))}><span aria-hidden=\"true\">×</span></Button></Badge>)),\n renderConnectionError: error => error instanceof Error ? error.message : String(error),\n // WhatsApp's composer; a host spreading these props can still override either.\n composerLayout: 'pill', voiceReplacesSend: true,\n };\n}\n\n/** Owns only the adapters it creates; each adapter borrows the host source. `limit`: messages per history page read from the source (default 60). */\nexport function useSharedChat(source: ChannelSource, options: SharedTextOptions & { readonly limit?: number | undefined } = {}) {\n const [list, setList] = useState<{ source: ChannelSource; items: Conversation[] }>();\n const [selection, setSelection] = useState<{ source: ChannelSource; id: string }>();\n const [error, setError] = useState<string>();\n const items = list?.source === source ? list.items : [];\n const conversationId = selection?.source === source ? selection.id : items[0]?.id;\n const [binding, setBinding] = useState<{ source: ChannelSource; id: string; controller: SharedChatController }>();\n useEffect(() => {\n let off = false; setError(undefined);\n void source.conversations().then(items => { if (!off) setList({ source, items }); }, cause => { if (!off) setError(String(cause)); });\n return () => { off = true; };\n }, [source]);\n useEffect(() => {\n if (conversationId === undefined) return;\n const controller = createSharedChat(source, { conversationId, ...(options.limit === undefined ? {} : { limit: options.limit }) });\n setBinding({ source, id: conversationId, controller });\n return () => { void controller.dispose(); };\n }, [source, conversationId, options.limit]);\n const controller = binding?.source === source && binding.id === conversationId ? binding.controller : undefined;\n const conversations: ConversationsConfig = { items, activeId: conversationId, loading: list?.source !== source, onSelect: id => setSelection({ source, id }) };\n const chat = useSharedChatProps(controller, options);\n return { controller, conversationId, conversations, error,\n chatProps: controller ? { ...chat, controller, conversations } : undefined,\n workspaceProps: { controller, conversationId, conversations, locale: options.locale, controls: chat.controls, chat },\n };\n}\n",
27
+ "content": "'use client';\nimport { useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';\nimport type { ReactNode } from 'react';\nimport { UserIcon } from 'lucide-react';\nimport { useControllerPerConversation } from '@hachej/boring-ui-kit/chat-react';\nimport { createSharedChat } from '@hachej/boring-ui-kit/shared-chat';\nimport type { ChannelSource, Conversation, SharedChatController } from '@hachej/boring-ui-kit/shared-chat';\nimport type { PiChatProps } from '../pi-chat/pi-chat';\nimport type { ConversationsConfig, MentionsConfig } from '../pi-chat/config';\nimport { MentionPicker } from '../pi-chat-mentions/mention-menu';\nimport { Badge } from '@/components/ui/badge';\nimport { Button } from '@/components/ui/button';\nimport { ItemContent, ItemDescription, ItemMedia, ItemTitle } from '@/components/ui/item';\nimport { namedMember } from './names';\nimport { MemberAvatar, SharedMembers, ReplyQuote, sharedQuote } from './messages';\nimport { SharedTimeline } from './timeline';\nimport type { TimelineApi } from './timeline';\nimport { SharedTextProvider } from './text';\nimport type { SharedTextOptions } from './text';\nimport { defaultSharedLabels } from './shared-labels';\nimport { frenchSharedLabels } from './shared-labels-fr';\n\nconst subscribeNothing = () => () => {};\n/** Explicit host composition through PiChat's generic transcript, composer and catalog seams. */\nexport function useSharedChatProps(controller: SharedChatController | undefined, options: SharedTextOptions = {}): Omit<PiChatProps, 'controller'> {\n const state = useSyncExternalStore(controller?.subscribe ?? subscribeNothing, () => controller?.getSnapshot(), () => controller?.getSnapshot());\n const api = useRef<TimelineApi | undefined>(undefined);\n const raw = state?.shared.members;\n // Plain names only: an id or a slug the host sent in a name's place never reaches the picker, a chip or a mention.\n const meId = state?.shared.me.id;\n const labels = { ...(options.locale === 'fr' ? frenchSharedLabels : defaultSharedLabels), ...options.labels };\n const nameOf = labels.sharedName, sharedMembers = labels.sharedMembers, sharedRole = labels.sharedRole;\n const members = useMemo(() => (raw ?? []).map(member => namedMember(member, nameOf)), [raw, nameOf]);\n const mentions = useMemo<MentionsConfig>(() => ({\n listLabel: sharedMembers, icon: <UserIcon aria-hidden=\"true\" />, Picker: MentionPicker,\n label: id => members.find(member => member.id === id)?.name ?? namedMember({ id, name: id, kind: 'person' }, nameOf).name,\n search: async query => members.filter(member => member.id !== meId && member.name.toLowerCase().includes(query.toLowerCase()))\n .sort((a, b) => Number(b.kind === 'agent') - Number(a.kind === 'agent')).map(member => ({ path: member.id, label: member.name })),\n render: id => <Badge variant=\"secondary\">@{(members.find(member => member.id === id) ?? namedMember({ id, name: id, kind: 'person' }, nameOf)).name}</Badge>,\n renderSent: id => {\n const member = members.find(member => member.id === id) ?? namedMember({ id, name: id, kind: 'person' }, nameOf);\n return <span data-testid=\"message-mention\" data-path={id} className=\"inline-flex items-baseline gap-1 align-baseline font-medium\"><span aria-hidden=\"true\"><MemberAvatar member={member} compact interactive={false} /></span><span data-testid=\"mention-name\">{member.name}</span></span>;\n },\n renderOption: item => {\n const member = members.find(member => member.id === item.path);\n const role = member ? sharedRole(member) : undefined;\n return <><ItemMedia variant=\"icon\">{member ? <MemberAvatar member={member} compact interactive={false} indicator={false} /> : <UserIcon aria-hidden=\"true\" />}</ItemMedia><ItemContent className=\"min-w-0\"><ItemTitle>{item.label}</ItemTitle>{role && <ItemDescription className=\"m-0 truncate\">{role}</ItemDescription>}</ItemContent></>;\n },\n }), [members, meId, nameOf, sharedMembers, sharedRole]);\n const wrap = (children: ReactNode) => <SharedTextProvider {...options}>{children}</SharedTextProvider>;\n return {\n locale: options.locale,\n timeline: { count: state?.shared.messages.length ?? 0,\n render: context => controller ? wrap(<SharedTimeline controller={controller} api={api} mentions={mentions} {...context} />) : null,\n afterSend: () => api.current?.reset() },\n controls: controller && options.presence !== false ? wrap(<SharedMembers controller={controller} />) : undefined,\n mentions,\n composerHeader: controller && state?.shared.replyTo ? wrap(<ReplyQuote targetId={state.shared.replyTo} quote={sharedQuote(state.shared, state.shared.replyTo, nameOf)} onReveal={id => api.current?.reveal(id)} onRemove={() => controller.setReplyTo(undefined)} />) : undefined,\n composerHasContent: Boolean(state?.shared.files.length),\n onPickFiles: files => { if (controller) controller.setFiles([...controller.getSnapshot().shared.files, ...files]); },\n composerChips: controller && wrap(state?.shared.files.map((file, index) => <Badge key={index} variant=\"secondary\">{file.name}<Button variant=\"ghost\" size=\"icon\" aria-label={`Remove ${file.name}`} onClick={() => controller.setFiles(controller.getSnapshot().shared.files.filter((_, at) => at !== index))}><span aria-hidden=\"true\">×</span></Button></Badge>)),\n renderConnectionError: error => error instanceof Error ? error.message : String(error),\n // WhatsApp's composer; a host spreading these props can still override either.\n composerLayout: 'pill', voiceReplacesSend: true,\n };\n}\n\n/** Owns only the adapters it creates; each adapter borrows the host source. `limit`: messages per history page read from the source (default 60). */\nexport function useSharedChat(source: ChannelSource, options: SharedTextOptions & { readonly limit?: number | undefined } = {}) {\n const [list, setList] = useState<{ source: ChannelSource; items: Conversation[] }>();\n const [selection, setSelection] = useState<{ source: ChannelSource; id: string }>();\n // A failure belongs to the source that raised it: another source starts clean.\n const [failure, setFailure] = useState<{ source: ChannelSource; message: string }>();\n const error = failure?.source === source ? failure.message : undefined;\n const items = list?.source === source ? list.items : [];\n const conversationId = selection?.source === source ? selection.id : items[0]?.id;\n useEffect(() => {\n let off = false;\n void source.conversations().then(items => { if (!off) setList({ source, items }); }, cause => { if (!off) setFailure({ source, message: String(cause) }); });\n return () => { off = true; };\n }, [source]);\n const principalId = source.me.id;\n const key = useMemo(() => conversationId === undefined ? undefined : { source, conversationId, principalId, limit: options.limit }, [source, conversationId, principalId, options.limit]);\n const selected = useControllerPerConversation({ key,\n create: () => createSharedChat(source, { conversationId: conversationId!, ...(options.limit === undefined ? {} : { limit: options.limit }) }),\n });\n const controller = selected.status === 'ready' ? selected.controller : undefined;\n const conversations: ConversationsConfig = { items, activeId: conversationId, loading: list?.source !== source, onSelect: id => setSelection({ source, id }) };\n const chat = useSharedChatProps(controller, options);\n return { controller, conversationId, conversations, error,\n chatProps: controller ? { ...chat, controller, conversations } : undefined,\n workspaceProps: { controller, conversationId, conversations, locale: options.locale, controls: chat.controls, chat },\n };\n}\n",
42
28
  "type": "registry:component",
43
29
  "target": "components/shared-chat/use-shared-chat.tsx"
44
30
  },
@@ -50,13 +36,13 @@
50
36
  },
51
37
  {
52
38
  "path": "registry/shared-chat/timeline.tsx",
53
- "content": "'use client';\nimport { Children, memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';\nimport type { ReactNode, RefObject } from 'react';\nimport type { SharedChatController } from '@hachej/boring-ui-kit/shared-chat';\nimport type { MentionsConfig } from '../pi-chat/config';\nimport { MessageText } from '../pi-chat/message';\nimport { Markdown } from '../pi-chat-markdown/markdown';\nimport { PAGE_FIRST, PAGE_MORE, useRowChunks, useTranscript } from '../pi-chat/session';\nimport type { ChatPaging } from '../pi-chat/session';\nimport { TranscriptHistory } from '../pi-chat/history';\nimport { Message, MessageContent } from '@/components/ui/message';\nimport { Bubble, BubbleContent } from '@/components/ui/bubble';\nimport { Badge } from '@/components/ui/badge';\nimport { Button } from '@/components/ui/button';\nimport { MessageScroller, MessageScrollerViewport, MessageScrollerContent, MessageScrollerItem } from '@/components/ui/message-scroller';\nimport { showsAuthors, runHead } from './names';\nimport { AuthorName, BubbleTail, ClockSpacer, MessageClock, PinnedComment, QuestionCard, ReplyQuote, RequestCard, SharedMessage, SystemLines, ViaLine, clockRoom, sharedItems } from './messages';\nimport type { SharedItem } from './messages';\nimport { HostTrace } from './host-trace';\nimport { useChatText, useSharedView } from './text';\n\nexport interface TimelineApi { readonly reset: () => void; readonly reveal: (id: string) => void }\n/**\n * WhatsApp's room: bubbles hug the bottom, a day pill starts each day, host notices collapse into one small line, and a\n * « ↓ 3 new messages » pill appears below the list (never over it) while the reader is scrolled up.\n */\nexport function SharedTimeline({ controller, api, focusComposer, openFile, mentions, pendingVoice, paging }: {\n readonly controller: SharedChatController; readonly api: RefObject<TimelineApi | undefined>;\n readonly focusComposer: () => void; readonly openFile?: ((path: string) => void) | undefined; readonly mentions: MentionsConfig; readonly pendingVoice?: ReactNode; readonly paging?: ChatPaging | undefined;\n}) {\n const state = useSyncExternalStore(controller.subscribe, controller.getSnapshot, controller.getSnapshot);\n const { labels, icons } = useChatText();\n const view = useSharedView();\n const items = useStableItems(useMemo(() => sharedItems(state.shared, labels.sharedName), [state.shared, labels.sharedName]));\n const connected = state.connection.kind === 'connected';\n const transcript = useTranscript(items, paging?.first ?? PAGE_FIRST, paging?.more ?? PAGE_MORE, { state: state.history, connected: !state.disposed && connected, load: controller.loadEarlier });\n const { stick, hidden } = transcript;\n const chunks = useRowChunks(useMemo(() => transcript.visible(items), [items, hidden]));\n const root = useRef<HTMLDivElement>(null);\n const [revealing, setRevealing] = useState<string>();\n // The last message the reader has seen at the bottom; what others posted after it while scrolled up is « new ».\n const latest = state.shared.messages.at(-1)?.id;\n const [seen, setSeen] = useState(latest);\n useEffect(() => { if (stick.isAtBottom && seen !== latest) setSeen(latest); }, [stick.isAtBottom, latest, seen]);\n // The pill's row shows only once the reader scrolls up, so loading a room (which scrolls down) never shifts the list under a tap.\n const lastTop = useRef(0);\n const [scrolledUp, setScrolledUp] = useState(false);\n useEffect(() => { if (stick.isAtBottom) setScrolledUp(false); }, [stick.isAtBottom]);\n const seenAt = state.shared.messages.findIndex(message => message.id === seen);\n const unseen = stick.isAtBottom ? 0 : state.shared.messages.slice(seenAt + 1).filter(message => message.author !== state.shared.me.id).length;\n // The reader's own new message lands in view, wherever they were: on Send it is drawn at once (pending), and Send's own scroll\n // runs before that render. Its confirmation (same clientId) is the same message and does not scroll again.\n const mine = state.shared.messages.findLast(message => message.author === state.shared.me.id);\n const mineKey = mine ? mine.clientId ?? mine.id : undefined;\n const lastMine = useRef(mineKey);\n useLayoutEffect(() => {\n if (mineKey === lastMine.current) return;\n const fresh = lastMine.current !== undefined || mine?.pending;\n lastMine.current = mineKey;\n if (fresh && mine === state.shared.messages.at(-1)) { transcript.reset(); void stick.scrollToBottom('instant'); }\n }, [mineKey]);\n useEffect(() => {\n const value = { reset: () => { transcript.reset(); void stick.scrollToBottom('instant'); }, reveal: setRevealing };\n api.current = value;\n return () => { if (api.current === value) api.current = undefined; };\n });\n useEffect(() => {\n if (!revealing) return;\n const key = items.find(item => item.message.id === revealing || item.system?.some(line => line.id === revealing))?.key;\n if (key) {\n transcript.revealTo(key);\n const frame = requestAnimationFrame(() => {\n const parent = [...(root.current?.querySelectorAll<HTMLElement>('[data-shared-message]') ?? [])].find(element => element.dataset.sharedMessage === revealing);\n if (parent) { parent.scrollIntoView({ block: 'center', behavior: 'instant' }); setRevealing(undefined); }\n });\n return () => cancelAnimationFrame(frame);\n }\n if (state.history.kind === 'loading') return;\n if (state.history.kind === 'ready' && state.history.hasMore) void controller.loadEarlier().catch(() => setRevealing(undefined));\n else setRevealing(undefined);\n }, [revealing, items, hidden, state.history]);\n const hasContent = items.length > 0 || Children.count(pendingVoice) > 0;\n // Names and avatars only where they tell people apart: two or more members besides the reader.\n const multi = showsAuthors(state.shared.members, state.shared.me.id, view.presence);\n const focus = useRef(focusComposer); focus.current = focusComposer;\n const reply = useCallback((id: string) => { controller.setReplyTo(id); focus.current(); }, [controller]);\n const open = useRef(openFile); open.current = openFile;\n const hasOpen = Boolean(openFile);\n const stableOpen = useMemo(() => hasOpen ? (path: string) => open.current?.(path) : undefined, [hasOpen]);\n return <MessageScroller>\n <MessageScrollerViewport ref={node => { root.current = node; stick.scrollRef(node); }} data-testid=\"transcript-scroll\" aria-label={labels.messages} className=\"min-h-0 flex-1 scrollbar-thumb-border scrollbar-track-transparent\"\n onScroll={event => { const top = event.currentTarget.scrollTop; if (top < lastTop.current - 4) setScrolledUp(true); lastTop.current = top; }}>\n {items.length > 0 && (hidden > 0 || state.history.kind === 'loading' || state.history.kind === 'error' || state.history.kind === 'ready' && state.history.hasMore) && <TranscriptHistory history={state.history} connected={!state.disposed && connected} onLoad={transcript.reveal} sentinelRef={transcript.sentinelRef} hidden={hidden} loaded={transcript.loaded} />}\n <div data-testid=\"transcript-bottom\" className=\"mx-auto flex min-h-full w-full max-w-[720px] flex-col justify-end px-4 pt-4 pb-3 sm:px-6\">\n <MessageScrollerContent aria-label={labels.messages} aria-live=\"polite\" data-testid=\"transcript\" className=\"h-auto min-h-0 gap-0 [&>[data-local-voice]]:mt-3\">\n {chunks.map((run, index) => <TimelineChunk key={run[0]!.key} items={run} top={index === 0} multi={multi} controller={controller} mentions={mentions} openFile={stableOpen} onReply={reply} onReveal={setRevealing} />)}\n {pendingVoice}\n </MessageScrollerContent>\n </div>\n </MessageScrollerViewport>\n {hasContent && !stick.isAtBottom && scrolledUp && <div data-testid=\"transcript-navigation\" className=\"flex shrink-0 items-center justify-center py-1.5\">\n <Button variant=\"secondary\" size=\"sm\" data-testid=\"jump-latest\" data-unseen={unseen || undefined} aria-label={unseen ? labels.sharedNewMessages(unseen) : labels.jumpToLatest} title={labels.jumpToLatest} className=\"max-sm:min-h-11 pointer-coarse:min-h-11\" onClick={() => api.current?.reset()}>\n <icons.jumpToLatest data-icon=\"inline-start\" aria-hidden=\"true\" />{unseen ? labels.sharedNewMessages(unseen) : labels.jumpToLatest}</Button>\n </div>}\n </MessageScroller>;\n}\n\n/** Same item, field by field (the quote is rebuilt each pass, so it compares by what it points at). */\nconst sameItem = (a: SharedItem, b: SharedItem) => a.key === b.key && a.message === b.message && a.author === b.author && a.own === b.own && a.first === b.first\n && a.lastItem === b.lastItem && a.separator === b.separator && a.showSpinner === b.showSpinner && a.latestCard === b.latestCard\n && a.quote?.message === b.quote?.message && a.quote?.author === b.quote?.author\n && (a.system === b.system || (a.system?.length === b.system?.length && a.system!.every((line, index) => line === b.system![index])));\n/**\n * Keeps the previous object of every item that did not change, so a page loaded above, a delta or a new message renders only the\n * rows it touches (rows and their runs are memoized by identity).\n */\nfunction useStableItems(next: readonly SharedItem[]): readonly SharedItem[] {\n const previous = useRef(new Map<string, SharedItem>());\n return useMemo(() => {\n const items = next.map(item => { const before = previous.current.get(item.key); return before && sameItem(before, item) ? before : item; });\n previous.current = new Map(items.map(item => [item.key, item]));\n return items;\n }, [next]);\n}\n\ntype RowProps = {\n readonly multi: boolean; readonly controller: SharedChatController; readonly mentions: MentionsConfig;\n readonly openFile: ((path: string) => void) | undefined; readonly onReply: (id: string) => void; readonly onReveal: (id: string) => void;\n};\n/** A run of timeline rows as one scroller item (`useRowChunks`). Memoized, like each row: it renders again only when its own items change. */\nconst TimelineChunk = memo(function TimelineChunk({ items, top, ...props }: RowProps & { readonly items: readonly SharedItem[]; readonly top: boolean }) {\n return <MessageScrollerItem messageId={items[0]!.key} data-transcript-chunk=\"\" className=\"flex flex-col [content-visibility:visible] [gap:inherit]\">\n {items.map((item, index) => <TimelineRow key={item.key} item={item} top={top && index === 0} {...props} />)}\n </MessageScrollerItem>;\n});\nconst TimelineRow = memo(function TimelineRow({ item, top, multi, controller, mentions, openFile, onReply, onReveal }: RowProps & { readonly item: SharedItem; readonly top: boolean }) {\n const { labels } = useChatText();\n const view = useSharedView();\n const context = useMemo(() => ({ pieces: { mentions: true, skills: [], renderMention: mentions.renderSent }, openFile }), [mentions.renderSent, openFile]);\n const renderMention = (path: string, text: string) => mentions.renderSent?.(path) ?? text;\n const bubble = () => {\n const { message, author, own, first } = item;\n const person = own || author.kind !== 'agent' || message.via !== undefined;\n const headKind = runHead({ first, own, authors: multi, via: message.via });\n const head = headKind === 'author' ? <AuthorName member={author} via={message.via} /> : undefined;\n const traced = view.steps !== 'hidden' && !message.via && author.kind === 'agent' && Boolean(message.streaming || message.steps?.length);\n // Who spoke (and via what) sits above the group's first bubble, outside it: the bubble keeps its width. My own messages carry no name;\n // one posted through an outside agent says so on my side.\n const via = headKind === 'via' && message.via ? <ViaLine via={message.via} className=\"ms-auto block w-fit max-w-full pb-0.5 pe-1\" /> : undefined;\n const live = traced && Boolean(message.streaming);\n const trace = traced ? <HostTrace participant={author.name} steps={message.steps ?? []} streaming={Boolean(message.streaming)} active={item.showSpinner} /> : undefined;\n const quote = message.replyTo ? <ReplyQuote targetId={message.replyTo} quote={item.quote} onReveal={onReveal} /> : undefined;\n if (message.question) return <article data-row-id={item.key} data-role=\"event\" className=\"flex w-full min-w-0 flex-col\">\n {head}{via}\n <Bubble variant=\"muted\" align=\"start\" data-testid=\"question-bubble\">\n {first && <BubbleTail side=\"start\" />}\n <BubbleContent className=\"relative min-w-52\">{quote}\n <QuestionCard message={message} answer={controller.answer} onReply={onReply} />\n </BubbleContent>\n </Bubble></article>;\n if (message.card) return <article data-row-id={item.key} data-role=\"event\" className=\"flex w-full min-w-0 flex-col\">\n {head}{via}\n <Bubble variant={own ? 'default' : 'muted'} align={own ? 'end' : 'start'} data-testid=\"card-bubble\">\n {first && <BubbleTail side={own ? 'end' : 'start'} />}\n <BubbleContent className=\"relative min-w-52\">{quote}\n {message.card.kind === 'pin' ? <PinnedComment card={message.card} own={own} at={message.at} /> : <RequestCard card={message.card} action={controller.action} at={message.at} own={own} />}\n </BubbleContent>\n </Bubble></article>;\n return <article data-row-id={item.key} data-role={person ? 'user' : 'assistant'} data-streaming={message.streaming ? 'true' : undefined} data-pending={message.pending ? 'true' : undefined} className=\"group/message\">{head}{via}<Message align={own ? 'end' : 'start'}><MessageContent>\n {own ? <div data-testid=\"own-message\" className=\"contents [&_[data-testid=message-mention]]:text-inherit\">\n <MessageText text={message.text ?? ''} context={context} variant=\"default\" decoration={first ? <BubbleTail side=\"end\" /> : undefined} header={quote} footer={<><ClockSpacer /><MessageClock at={message.at} tone=\"primary\" pending={message.pending} /></>} /></div>\n : view.answerStyle === 'plain' && !person ? <div data-testid=\"plain-answer\" className=\"min-w-0\">\n {quote}\n {message.text && <Markdown text={message.text} streaming={Boolean(message.streaming)} renderMention={renderMention} />}\n {traced && <HostTrace participant={author.name} steps={message.steps ?? []} streaming={Boolean(message.streaming)} active={item.showSpinner} />}\n </div>\n : <Bubble variant=\"muted\">{first && <BubbleTail side=\"start\" />}<BubbleContent className=\"relative\">\n {quote}\n {/* While the agent writes, the working line sits above the growing text and the time waits for the answer: nothing under the text moves as it grows (B5). */}\n {live && trace}\n {message.text && <div className={traced ? undefined : clockRoom}><Markdown text={message.text} streaming={Boolean(message.streaming)} renderMention={renderMention} /></div>}\n {traced ? <>\n {!live && trace}\n {!live && <div className=\"flex justify-end\"><MessageClock at={message.at} tone=\"muted\" floating={false} /></div>}\n </> : <MessageClock at={message.at} tone=\"muted\" />}\n </BubbleContent></Bubble>}\n </MessageContent></Message></article>;\n };\n const gap = top || item.separator ? '' : item.system || item.first ? ' mt-3' : ' mt-0.5';\n return <div data-row-key={item.key} data-group-start={item.first ? 'true' : undefined} className={`min-w-0 shrink-0${gap}`}>\n {item.separator && <div data-testid=\"day-separator\" className={`flex justify-center pb-3${top ? '' : ' pt-4'}`}><Badge variant=\"secondary\"><time dateTime={item.message.at} title={labels.sharedTimestamp(item.message.at)}>{labels.sharedDay(item.message.at)}</time></Badge></div>}\n {item.system ? <SystemLines lines={item.system} />\n : <SharedMessage shared={item} remove={controller.remove} canRemove={controller.canRemove} openFile={openFile} onReply={onReply} onReveal={onReveal}>{bubble()}</SharedMessage>}\n </div>;\n});\n",
39
+ "content": "'use client';\nimport { Children, memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';\nimport type { ReactNode, RefObject } from 'react';\nimport type { SharedChatController } from '@hachej/boring-ui-kit/shared-chat';\nimport type { MentionsConfig } from '../pi-chat/config';\nimport { MessageText } from '../pi-chat/message';\nimport { Markdown } from '../pi-chat-markdown/markdown';\nimport { PAGE_FIRST, PAGE_MORE, useRowChunks, useTranscript } from '../pi-chat/session';\nimport type { ChatPaging } from '../pi-chat/session';\nimport { TranscriptHistory } from '../pi-chat/history';\nimport { Message, MessageContent } from '@/components/ui/message';\nimport { Bubble, BubbleContent } from '@/components/ui/bubble';\nimport { Badge } from '@/components/ui/badge';\nimport { Button } from '@/components/ui/button';\nimport { MessageScroller, MessageScrollerViewport, MessageScrollerContent, MessageScrollerItem } from '@/components/ui/message-scroller';\nimport { showsAuthors, runHead } from './names';\nimport { AuthorName, BubbleTail, ClockSpacer, MessageClock, PinnedComment, QuestionCard, ReplyQuote, RequestCard, SharedMessage, SystemLines, ViaLine, clockRoom, sharedItems } from './messages';\nimport type { SharedItem } from './messages';\nimport { HostTrace } from './host-trace';\nimport { useChatText, useSharedView } from './text';\n\nexport interface TimelineApi { readonly reset: () => void; readonly reveal: (id: string) => void }\n/**\n * WhatsApp's room: bubbles hug the bottom, a day pill starts each day, host notices collapse into one small line, and a\n * « ↓ 3 new messages » pill appears below the list (never over it) while the reader is scrolled up.\n */\nexport function SharedTimeline({ controller, api, focusComposer, openFile, mentions, pendingVoice, paging }: {\n readonly controller: SharedChatController; readonly api: RefObject<TimelineApi | undefined>;\n readonly focusComposer: () => void; readonly openFile?: ((path: string) => void) | undefined; readonly mentions: MentionsConfig; readonly pendingVoice?: ReactNode; readonly paging?: ChatPaging | undefined;\n}) {\n const state = useSyncExternalStore(controller.subscribe, controller.getSnapshot, controller.getSnapshot);\n const { labels, icons } = useChatText();\n const view = useSharedView();\n const items = useStableItems(useMemo(() => sharedItems(state.shared, labels.sharedName), [state.shared, labels.sharedName]));\n const connected = state.connection.kind === 'connected';\n const transcript = useTranscript(items, paging?.first ?? PAGE_FIRST, paging?.more ?? PAGE_MORE, { state: state.history, connected: !state.disposed && connected, load: controller.loadEarlier });\n const { stick, hidden } = transcript;\n const chunks = useRowChunks(useMemo(() => hidden ? items.slice(hidden) : items, [items, hidden]));\n const root = useRef<HTMLDivElement>(null);\n const [revealing, setRevealing] = useState<string>();\n // The last message the reader has seen at the bottom; what others posted after it while scrolled up is « new ».\n const latest = state.shared.messages.at(-1)?.id;\n const [seen, setSeen] = useState(latest);\n // eslint-disable-next-line react-hooks/set-state-in-effect -- marks the latest message seen once the reader is at the bottom, after commit; settles when equal\n useEffect(() => { if (stick.isAtBottom && seen !== latest) setSeen(latest); }, [stick.isAtBottom, latest, seen]);\n // The pill's row shows only once the reader scrolls up, so loading a room (which scrolls down) never shifts the list under a tap.\n const lastTop = useRef(0);\n const [scrolledUp, setScrolledUp] = useState(false);\n // eslint-disable-next-line react-hooks/set-state-in-effect -- reaching the bottom hides the pill's row once, after commit\n useEffect(() => { if (stick.isAtBottom) setScrolledUp(false); }, [stick.isAtBottom]);\n const seenAt = state.shared.messages.findIndex(message => message.id === seen);\n const unseen = stick.isAtBottom ? 0 : state.shared.messages.slice(seenAt + 1).filter(message => message.author !== state.shared.me.id).length;\n // The reader's own new message lands in view, wherever they were: on Send it is drawn at once (pending), and Send's own scroll\n // runs before that render. Its confirmation (same clientId) is the same message and does not scroll again.\n const mine = state.shared.messages.findLast(message => message.author === state.shared.me.id);\n const mineKey = mine ? mine.clientId ?? mine.id : undefined;\n const lastMine = useRef(mineKey);\n useLayoutEffect(() => {\n if (mineKey === lastMine.current) return;\n const fresh = lastMine.current !== undefined || mine?.pending;\n lastMine.current = mineKey;\n if (fresh && mine === state.shared.messages.at(-1)) { transcript.reset(); void stick.scrollToBottom('instant'); }\n // eslint-disable-next-line react-hooks/exhaustive-deps -- scrolls only when the reader's own latest message changes, not on every message or transcript object\n }, [mineKey]);\n useEffect(() => bindApi(api, { reset: () => { transcript.reset(); void stick.scrollToBottom('instant'); }, reveal: setRevealing }), [api, transcript, stick]);\n useEffect(() => {\n if (!revealing) return;\n const key = items.find(item => item.message.id === revealing || item.system?.some(line => line.id === revealing))?.key;\n if (key) {\n transcript.revealTo(key);\n const frame = requestAnimationFrame(() => {\n const parent = [...(root.current?.querySelectorAll<HTMLElement>('[data-shared-message]') ?? [])].find(element => element.dataset.sharedMessage === revealing);\n if (parent) { parent.scrollIntoView({ block: 'center', behavior: 'instant' }); setRevealing(undefined); }\n });\n return () => cancelAnimationFrame(frame);\n }\n if (state.history.kind === 'loading') return;\n if (state.history.kind === 'ready' && state.history.hasMore) void controller.loadEarlier().catch(() => setRevealing(undefined));\n // A message that is neither loaded nor further back in history cannot be revealed.\n // eslint-disable-next-line react-hooks/set-state-in-effect -- an unreachable target is dropped once, after commit\n else setRevealing(undefined);\n // eslint-disable-next-line react-hooks/exhaustive-deps -- runs per target, rows and history page; the transcript object and controller are rebuilt or stable and must not re-trigger a reveal\n }, [revealing, items, hidden, state.history]);\n const hasContent = items.length > 0 || Children.count(pendingVoice) > 0;\n // Names and avatars only where they tell people apart: two or more members besides the reader.\n const multi = showsAuthors(state.shared.members, state.shared.me.id, view.presence);\n const focus = useRef(focusComposer);\n useLayoutEffect(() => { focus.current = focusComposer; });\n const reply = useCallback((id: string) => { controller.setReplyTo(id); focus.current(); }, [controller]);\n const open = useRef(openFile);\n useLayoutEffect(() => { open.current = openFile; });\n const hasOpen = Boolean(openFile);\n const stableOpen = useMemo(() => hasOpen ? (path: string) => open.current?.(path) : undefined, [hasOpen]);\n return <MessageScroller>\n <MessageScrollerViewport ref={node => { root.current = node; stick.scrollRef(node); }} data-testid=\"transcript-scroll\" aria-label={labels.messages} className=\"min-h-0 flex-1 scrollbar-thumb-border scrollbar-track-transparent\"\n onScroll={event => { const top = event.currentTarget.scrollTop; if (top < lastTop.current - 4) setScrolledUp(true); lastTop.current = top; }}>\n {items.length > 0 && (hidden > 0 || state.history.kind === 'loading' || state.history.kind === 'error' || state.history.kind === 'ready' && state.history.hasMore) && <TranscriptHistory history={state.history} connected={!state.disposed && connected} onLoad={transcript.reveal} sentinelRef={transcript.sentinelRef} hidden={hidden} loaded={transcript.loaded} />}\n <div data-testid=\"transcript-bottom\" className=\"mx-auto flex min-h-full w-full max-w-[720px] flex-col justify-end px-4 pt-4 pb-3 sm:px-6\">\n <MessageScrollerContent aria-label={labels.messages} aria-live=\"polite\" data-testid=\"transcript\" className=\"h-auto min-h-0 gap-0 [&>[data-local-voice]]:mt-3\">\n {chunks.map((run, index) => <TimelineChunk key={run[0]!.key} items={run} top={index === 0} multi={multi} controller={controller} mentions={mentions} openFile={stableOpen} onReply={reply} onReveal={setRevealing} />)}\n {pendingVoice}\n </MessageScrollerContent>\n </div>\n </MessageScrollerViewport>\n {hasContent && !stick.isAtBottom && scrolledUp && <div data-testid=\"transcript-navigation\" className=\"flex shrink-0 items-center justify-center py-1.5\">\n <Button variant=\"secondary\" size=\"sm\" data-testid=\"jump-latest\" data-unseen={unseen || undefined} aria-label={unseen ? labels.sharedNewMessages(unseen) : labels.jumpToLatest} title={labels.jumpToLatest} className=\"max-sm:min-h-11 pointer-coarse:min-h-11\" onClick={() => api.current?.reset()}>\n <icons.jumpToLatest data-icon=\"inline-start\" aria-hidden=\"true\" />{unseen ? labels.sharedNewMessages(unseen) : labels.jumpToLatest}</Button>\n </div>}\n </MessageScroller>;\n}\n\n/** Same item, field by field (the quote is rebuilt each pass, so it compares by what it points at). */\nconst sameItem = (a: SharedItem, b: SharedItem) => a.key === b.key && a.message === b.message && a.author === b.author && a.own === b.own && a.first === b.first\n && a.lastItem === b.lastItem && a.separator === b.separator && a.showSpinner === b.showSpinner && a.latestCard === b.latestCard\n && a.quote?.message === b.quote?.message && a.quote?.author === b.quote?.author\n && (a.system === b.system || (a.system?.length === b.system?.length && a.system!.every((line, index) => line === b.system![index])));\n/**\n * Keeps the previous object of every item that did not change, so a page loaded above, a delta or a new message renders only the\n * rows it touches (rows and their runs are memoized by identity).\n */\nfunction useStableItems(next: readonly SharedItem[]): readonly SharedItem[] {\n // A derived identity cache, read and replaced inside the memo only: the same items always give the same objects.\n const previous = useRef(new Map<string, SharedItem>());\n return useMemo(() => {\n // eslint-disable-next-line react-hooks/refs -- reuses the cached object of an unchanged item; no setState, no render loop\n const items = next.map(item => { const before = previous.current.get(item.key); return before && sameItem(before, item) ? before : item; });\n // eslint-disable-next-line react-hooks/refs -- stores the items for the next pass; a cache write, not state, so it triggers no render\n previous.current = new Map(items.map(item => [item.key, item]));\n return items;\n }, [next]);\n}\n\n/** Hands the timeline's controls to the host's ref until this timeline unmounts or rebinds. */\nfunction bindApi(api: RefObject<TimelineApi | undefined>, value: TimelineApi) {\n api.current = value;\n return () => { if (api.current === value) api.current = undefined; };\n}\n\ntype RowProps = {\n readonly multi: boolean; readonly controller: SharedChatController; readonly mentions: MentionsConfig;\n readonly openFile: ((path: string) => void) | undefined; readonly onReply: (id: string) => void; readonly onReveal: (id: string) => void;\n};\n/** A run of timeline rows as one scroller item (`useRowChunks`). Memoized, like each row: it renders again only when its own items change. */\nconst TimelineChunk = memo(function TimelineChunk({ items, top, ...props }: RowProps & { readonly items: readonly SharedItem[]; readonly top: boolean }) {\n return <MessageScrollerItem messageId={items[0]!.key} data-transcript-chunk=\"\" className=\"flex flex-col [content-visibility:visible] [gap:inherit]\">\n {items.map((item, index) => <TimelineRow key={item.key} item={item} top={top && index === 0} {...props} />)}\n </MessageScrollerItem>;\n});\nconst TimelineRow = memo(function TimelineRow({ item, top, multi, controller, mentions, openFile, onReply, onReveal }: RowProps & { readonly item: SharedItem; readonly top: boolean }) {\n const { labels } = useChatText();\n const view = useSharedView();\n const context = useMemo(() => ({ pieces: { mentions: true, skills: [], renderMention: mentions.renderSent }, openFile }), [mentions.renderSent, openFile]);\n const renderMention = (path: string, text: string) => mentions.renderSent?.(path) ?? text;\n const bubble = () => {\n const { message, author, own, first } = item;\n const person = own || author.kind !== 'agent' || message.via !== undefined;\n const headKind = runHead({ first, own, authors: multi, via: message.via });\n const head = headKind === 'author' ? <AuthorName member={author} via={message.via} /> : undefined;\n const traced = view.steps !== 'hidden' && !message.via && author.kind === 'agent' && Boolean(message.streaming || message.steps?.length);\n // Who spoke (and via what) sits above the group's first bubble, outside it: the bubble keeps its width. My own messages carry no name;\n // one posted through an outside agent says so on my side.\n const via = headKind === 'via' && message.via ? <ViaLine via={message.via} className=\"ms-auto block w-fit max-w-full pb-0.5 pe-1\" /> : undefined;\n const live = traced && Boolean(message.streaming);\n const trace = traced ? <HostTrace participant={author.name} steps={message.steps ?? []} streaming={Boolean(message.streaming)} active={item.showSpinner} /> : undefined;\n const quote = message.replyTo ? <ReplyQuote targetId={message.replyTo} quote={item.quote} onReveal={onReveal} /> : undefined;\n if (message.question) return <article data-row-id={item.key} data-role=\"event\" className=\"flex w-full min-w-0 flex-col\">\n {head}{via}\n <Bubble variant=\"muted\" align=\"start\" data-testid=\"question-bubble\">\n {first && <BubbleTail side=\"start\" />}\n <BubbleContent className=\"relative min-w-52\">{quote}\n <QuestionCard message={message} answer={controller.answer} onReply={onReply} />\n </BubbleContent>\n </Bubble></article>;\n if (message.card) return <article data-row-id={item.key} data-role=\"event\" className=\"flex w-full min-w-0 flex-col\">\n {head}{via}\n <Bubble variant={own ? 'default' : 'muted'} align={own ? 'end' : 'start'} data-testid=\"card-bubble\">\n {first && <BubbleTail side={own ? 'end' : 'start'} />}\n <BubbleContent className=\"relative min-w-52\">{quote}\n {message.card.kind === 'pin' ? <PinnedComment card={message.card} own={own} at={message.at} /> : <RequestCard card={message.card} action={controller.action} at={message.at} own={own} />}\n </BubbleContent>\n </Bubble></article>;\n return <article data-row-id={item.key} data-role={person ? 'user' : 'assistant'} data-streaming={message.streaming ? 'true' : undefined} data-pending={message.pending ? 'true' : undefined} className=\"group/message\">{head}{via}<Message align={own ? 'end' : 'start'}><MessageContent>\n {own ? <div data-testid=\"own-message\" className=\"contents [&_[data-testid=message-mention]]:text-inherit\">\n <MessageText text={message.text ?? ''} context={context} variant=\"default\" decoration={first ? <BubbleTail side=\"end\" /> : undefined} header={quote} footer={<><ClockSpacer /><MessageClock at={message.at} tone=\"primary\" pending={message.pending} /></>} /></div>\n : view.answerStyle === 'plain' && !person ? <div data-testid=\"plain-answer\" className=\"min-w-0\">\n {quote}\n {message.text && <Markdown text={message.text} streaming={Boolean(message.streaming)} renderMention={renderMention} />}\n {traced && <HostTrace participant={author.name} steps={message.steps ?? []} streaming={Boolean(message.streaming)} active={item.showSpinner} />}\n </div>\n : <Bubble variant=\"muted\">{first && <BubbleTail side=\"start\" />}<BubbleContent className=\"relative\">\n {quote}\n {/* While the agent writes, the working line sits above the growing text and the time waits for the answer: nothing under the text moves as it grows (B5). */}\n {live && trace}\n {message.text && <div className={traced ? undefined : clockRoom}><Markdown text={message.text} streaming={Boolean(message.streaming)} renderMention={renderMention} /></div>}\n {traced ? <>\n {!live && trace}\n {!live && <div className=\"flex justify-end\"><MessageClock at={message.at} tone=\"muted\" floating={false} /></div>}\n </> : <MessageClock at={message.at} tone=\"muted\" />}\n </BubbleContent></Bubble>}\n </MessageContent></Message></article>;\n };\n const gap = top || item.separator ? '' : item.system || item.first ? ' mt-3' : ' mt-0.5';\n return <div data-row-key={item.key} data-group-start={item.first ? 'true' : undefined} className={`min-w-0 shrink-0${gap}`}>\n {item.separator && <div data-testid=\"day-separator\" className={`flex justify-center pb-3${top ? '' : ' pt-4'}`}><Badge variant=\"secondary\"><time dateTime={item.message.at} title={labels.sharedTimestamp(item.message.at)}>{labels.sharedDay(item.message.at)}</time></Badge></div>}\n {item.system ? <SystemLines lines={item.system} />\n : <SharedMessage shared={item} remove={controller.remove} canRemove={controller.canRemove} openFile={openFile} onReply={onReply} onReveal={onReveal}>{bubble()}</SharedMessage>}\n </div>;\n});\n",
54
40
  "type": "registry:component",
55
41
  "target": "components/shared-chat/timeline.tsx"
56
42
  },
57
43
  {
58
44
  "path": "registry/shared-chat/host-trace.tsx",
59
- "content": "'use client';\nimport { useEffect, useState } from 'react';\nimport { AlertCircleIcon, CheckIcon, WrenchIcon } from 'lucide-react';\nimport type { Step } from '@hachej/boring-ui-kit/shared-chat';\nimport { Spinner } from '@/components/ui/spinner';\nimport { Trace, TraceCollapsible, TraceCollapsibleContent, TraceCollapsibleTrigger, TraceIcon, TraceLine, TraceTitle } from '@/components/ui/trace';\nimport { useChatText } from './text';\n\n/** Host observations only. This trace neither creates Pi tool calls nor runs work. */\nexport function HostTrace({ steps, participant, streaming, active }: { readonly steps: readonly Step[]; readonly participant: string; readonly streaming: boolean; readonly active: boolean }) {\n const { labels } = useChatText();\n const [chosen, setChosen] = useState<boolean | undefined>();\n useEffect(() => setChosen(undefined), [participant, streaming]);\n const open = chosen ?? false;\n const state = streaming ? 'running' : steps.some(step => step.status === 'failed') ? 'failed' : 'done';\n const running = steps.reduce((found, step, index) => found === -1 && step.status === 'running' ? index : found, -1);\n const current = steps[running];\n const progress = current ? ` · ${current.label} (${running + 1}/${steps.length})` : '';\n const Icon = streaming ? Spinner : state === 'failed' ? AlertCircleIcon : CheckIcon;\n return <Trace data-testid=\"activity\" data-boring=\"activity\" data-state={state} data-open={open ? 'true' : 'false'} className=\"my-1.5\">\n <TraceCollapsible open={open} onOpenChange={setChosen}>\n <TraceCollapsibleTrigger className=\"min-h-8 max-sm:min-h-11 pointer-coarse:min-h-11\">\n <TraceLine {...(streaming ? { status: 'running' } : state === 'failed' ? { status: 'error' } : {})}>\n {!(open || streaming && !active) && <TraceIcon><Icon aria-hidden=\"true\" /></TraceIcon>}\n <TraceTitle data-testid=\"activity-label\">{streaming ? `${labels.sharedWorking(participant)}${progress}` : labels.steps(steps.length)}</TraceTitle>\n </TraceLine>\n </TraceCollapsibleTrigger>\n <TraceCollapsibleContent><Trace data-testid=\"activity-steps\">\n {steps.map((step, index) => {\n const StepIcon = step.status === 'running' ? open && active && streaming && index === running ? Spinner : WrenchIcon : step.status === 'done' ? CheckIcon : AlertCircleIcon;\n return <div key={index} data-testid=\"activity-step\" data-kind=\"host\" data-status={step.status === 'done' ? 'completed' : step.status}>\n <TraceLine data-testid=\"host-step\" {...(step.status === 'running' ? { status: 'running' } : step.status === 'failed' ? { status: 'error' } : {})}>\n <TraceIcon><StepIcon aria-hidden=\"true\" /></TraceIcon><TraceTitle><div className=\"flex min-w-0 flex-col\"><span>{step.label}</span>{step.detail && <pre data-testid=\"step-detail\" className=\"m-0 text-xs font-mono whitespace-pre-wrap text-muted-foreground [overflow-wrap:anywhere]\">{step.detail}</pre>}</div></TraceTitle>\n </TraceLine>\n </div>;\n })}\n </Trace></TraceCollapsibleContent>\n </TraceCollapsible>\n </Trace>;\n}\n",
45
+ "content": "'use client';\nimport { useEffect, useState } from 'react';\nimport { AlertCircleIcon, CheckIcon, WrenchIcon } from 'lucide-react';\nimport type { Step } from '@hachej/boring-ui-kit/shared-chat';\nimport { Spinner } from '@/components/ui/spinner';\nimport { Trace, TraceCollapsible, TraceCollapsibleContent, TraceCollapsibleTrigger, TraceIcon, TraceLine, TraceTitle } from '@/components/ui/trace';\nimport { useChatText } from './text';\n\n/** Host observations only. This trace neither creates Pi tool calls nor runs work. */\nexport function HostTrace({ steps, participant, streaming, active }: { readonly steps: readonly Step[]; readonly participant: string; readonly streaming: boolean; readonly active: boolean }) {\n const { labels } = useChatText();\n const [chosen, setChosen] = useState<boolean | undefined>();\n // Another participant or a run that starts or ends drops the reader's choice.\n // eslint-disable-next-line react-hooks/set-state-in-effect -- resets the choice once per participant or run change, after commit\n useEffect(() => setChosen(undefined), [participant, streaming]);\n const open = chosen ?? false;\n const state = streaming ? 'running' : steps.some(step => step.status === 'failed') ? 'failed' : 'done';\n const running = steps.reduce((found, step, index) => found === -1 && step.status === 'running' ? index : found, -1);\n const current = steps[running];\n const progress = current ? ` · ${current.label} (${running + 1}/${steps.length})` : '';\n const Icon = streaming ? Spinner : state === 'failed' ? AlertCircleIcon : CheckIcon;\n return <Trace data-testid=\"activity\" data-boring=\"activity\" data-state={state} data-open={open ? 'true' : 'false'} className=\"my-1.5\">\n <TraceCollapsible open={open} onOpenChange={setChosen}>\n <TraceCollapsibleTrigger className=\"min-h-8 max-sm:min-h-11 pointer-coarse:min-h-11\">\n <TraceLine {...(streaming ? { status: 'running' } : state === 'failed' ? { status: 'error' } : {})}>\n {!(open || streaming && !active) && <TraceIcon><Icon aria-hidden=\"true\" /></TraceIcon>}\n <TraceTitle data-testid=\"activity-label\">{streaming ? `${labels.sharedWorking(participant)}${progress}` : labels.steps(steps.length)}</TraceTitle>\n </TraceLine>\n </TraceCollapsibleTrigger>\n <TraceCollapsibleContent><Trace data-testid=\"activity-steps\">\n {steps.map((step, index) => {\n const StepIcon = step.status === 'running' ? open && active && streaming && index === running ? Spinner : WrenchIcon : step.status === 'done' ? CheckIcon : AlertCircleIcon;\n return <div key={index} data-testid=\"activity-step\" data-kind=\"host\" data-status={step.status === 'done' ? 'completed' : step.status}>\n <TraceLine data-testid=\"host-step\" {...(step.status === 'running' ? { status: 'running' } : step.status === 'failed' ? { status: 'error' } : {})}>\n <TraceIcon><StepIcon aria-hidden=\"true\" /></TraceIcon><TraceTitle><div className=\"flex min-w-0 flex-col\"><span>{step.label}</span>{step.detail && <pre data-testid=\"step-detail\" className=\"m-0 text-xs font-mono whitespace-pre-wrap text-muted-foreground [overflow-wrap:anywhere]\">{step.detail}</pre>}</div></TraceTitle>\n </TraceLine>\n </div>;\n })}\n </Trace></TraceCollapsibleContent>\n </TraceCollapsible>\n </Trace>;\n}\n",
60
46
  "type": "registry:component",
61
47
  "target": "components/shared-chat/host-trace.tsx"
62
48
  },
@@ -4,18 +4,12 @@
4
4
  "title": "Consumer task-list viewer",
5
5
  "description": "A consumer-defined task-list schema and controller over the shared text buffer, with a restylable borrowed React renderer.",
6
6
  "dependencies": [
7
- "@hachej/boring-ui-kit@^0.4.24",
8
- "@hachej/boring-files@^0.4.24",
7
+ "@hachej/boring-ui-kit@^0.4.26",
8
+ "@hachej/boring-files@^0.4.26",
9
9
  "react@^19.0.0",
10
10
  "react-dom@^19.0.0",
11
11
  "zod@^4.6.5"
12
12
  ],
13
- "registryDependencies": [
14
- "button",
15
- "card",
16
- "input",
17
- "checkbox"
18
- ],
19
13
  "files": [
20
14
  {
21
15
  "path": "registry/task-list-viewer/task-list-document.ts",
@@ -31,7 +25,7 @@
31
25
  },
32
26
  {
33
27
  "path": "registry/task-list-viewer/task-list-viewer.tsx",
34
- "content": "'use client';\n\nimport { randomUUID } from '@hachej/boring-files/platform';\nimport React, { useId, useRef, useState, useSyncExternalStore } from 'react';\nimport { Button } from '@/components/ui/button';\nimport { Checkbox } from '@/components/ui/checkbox';\nimport { Card } from '@/components/ui/card';\nimport { Input } from '@/components/ui/input';\nimport type { ReadResult } from '@hachej/boring-files';\nimport type { SaveResult } from '@hachej/boring-ui-kit/resources';\nimport type { TaskListController } from './task-list-controller';\nimport type { TaskListOperation } from './task-list-document';\n\nexport interface TaskListViewerProps {\n readonly controller: TaskListController;\n readonly title?: string;\n readonly className?: string;\n}\n\nexport function TaskListViewer({ controller, title = 'Tasks', className }: TaskListViewerProps) {\n const state = useSyncExternalStore(controller.subscribe, controller.getSnapshot, controller.getSnapshot);\n const current = useRef({ controller });\n if (current.current.controller !== controller) current.current = { controller };\n const owner = current.current;\n const [form, setForm] = useState({ owner, draftTitle: '', notice: '' });\n const draftTitle = form.owner === owner ? form.draftTitle : '';\n const notice = form.owner === owner ? form.notice : '';\n const updateForm = (patch: Partial<{ draftTitle: string; notice: string }>) => {\n if (current.current !== owner) return;\n setForm(previous => ({ ...(previous.owner === owner ? previous : { owner, draftTitle: '', notice: '' }), ...patch }));\n };\n const setDraftTitle = (value: string) => updateForm({ draftTitle: value });\n const setNotice = (value: string) => updateForm({ notice: value });\n const inputId = useId();\n const disabled = state.readOnly || state.lifecycle === 'disposed';\n const edit = (operations: readonly TaskListOperation[]) => {\n const result = controller.actions.edit(controller.actions.selection(), operations);\n setNotice(result.kind === 'applied' ? '' : `${result.kind}: ${result.reason}`);\n return result;\n };\n const run = async (action: () => Promise<ReadResult | SaveResult>) => {\n try {\n const result = await action();\n setNotice(result.kind === 'available' || result.kind === 'saved' ? ''\n : result.kind === 'missing' ? 'missing: Resource is missing'\n : `${result.kind}${'reason' in result ? `: ${result.reason}` : ''}`);\n }\n catch (error) { setNotice(String(error)); }\n };\n const save = state.save.kind === 'settled' ? state.save.result : undefined;\n return <Card className={['boring-task-list-recipe', className].filter(Boolean).join(' ')} data-boring=\"task-list-viewer\">\n <h2>{title}</h2>\n <p role=\"status\" aria-live=\"polite\">{state.lifecycle === 'disposed' ? 'Closed' : state.readOnly ? 'Read only' : state.dirty ? 'Unsaved changes' : 'Saved'}\n {state.save.kind === 'pending' ? ' · Saving' : save ? ` · ${save.kind}${'reason' in save ? `: ${save.reason}` : ''}` : ''}\n {state.remote ? ' · Remote changes available' : ''}</p>\n <ul>{state.document.items.map(item => <li key={item.id} data-task-id={item.id}>\n <label><Checkbox aria-label={`Complete ${item.title}`} checked={item.completed} disabled={disabled}\n onCheckedChange={completed => edit([{ kind: 'set-completed', id: item.id, completed }])} />{item.title}</label>\n <Button variant=\"ghost\" aria-label={`Remove ${item.title}`} disabled={disabled} onClick={() => edit([{ kind: 'remove', id: item.id }])}>Remove</Button>\n </li>)}</ul>\n <form onSubmit={event => { event.preventDefault(); if (edit([{ kind: 'add', id: randomUUID(), title: draftTitle }]).kind === 'applied') setDraftTitle(''); }}>\n <label htmlFor={inputId}>New task</label><Input id={inputId} value={draftTitle} disabled={disabled} onChange={event => setDraftTitle(event.target.value)} />\n <Button variant=\"default\" type=\"submit\" disabled={disabled || !draftTitle.trim()}>Add task</Button>\n </form>\n <div className=\"boring-task-list-actions\">\n <Button variant=\"default\" disabled={disabled || !state.dirty || state.save.kind === 'pending'} onClick={() => void run(() => controller.flush(controller.actions.selection()))}>Save</Button>\n <Button variant=\"ghost\" disabled={state.lifecycle === 'disposed'} onClick={() => void run(controller.actions.refresh)}>Refresh</Button>\n <Button variant=\"ghost\" disabled={disabled || (!state.dirty && !state.remote)} onClick={() => void run(controller.actions.discardToRemote)}>Discard</Button>\n <Button variant=\"ghost\" disabled={state.lifecycle === 'disposed' || save?.kind !== 'unknown'} onClick={() => void run(controller.actions.reconcile)}>Reconcile</Button>\n <Button variant=\"ghost\" disabled={state.lifecycle === 'disposed' || save?.kind !== 'unknown'} onClick={() => void run(controller.actions.abandon)}>Abandon save</Button>\n </div>\n {notice && <p role=\"alert\">{notice}</p>}\n </Card>;\n}\n",
28
+ "content": "'use client';\n\nimport { randomUUID } from '@hachej/boring-files/platform';\nimport React, { useId, useRef, useState, useSyncExternalStore } from 'react';\nimport { Button } from '@/components/ui/button';\nimport { Checkbox } from '@/components/ui/checkbox';\nimport { Card } from '@/components/ui/card';\nimport { Input } from '@/components/ui/input';\nimport type { ReadResult } from '@hachej/boring-files';\nimport type { SaveResult } from '@hachej/boring-ui-kit/resources';\nimport type { TaskListController } from './task-list-controller';\nimport type { TaskListOperation } from './task-list-document';\n\nexport interface TaskListViewerProps {\n readonly controller: TaskListController;\n readonly title?: string;\n readonly className?: string;\n}\n\nexport function TaskListViewer({ controller, title = 'Tasks', className }: TaskListViewerProps) {\n const state = useSyncExternalStore(controller.subscribe, controller.getSnapshot, controller.getSnapshot);\n // A fresh owner per controller: drafts and notices of another controller are dropped, and a late result of one is ignored.\n const current = useRef({ controller });\n // eslint-disable-next-line react-hooks/refs -- an identity cache keyed by controller: a new controller swaps in a new owner object, never setState in render\n if (current.current.controller !== controller) current.current = { controller };\n // eslint-disable-next-line react-hooks/refs -- the owner token for this controller (see above); late results compare against it\n const owner = current.current;\n const [form, setForm] = useState({ owner, draftTitle: '', notice: '' });\n const draftTitle = form.owner === owner ? form.draftTitle : '';\n const notice = form.owner === owner ? form.notice : '';\n const updateForm = (patch: Partial<{ draftTitle: string; notice: string }>) => {\n if (current.current !== owner) return;\n setForm(previous => ({ ...(previous.owner === owner ? previous : { owner, draftTitle: '', notice: '' }), ...patch }));\n };\n const setDraftTitle = (value: string) => updateForm({ draftTitle: value });\n const setNotice = (value: string) => updateForm({ notice: value });\n const inputId = useId();\n const disabled = state.readOnly || state.lifecycle === 'disposed';\n const edit = (operations: readonly TaskListOperation[]) => {\n const result = controller.actions.edit(controller.actions.selection(), operations);\n setNotice(result.kind === 'applied' ? '' : `${result.kind}: ${result.reason}`);\n return result;\n };\n const run = async (action: () => Promise<ReadResult | SaveResult>) => {\n try {\n const result = await action();\n setNotice(result.kind === 'available' || result.kind === 'saved' ? ''\n : result.kind === 'missing' ? 'missing: Resource is missing'\n : `${result.kind}${'reason' in result ? `: ${result.reason}` : ''}`);\n }\n catch (error) { setNotice(String(error)); }\n };\n const save = state.save.kind === 'settled' ? state.save.result : undefined;\n return <Card className={['boring-task-list-recipe', className].filter(Boolean).join(' ')} data-boring=\"task-list-viewer\">\n <h2>{title}</h2>\n <p role=\"status\" aria-live=\"polite\">{state.lifecycle === 'disposed' ? 'Closed' : state.readOnly ? 'Read only' : state.dirty ? 'Unsaved changes' : 'Saved'}\n {state.save.kind === 'pending' ? ' · Saving' : save ? ` · ${save.kind}${'reason' in save ? `: ${save.reason}` : ''}` : ''}\n {state.remote ? ' · Remote changes available' : ''}</p>\n <ul>{state.document.items.map(item => <li key={item.id} data-task-id={item.id}>\n <label><Checkbox aria-label={`Complete ${item.title}`} checked={item.completed} disabled={disabled}\n onCheckedChange={completed => edit([{ kind: 'set-completed', id: item.id, completed }])} />{item.title}</label>\n <Button variant=\"ghost\" aria-label={`Remove ${item.title}`} disabled={disabled} onClick={() => edit([{ kind: 'remove', id: item.id }])}>Remove</Button>\n </li>)}</ul>\n <form onSubmit={event => { event.preventDefault(); if (edit([{ kind: 'add', id: randomUUID(), title: draftTitle }]).kind === 'applied') setDraftTitle(''); }}>\n <label htmlFor={inputId}>New task</label><Input id={inputId} value={draftTitle} disabled={disabled} onChange={event => setDraftTitle(event.target.value)} />\n <Button variant=\"default\" type=\"submit\" disabled={disabled || !draftTitle.trim()}>Add task</Button>\n </form>\n <div className=\"boring-task-list-actions\">\n <Button variant=\"default\" disabled={disabled || !state.dirty || state.save.kind === 'pending'} onClick={() => void run(() => controller.flush(controller.actions.selection()))}>Save</Button>\n <Button variant=\"ghost\" disabled={state.lifecycle === 'disposed'} onClick={() => void run(controller.actions.refresh)}>Refresh</Button>\n <Button variant=\"ghost\" disabled={disabled || (!state.dirty && !state.remote)} onClick={() => void run(controller.actions.discardToRemote)}>Discard</Button>\n <Button variant=\"ghost\" disabled={state.lifecycle === 'disposed' || save?.kind !== 'unknown'} onClick={() => void run(controller.actions.reconcile)}>Reconcile</Button>\n <Button variant=\"ghost\" disabled={state.lifecycle === 'disposed' || save?.kind !== 'unknown'} onClick={() => void run(controller.actions.abandon)}>Abandon save</Button>\n </div>\n {notice && <p role=\"alert\">{notice}</p>}\n </Card>;\n}\n",
35
29
  "type": "registry:component",
36
30
  "target": "components/task-list-viewer/task-list-viewer.tsx"
37
31
  }
@@ -4,18 +4,16 @@
4
4
  "title": "Version history rows",
5
5
  "description": "Shared view-only version rows and menu metadata with relative time, provenance and Current. No file reads, publication or restoration.",
6
6
  "dependencies": [
7
- "@hachej/boring-files@^0.4.24",
7
+ "@hachej/boring-files@^0.4.26",
8
8
  "react@^19.0.0"
9
9
  ],
10
10
  "registryDependencies": [
11
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.24/r/theme.json",
12
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.24/r/button.json",
13
- "button"
11
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.26/r/button.json"
14
12
  ],
15
13
  "files": [
16
14
  {
17
15
  "path": "registry/version-history/version-history.tsx",
18
- "content": "'use client';\n\nimport type { SavedRevision } from '@hachej/boring-files/revision';\nimport { Button } from '@/components/ui/button';\nimport { ActionMenuItemContent } from '../button/actions';\nimport type { MenuItem } from '../button/actions';\n\nexport const defaultVersionHistoryLabels = {\n versionHistory: 'Version history',\n currentVersion: 'Current',\n versionYou: 'You',\n versionAgent: 'Agent',\n versionObserved: 'External change',\n versionUnknownAuthor: 'Saved version',\n versionJustNow: 'Just now',\n versionMinutesAgo: (count: number) => `${count} min ago`,\n versionHoursAgo: (count: number) => `${count} h ago`,\n versionYesterdayAt: (time: string) => `Yesterday ${time}`,\n versionToday: 'Today',\n versionYesterday: 'Yesterday',\n};\nexport type VersionHistoryLabels = typeof defaultVersionHistoryLabels;\nexport const frenchVersionHistoryLabels: VersionHistoryLabels = {\n versionHistory: 'Historique des versions',\n currentVersion: 'Actuelle',\n versionYou: 'Vous',\n versionAgent: 'Agent',\n versionObserved: 'Modification externe',\n versionUnknownAuthor: 'Version enregistrée',\n versionJustNow: 'À l’instant',\n versionMinutesAgo: count => `Il y a ${count} min`,\n versionHoursAgo: count => `Il y a ${count} h`,\n versionYesterdayAt: time => `Hier ${time}`,\n versionToday: 'Aujourd’hui',\n versionYesterday: 'Hier',\n};\n\nexport interface VersionHistoryEntry {\n readonly id: string;\n readonly label: string;\n readonly latest?: boolean;\n readonly savedAt?: number;\n readonly source?: SavedRevision['source'];\n /** Only shown when the host has measured the change. Never inferred from a revision id. */\n readonly changeSummary?: string;\n}\n\nfunction dayLabel(time: number, now: number, labels: VersionHistoryLabels): string {\n const date = new Date(time), today = new Date(now), yesterday = new Date(now);\n yesterday.setDate(yesterday.getDate() - 1);\n return date.toDateString() === today.toDateString() ? labels.versionToday\n : date.toDateString() === yesterday.toDateString() ? labels.versionYesterday\n : date.toLocaleDateString();\n}\n\nfunction relativeTime(time: number, now: number, labels: VersionHistoryLabels): string {\n const minutes = Math.max(0, Math.floor((now - time) / 60000));\n if (minutes < 1) return labels.versionJustNow;\n if (minutes < 60) return labels.versionMinutesAgo(minutes);\n const yesterday = new Date(now); yesterday.setDate(yesterday.getDate() - 1);\n if (new Date(time).toDateString() === yesterday.toDateString()) return labels.versionYesterdayAt(new Date(time).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false }));\n if (minutes < 1440) return labels.versionHoursAgo(Math.floor(minutes / 60));\n return new Date(time).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' });\n}\n\n/** Current is the displayed revision, not necessarily the latest one. Shared by menus and inline rows. */\nexport function versionHistoryItems({ versions, current, onSelect, labels, now, testId }: {\n readonly versions: readonly VersionHistoryEntry[];\n readonly current: string;\n readonly onSelect: (id: string) => void;\n readonly labels: VersionHistoryLabels;\n readonly now: number;\n readonly testId: string;\n}): readonly MenuItem[] {\n return versions.map(version => {\n const author = version.source === 'publish' ? labels.versionYou : version.source === 'agent' ? labels.versionAgent : version.source === 'observed' ? labels.versionObserved : labels.versionUnknownAuthor;\n return { id: version.id, label: version.savedAt !== undefined ? relativeTime(version.savedAt, now, labels) : version.label,\n description: version.changeSummary ? `${author} · ${version.changeSummary}` : author,\n ...(version.savedAt !== undefined ? { group: dayLabel(version.savedAt, now, labels) } : {}),\n ...(version.id === current ? { hint: labels.currentVersion } : {}), checked: version.id === current,\n testId: `${testId}-item`, onSelect: () => onSelect(version.id) };\n });\n}\n\n/** Inline form of the viewer menu's rows. The consumer's shadcn Button owns each selection control. */\nexport function VersionHistoryRows({ versions, current, onSelect, labels: given, testId = 'version-history' }: {\n readonly versions: readonly VersionHistoryEntry[];\n readonly current: string;\n readonly onSelect: (id: string) => void;\n readonly labels?: Partial<VersionHistoryLabels>;\n readonly testId?: string;\n}) {\n const labels = { ...defaultVersionHistoryLabels, ...given };\n const items = versionHistoryItems({ versions, current, onSelect, labels, now: Date.now(), testId });\n return <ul data-boring=\"version-history\" className=\"m-0 list-none p-0\">\n {items.map((item, index) => <li key={item.id}>\n {item.group && item.group !== items[index - 1]?.group && <p className=\"px-2 pt-3 pb-1 text-xs text-muted-foreground\">{item.group}</p>}\n <Button type=\"button\" variant=\"ghost\" aria-pressed={item.checked} data-item={item.id} data-testid={item.testId} onClick={item.onSelect} className=\"h-auto min-h-11 w-full justify-start gap-2 text-left\">\n <ActionMenuItemContent item={item} testId={testId} />\n </Button>\n </li>)}\n </ul>;\n}\n",
16
+ "content": "'use client';\n\nimport type { SavedRevision } from '@hachej/boring-files/revision';\nimport { Button } from '@/components/ui/button';\nimport { ActionMenuItemContent } from '../button/actions';\nimport type { MenuItem } from '../button/actions';\n\nexport const defaultVersionHistoryLabels = {\n versionHistory: 'Version history',\n currentVersion: 'Current',\n versionYou: 'You',\n versionAgent: 'Agent',\n versionObserved: 'External change',\n versionUnknownAuthor: 'Saved version',\n versionJustNow: 'Just now',\n versionMinutesAgo: (count: number) => `${count} min ago`,\n versionHoursAgo: (count: number) => `${count} h ago`,\n versionYesterdayAt: (time: string) => `Yesterday ${time}`,\n versionToday: 'Today',\n versionYesterday: 'Yesterday',\n};\nexport type VersionHistoryLabels = typeof defaultVersionHistoryLabels;\nexport const frenchVersionHistoryLabels: VersionHistoryLabels = {\n versionHistory: 'Historique des versions',\n currentVersion: 'Actuelle',\n versionYou: 'Vous',\n versionAgent: 'Agent',\n versionObserved: 'Modification externe',\n versionUnknownAuthor: 'Version enregistrée',\n versionJustNow: 'À l’instant',\n versionMinutesAgo: count => `Il y a ${count} min`,\n versionHoursAgo: count => `Il y a ${count} h`,\n versionYesterdayAt: time => `Hier ${time}`,\n versionToday: 'Aujourd’hui',\n versionYesterday: 'Hier',\n};\n\nexport interface VersionHistoryEntry {\n readonly id: string;\n readonly label: string;\n readonly latest?: boolean;\n readonly savedAt?: number;\n readonly source?: SavedRevision['source'];\n /** Only shown when the host has measured the change. Never inferred from a revision id. */\n readonly changeSummary?: string;\n}\n\nfunction dayLabel(time: number, now: number, labels: VersionHistoryLabels): string {\n const date = new Date(time), today = new Date(now), yesterday = new Date(now);\n yesterday.setDate(yesterday.getDate() - 1);\n return date.toDateString() === today.toDateString() ? labels.versionToday\n : date.toDateString() === yesterday.toDateString() ? labels.versionYesterday\n : date.toLocaleDateString();\n}\n\nfunction relativeTime(time: number, now: number, labels: VersionHistoryLabels): string {\n const minutes = Math.max(0, Math.floor((now - time) / 60000));\n if (minutes < 1) return labels.versionJustNow;\n if (minutes < 60) return labels.versionMinutesAgo(minutes);\n const yesterday = new Date(now); yesterday.setDate(yesterday.getDate() - 1);\n if (new Date(time).toDateString() === yesterday.toDateString()) return labels.versionYesterdayAt(new Date(time).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false }));\n if (minutes < 1440) return labels.versionHoursAgo(Math.floor(minutes / 60));\n return new Date(time).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' });\n}\n\n/** Current is the displayed revision, not necessarily the latest one. Shared by menus and inline rows. */\nexport function versionHistoryItems({ versions, current, onSelect, labels, now, testId }: {\n readonly versions: readonly VersionHistoryEntry[];\n readonly current: string;\n readonly onSelect: (id: string) => void;\n readonly labels: VersionHistoryLabels;\n readonly now: number;\n readonly testId: string;\n}): readonly MenuItem[] {\n return versions.map(version => {\n const author = version.source === 'publish' ? labels.versionYou : version.source === 'agent' ? labels.versionAgent : version.source === 'observed' ? labels.versionObserved : labels.versionUnknownAuthor;\n return { id: version.id, label: version.savedAt !== undefined ? relativeTime(version.savedAt, now, labels) : version.label,\n description: version.changeSummary ? `${author} · ${version.changeSummary}` : author,\n ...(version.savedAt !== undefined ? { group: dayLabel(version.savedAt, now, labels) } : {}),\n ...(version.id === current ? { hint: labels.currentVersion } : {}), checked: version.id === current,\n testId: `${testId}-item`, onSelect: () => onSelect(version.id) };\n });\n}\n\n/** Inline form of the viewer menu's rows. The consumer's shadcn Button owns each selection control. */\nexport function VersionHistoryRows({ versions, current, onSelect, labels: given, testId = 'version-history' }: {\n readonly versions: readonly VersionHistoryEntry[];\n readonly current: string;\n readonly onSelect: (id: string) => void;\n readonly labels?: Partial<VersionHistoryLabels>;\n readonly testId?: string;\n}) {\n const labels = { ...defaultVersionHistoryLabels, ...given };\n // eslint-disable-next-line react-hooks/purity -- relative times (\"2 min ago\") read the clock each render on purpose\n const items = versionHistoryItems({ versions, current, onSelect, labels, now: Date.now(), testId });\n return <ul data-boring=\"version-history\" className=\"m-0 list-none p-0\">\n {items.map((item, index) => <li key={item.id}>\n {item.group && item.group !== items[index - 1]?.group && <p className=\"px-2 pt-3 pb-1 text-xs text-muted-foreground\">{item.group}</p>}\n <Button type=\"button\" variant=\"ghost\" aria-pressed={item.checked} data-item={item.id} data-testid={item.testId} onClick={item.onSelect} className=\"h-auto min-h-11 w-full justify-start gap-2 text-left\">\n <ActionMenuItemContent item={item} testId={testId} />\n </Button>\n </li>)}\n </ul>;\n}\n",
19
17
  "type": "registry:component",
20
18
  "target": "components/version-history/version-history.tsx"
21
19
  }
@@ -8,9 +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.24/r/viewer-frame.json",
12
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.24/r/pi-chat.json",
13
- "table"
11
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.26/r/viewer-frame.json",
12
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.26/r/pi-chat.json"
14
13
  ],
15
14
  "files": [
16
15
  {
@@ -4,23 +4,15 @@
4
4
  "title": "Viewer frame",
5
5
  "description": "The standard viewer top bar every viewer draws in (title, status, viewer controls, Refresh, Share, Copy, Download, Open in new tab, Close, overflow menu), its window labels, download, share and media helpers and the sandboxed interactive HTML frame. The viewers' styles live here. Each viewer is its own item (viewer-markdown, viewer-html, viewer-image, viewer-pdf, viewer-code) with a cheap kind module and a view loaded on first open.",
6
6
  "dependencies": [
7
- "@hachej/boring-ui-kit@^0.4.24",
7
+ "@hachej/boring-ui-kit@^0.4.26",
8
8
  "react@^19.0.0",
9
9
  "react-dom@^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.24/r/theme.json",
14
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.24/r/utils.json",
15
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.24/r/button.json",
16
- "button",
17
- "toggle",
18
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.24/r/version-history.json",
19
- "toggle-group",
20
- "dialog",
21
- "textarea",
22
- "badge",
23
- "dropdown-menu"
13
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.26/r/utils.json",
14
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.26/r/button.json",
15
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.26/r/version-history.json"
24
16
  ],
25
17
  "files": [
26
18
  {
@@ -37,13 +29,13 @@
37
29
  },
38
30
  {
39
31
  "path": "registry/viewer-frame/media.ts",
40
- "content": "import { useEffect, useMemo, useState } from 'react';\n\n/** What a read-only media viewer shows: bytes or a Blob (the viewer owns the object URL it makes) or a URL the host owns. */\nexport interface MediaSource {\n readonly bytes?: Uint8Array | ArrayBuffer;\n readonly blob?: Blob;\n readonly url?: string;\n}\n\n/** A Blob for the source, or `undefined` when the host gave a URL (or nothing). */\nexport function mediaBlob(source: MediaSource, mediaType: string): Blob | undefined {\n if (source.blob) return source.blob;\n if (source.bytes) return new Blob([source.bytes as BlobPart], { type: mediaType });\n return undefined;\n}\n\n/** An object URL for the source, revoked when the source changes or the viewer unmounts. A host `url` is passed through untouched. */\nexport function useMediaUrl(source: MediaSource, mediaType: string): { url: string | undefined; size: number | undefined; blob: Blob | undefined } {\n const blob = useMemo(() => mediaBlob(source, mediaType), [source.blob, source.bytes, mediaType]); // eslint-disable-line react-hooks/exhaustive-deps\n const [made, setMade] = useState<string>();\n useEffect(() => {\n if (!blob) { setMade(undefined); return; }\n const url = URL.createObjectURL(blob);\n setMade(url);\n return () => { URL.revokeObjectURL(url); setMade(current => current === url ? undefined : current); };\n }, [blob]);\n return { url: blob ? made : source.url, size: blob?.size, blob };\n}\n\nexport function formatBytes(size: number): string {\n if (size < 1024) return `${size} B`;\n if (size < 1024 * 1024) return `${(size / 1024).toFixed(size < 10240 ? 1 : 0)} KB`;\n return `${(size / 1024 / 1024).toFixed(1)} MB`;\n}\n",
32
+ "content": "import { useEffect, useMemo, useState } from 'react';\n\n/** What a read-only media viewer shows: bytes or a Blob (the viewer owns the object URL it makes) or a URL the host owns. */\nexport interface MediaSource {\n readonly bytes?: Uint8Array | ArrayBuffer;\n readonly blob?: Blob;\n readonly url?: string;\n}\n\n/** A Blob for the source, or `undefined` when the host gave a URL (or nothing). */\nexport function mediaBlob(source: MediaSource, mediaType: string): Blob | undefined {\n if (source.blob) return source.blob;\n if (source.bytes) return new Blob([source.bytes as BlobPart], { type: mediaType });\n return undefined;\n}\n\n/** An object URL for the source, revoked when the source changes or the viewer unmounts. A host `url` is passed through untouched. */\nexport function useMediaUrl(source: MediaSource, mediaType: string): { url: string | undefined; size: number | undefined; blob: Blob | undefined } {\n const { blob: given, bytes } = source;\n const blob = useMemo(() => mediaBlob({ blob: given, bytes }, mediaType), [given, bytes, mediaType]);\n // The object URL of the current blob only: a URL made for an earlier blob is never returned, and is revoked with it.\n const [made, setMade] = useState<{ readonly blob: Blob; readonly url: string }>();\n useEffect(() => {\n if (!blob) return undefined;\n const url = URL.createObjectURL(blob);\n // eslint-disable-next-line react-hooks/set-state-in-effect -- the object URL is an external resource owned by this effect run; it exists only after commit and is revoked in the cleanup\n setMade({ blob, url });\n return () => { URL.revokeObjectURL(url); setMade(current => current?.url === url ? undefined : current); };\n }, [blob]);\n return { url: blob ? (made?.blob === blob ? made.url : undefined) : source.url, size: blob?.size, blob };\n}\n\nexport function formatBytes(size: number): string {\n if (size < 1024) return `${size} B`;\n if (size < 1024 * 1024) return `${(size / 1024).toFixed(size < 10240 ? 1 : 0)} KB`;\n return `${(size / 1024 / 1024).toFixed(1)} MB`;\n}\n",
41
33
  "type": "registry:lib",
42
34
  "target": "components/viewer-frame/media.ts"
43
35
  },
44
36
  {
45
37
  "path": "registry/viewer-frame/menu.tsx",
46
- "content": "'use client';\n\nimport { useState } from 'react';\nimport { HistoryIcon } from 'lucide-react';\nimport type { ReactNode } from 'react';\nimport { Button } from '@/components/ui/button';\nimport { DropdownMenu, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';\nimport { ActionMenuContent, useMenuPortal } from '../button/actions';\nimport type { MenuItem } from '../button/actions';\nimport { versionHistoryItems } from '../version-history/version-history';\nimport type { VersionHistoryEntry } from '../version-history/version-history';\nimport { useViewerText } from './viewer-window';\n\nexport type ViewerMenuItem = MenuItem;\nexport interface ViewerActionsProps {\n readonly label: string;\n readonly icon: ReactNode;\n readonly items: readonly MenuItem[];\n readonly testId?: string;\n readonly align?: 'start' | 'end';\n readonly className?: string;\n readonly children?: ReactNode;\n readonly onOpenChange?: (open: boolean) => void;\n}\nexport type ViewerVersion = VersionHistoryEntry;\n\n/** The consumer's shadcn Base UI dropdown supplies positioning, dismissal and keyboard navigation. */\nexport function ViewerActions({ label, icon, items, testId = 'viewer-menu', align = 'end', className, children, onOpenChange }: ViewerActionsProps) {\n const portal = useMenuPortal();\n return <DropdownMenu onOpenChange={open => onOpenChange?.(open)}>\n <DropdownMenuTrigger render={<Button ref={portal.ref} variant=\"ghost\" size=\"icon-sm\" aria-label={label} title={label} data-testid={testId} className={className}>{icon}</Button>} />\n <ActionMenuContent label={label} testId={testId} items={items} align={align} container={portal.container}>{children}</ActionMenuContent>\n </DropdownMenu>;\n}\n\n/** Saved versions are view-only choices. Current is the displayed revision, not necessarily the latest one. */\nexport function ViewerVersions({ versions, current, onSelect, testId = 'viewer-versions', label }: {\n readonly versions: readonly ViewerVersion[];\n readonly current: string;\n readonly onSelect: (id: string) => void;\n readonly testId?: string;\n readonly label?: string;\n}) {\n const { labels } = useViewerText();\n const [now, setNow] = useState(Date.now);\n return <ViewerActions label={label ?? labels.versionHistory} testId={testId} align=\"end\" icon={<HistoryIcon data-icon=\"inline-start\" aria-hidden=\"true\" />} onOpenChange={open => { if (open) setNow(Date.now()); }}\n items={versionHistoryItems({ versions, current, onSelect, labels, now, testId })} />;\n}\n",
38
+ "content": "'use client';\n\nimport { useState } from 'react';\nimport { HistoryIcon } from 'lucide-react';\nimport type { ReactNode } from 'react';\nimport { Button } from '@/components/ui/button';\nimport { DropdownMenu, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';\nimport { ActionMenuContent, useMenuPortal } from '../button/actions';\nimport type { MenuItem } from '../button/actions';\nimport { versionHistoryItems } from '../version-history/version-history';\nimport type { VersionHistoryEntry } from '../version-history/version-history';\nimport { useViewerText } from './viewer-window';\n\nexport type ViewerMenuItem = MenuItem;\nexport interface ViewerActionsProps {\n readonly label: string;\n readonly icon: ReactNode;\n readonly items: readonly MenuItem[];\n readonly testId?: string;\n readonly align?: 'start' | 'end';\n readonly className?: string;\n readonly children?: ReactNode;\n readonly onOpenChange?: (open: boolean) => void;\n}\nexport type ViewerVersion = VersionHistoryEntry;\n\n/** The consumer's shadcn Base UI dropdown supplies positioning, dismissal and keyboard navigation. */\nexport function ViewerActions({ label, icon, items, testId = 'viewer-menu', align = 'end', className, children, onOpenChange }: ViewerActionsProps) {\n const { ref: portalRef, container: portalContainer } = useMenuPortal();\n return <DropdownMenu onOpenChange={open => onOpenChange?.(open)}>\n <DropdownMenuTrigger render={<Button ref={portalRef} variant=\"ghost\" size=\"icon-sm\" aria-label={label} title={label} data-testid={testId} className={className}>{icon}</Button>} />\n <ActionMenuContent label={label} testId={testId} items={items} align={align} container={portalContainer}>{children}</ActionMenuContent>\n </DropdownMenu>;\n}\n\n/** Saved versions are view-only choices. Current is the displayed revision, not necessarily the latest one. */\nexport function ViewerVersions({ versions, current, onSelect, testId = 'viewer-versions', label }: {\n readonly versions: readonly ViewerVersion[];\n readonly current: string;\n readonly onSelect: (id: string) => void;\n readonly testId?: string;\n readonly label?: string;\n}) {\n const { labels } = useViewerText();\n const [now, setNow] = useState(Date.now);\n return <ViewerActions label={label ?? labels.versionHistory} testId={testId} align=\"end\" icon={<HistoryIcon data-icon=\"inline-start\" aria-hidden=\"true\" />} onOpenChange={open => { if (open) setNow(Date.now()); }}\n items={versionHistoryItems({ versions, current, onSelect, labels, now, testId })} />;\n}\n",
47
39
  "type": "registry:component",
48
40
  "target": "components/viewer-frame/menu.tsx"
49
41
  },
@@ -55,7 +47,7 @@
55
47
  },
56
48
  {
57
49
  "path": "registry/viewer-frame/viewer-frame.tsx",
58
- "content": "'use client';\n\nimport { createContext, useCallback, useContext, useEffect, useLayoutEffect, useRef, useState } from 'react';\nimport type { ReactNode } from 'react';\nimport { ArrowLeftIcon, CheckIcon, CircleIcon, CopyIcon, DownloadIcon, ExternalLinkIcon, RefreshCwIcon } from 'lucide-react';\nimport { Button } from '@/components/ui/button';\nimport { Toggle } from '@/components/ui/toggle';\nimport { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';\nimport { Badge } from '@/components/ui/badge';\nimport { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';\nimport { Textarea } from '@/components/ui/textarea';\nimport { DropdownMenuSeparator } from '@/components/ui/dropdown-menu';\nimport { ActionButton, actionMenuItem, headerActions, menuActions } from '../button/actions';\nimport type { BlockAction } from '../button/actions';\nimport { ViewerActions } from './menu';\nimport type { ViewerMenuItem } from './menu';\nimport { useViewerText, useViewerWindow } from './viewer-window';\nimport type { ViewerIcons, ViewerLabels } from './viewer-window';\nimport { cn } from '../utils/utils';\nimport type { ManualCopyError } from '../utils/utils';\nimport type { ViewerShare } from './share';\n\nexport type { ViewerShare, ViewerShareRequest, ViewerShareResult } from './share';\nexport { ViewerWindowProvider, defaultViewerIcons, defaultViewerLabels, useViewerText } from './viewer-window';\nexport type { ViewerIcons, ViewerLabels, ViewerWindow } from './viewer-window';\n\nexport type ViewerTone = 'neutral' | 'success' | 'warning' | 'danger';\n/**\n * `kind` says what the status is whatever its (translated) label: the bar hides a plain `saved` (unless `showSavedStatus`) and shows\n * `read-only` as a fact. The bar shows a dot in the default shadcn roles: `success` primary, `warning` and `neutral` muted-foreground, `danger` destructive. Only a\n * `danger` status also shows its words, because the person must act on it; the others keep the label in the tooltip and for screen readers.\n */\nexport interface ViewerStatus { readonly label: string; readonly tone?: ViewerTone; readonly kind?: 'saved' | 'read-only' | undefined }\n\nexport interface ViewerFrameProps {\n /** What is shown, for example the file name. */\n readonly title: string;\n /** Type, version, path, size: short facts under the title. */\n readonly subtitle?: ReactNode;\n /** Saved, Unsaved, Saving, Changed elsewhere, Read-only. */\n readonly status?: ViewerStatus | undefined;\n /** Keep a quiet saved acknowledgement visible, for autosaving editors. */\n readonly showSavedStatus?: boolean | undefined;\n /** Opaque identifiers handed to `onShare` so the host can build a link to this exact thing. */\n readonly target?: unknown;\n readonly revision?: string | undefined;\n /** Leading header content, for example Back before the file title. */\n readonly headerStart?: ReactNode;\n /** Viewer-specific controls that stay visible: a mode toggle, Save while there are edits, zoom steps, a version switcher when there are several. */\n readonly controls?: ReactNode;\n /**\n * The one control that matters most here (Comment on a version to try, for example), kept in the bar at every width. With the compact\n * header a phone folds `controls` into the \"…\" menu; this one stays beside it, so the main action is one tap away. Test id `<testId>-main`.\n */\n readonly mainControl?: ReactNode;\n /** Viewer-specific secondary actions, listed in the \"…\" menu after the standard ones. */\n readonly menu?: readonly ViewerMenuItem[];\n /** Viewer-specific native dropdown groups, before the standard secondary actions. */\n readonly menuContent?: ReactNode;\n /**\n * The host's actions for this bar, after the window's (`ViewerWindow.actions`): `header` ones as buttons beside Share, `menu` ones at the\n * end of the \"…\" menu. Test ids `<testId>-<id>`.\n */\n readonly actions?: readonly BlockAction[] | undefined;\n /** This bar's words and icons, over the window's and the defaults (`defaultViewerLabels`, `defaultViewerIcons`). */\n readonly labels?: Partial<ViewerLabels> | undefined;\n readonly icons?: Partial<ViewerIcons> | undefined;\n /** Each standard action appears only when its handler is supplied. Share is always visible; Reload, Copy, Download and Open in new tab live in the \"…\" menu. */\n readonly onRefresh?: () => unknown;\n readonly onShare?: ViewerShare;\n readonly onCopy?: () => unknown;\n readonly onDownload?: () => unknown;\n readonly onOpenInNewTab?: () => unknown;\n readonly onClose?: () => unknown;\n /** Prefix of the `data-testid` of the bar and its actions (`viewer-share`, `viewer-more`, and `viewer-refresh` for the menu's Reload, ...). */\n readonly testId?: string;\n readonly titleTestId?: string;\n /** Quiet one-row bar with secondary actions in the menu. False retains the former visible Share/fullscreen/close layout. */\n readonly compactHeader?: boolean;\n readonly className?: string;\n readonly children?: ReactNode;\n}\n\n\n/** Narrow panes drop optional controls. */\nconst TIGHT_WIDTH = { touch: 440, pointer: 340 };\nconst touchScreen = () => typeof matchMedia === 'function' && matchMedia('(max-width: 639px), (pointer: coarse)').matches;\n\n/** How much room the bar has, for a pane that wants to drop optional controls (the zoom percentage, for example) on a narrow panel. */\nexport interface ViewerBarSpace { readonly tight: boolean }\nconst ViewerBarContext = createContext<ViewerBarSpace>({ tight: false });\nexport const useViewerBar = (): ViewerBarSpace => useContext(ViewerBarContext);\nconst NOTICE_MS = 2600;\n\n/**\n * One choice of a mode toggle, for example Rich | Source. Icon only: `label` is the accessible name and the tooltip, `text` the short word\n * for hosts that want it (shown when there is no icon). A disabled choice shows `reason` as its tooltip.\n */\nexport interface ViewerToggleOption<T extends string> { readonly id: T; readonly label: string; readonly text: string; readonly icon?: ReactNode; readonly disabled?: boolean; readonly reason?: string | undefined; readonly testId?: string }\n\n/** A segmented control over the consumer's outlined ToggleGroup: the pressed segment is filled with a full-contrast icon, the others muted. */\nexport function ViewerToggle<T extends string>({ label, value, options, onChange }: { readonly label: string; readonly value: T; readonly options: readonly ViewerToggleOption<T>[]; readonly onChange: (id: T) => void }) {\n return <ToggleGroup aria-label={label} variant=\"outline\" size=\"sm\" spacing={1} value={[value]} onValueChange={values => {\n const selected = options.find(option => option.id === values[0] && !option.disabled);\n if (selected && selected.id !== value) onChange(selected.id);\n }}>\n {options.map(option => <ToggleGroupItem key={option.id} value={option.id} aria-label={option.label} title={option.disabled && option.reason ? option.reason : option.label} disabled={option.disabled}\n className=\"disabled:pointer-events-auto disabled:cursor-not-allowed\" data-testid={option.testId} data-mode={option.id}>\n <span className={cn('inline-flex items-center', value !== option.id && 'text-muted-foreground')}>{option.icon ?? option.text}</span></ToggleGroupItem>)}\n </ToggleGroup>;\n}\n\n/** The copy helper throws this when the browser allowed no automatic copy at all; the person copies by hand instead. */\nconst needsManualCopy = (error: unknown): error is ManualCopyError => (error as { name?: string } | null)?.name === 'ManualCopyError' && typeof (error as { text?: unknown }).text === 'string';\n\n/** Last resort for Copy and Share: the text, already selected, so Ctrl/Cmd+C or the long-press menu finishes the job. */\nfunction ManualCopy({ testId, link, text, onClose, labels }: { readonly testId: string; readonly link: boolean; readonly text: string; readonly onClose: () => void; readonly labels: ViewerLabels }) {\n const field = useRef<HTMLTextAreaElement>(null);\n return <Dialog open onOpenChange={open => { if (!open) onClose(); }}>\n <DialogContent showCloseButton={false} className=\"boring-viewer\" data-testid={`${testId}-manual-copy`} initialFocus={field}>\n <DialogHeader><DialogTitle>{labels.manualCopy(link)}</DialogTitle></DialogHeader>\n <Textarea ref={field} readOnly rows={Math.min(6, Math.max(2, Math.ceil(text.length / 48)))} value={text} data-testid={`${testId}-manual-copy-text`} onFocus={event => event.currentTarget.select()} />\n <div className=\"flex justify-end\"><Button variant=\"ghost\" size=\"sm\" data-testid={`${testId}-manual-copy-close`} onClick={onClose}>{labels.done}</Button></div>\n </DialogContent>\n </Dialog>;\n}\n\n/** Dot colours: default shadcn roles only. */\nconst DOTS: Record<ViewerTone, string> = {\n neutral: 'fill-muted-foreground',\n success: 'fill-primary',\n warning: 'fill-muted-foreground',\n danger: 'fill-destructive',\n};\n\n/** The save status as a coloured dot; the label is the tooltip and the screen-reader text, and is shown only when the person must act (`danger`). */\nfunction StatusMark({ status, testId }: { readonly status: ViewerStatus; readonly testId: string }) {\n const tone = status.tone ?? 'neutral';\n return <span role=\"status\" aria-label={status.label} title={status.label} data-testid={`${testId}-status`} data-tone={tone} data-kind={status.kind}\n className=\"inline-flex h-6 min-w-6 shrink-0 cursor-default items-center justify-center gap-1.5 text-xs\">\n <CircleIcon aria-hidden=\"true\" strokeWidth={0} className={cn('size-2 shrink-0', DOTS[tone])} />\n <span data-testid={`${testId}-status-text`} className={tone === 'danger' ? 'max-w-48 truncate text-destructive' : 'sr-only'}>{status.label}</span>\n </span>;\n}\n\n/** The one top bar of every viewer, always a single row: title and facts, viewer controls, a status dot when something needs attention, Share, full screen, Close and the \"…\" menu. */\nexport function ViewerFrame({ title, subtitle, status, showSavedStatus = false, target, revision, headerStart, controls, mainControl, menu, menuContent, actions, labels: ownLabels, icons: ownIcons, onRefresh, onShare, onCopy, onDownload, onOpenInNewTab, onClose, testId = 'viewer', titleTestId, compactHeader = true, className, children }: ViewerFrameProps) {\n const bar = useRef<HTMLElement>(null);\n const [space, setSpace] = useState<ViewerBarSpace>({ tight: false });\n const [busy, setBusy] = useState<'refresh' | 'share' | undefined>();\n const [notice, setNotice] = useState<{ readonly text: string; readonly tone: ViewerTone } | undefined>();\n const [copied, setCopied] = useState(false);\n const [manual, setManual] = useState<{ readonly text: string; readonly link: boolean } | undefined>();\n const host = useViewerWindow();\n const { labels, icons } = useViewerText(ownLabels, ownIcons);\n const mounted = useRef(true);\n useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []);\n useLayoutEffect(() => {\n const element = bar.current;\n if (!element || typeof ResizeObserver === 'undefined') return;\n const measure = () => {\n const width = element.getBoundingClientRect().width;\n const next = { tight: width > 0 && width < (touchScreen() ? TIGHT_WIDTH.touch : TIGHT_WIDTH.pointer) };\n setSpace(now => now.tight === next.tight ? now : next);\n };\n measure();\n const observer = new ResizeObserver(measure);\n observer.observe(element);\n return () => observer.disconnect();\n }, [host?.renderHeader]);\n useEffect(() => {\n if (!notice) return;\n const timer = setTimeout(() => setNotice(undefined), NOTICE_MS);\n return () => clearTimeout(timer);\n }, [notice]);\n\n const guarded = useCallback(async (kind: 'refresh' | 'share', run: () => unknown, failed: string) => {\n setBusy(kind);\n setNotice(undefined);\n try { return await run(); }\n catch (error) { if (mounted.current) { if (needsManualCopy(error)) setManual({ text: error.text, link: kind === 'share' }); else setNotice({ text: failed, tone: 'danger' }); } return undefined; }\n finally { if (mounted.current) setBusy(undefined); }\n }, []);\n const refresh = () => { if (onRefresh) void guarded('refresh', onRefresh, labels.refreshFailed); };\n const share = () => {\n if (!onShare) return;\n void guarded('share', () => onShare({ title, target, revision }), labels.shareFailed).then(result => {\n if (!mounted.current) return;\n if (result === 'copied') setNotice({ text: labels.linkCopied, tone: 'success' });\n else if (result === 'shared') setNotice({ text: labels.shared, tone: 'success' });\n });\n };\n const copy = () => {\n void Promise.resolve().then(onCopy).then(() => { if (mounted.current) { setCopied(true); setNotice({ text: labels.copied, tone: 'success' }); setTimeout(() => { if (mounted.current) setCopied(false); }, 1500); } })\n .catch(error => { if (!mounted.current) return; if (needsManualCopy(error)) setManual({ text: error.text, link: false }); else setNotice({ text: labels.copyFailed, tone: 'danger' }); });\n };\n const run = (action: (() => unknown) | undefined) => { void Promise.resolve().then(action).catch(() => { if (mounted.current) setNotice({ text: labels.actionFailed, tone: 'danger' }); }); };\n // The host's actions: the window's (every viewer in the panel), then this frame's. Each runs through `run`, so a failure shows as a notice.\n const hostActions = [...host?.actions ?? [], ...actions ?? []].map(action => ({ ...action, onSelect: () => run(action.onSelect) }));\n\n const reading = status?.kind === 'read-only' || status?.label === labels.readOnly;\n const menuItems: ViewerMenuItem[] = ([\n ...(compactHeader && onShare ? [{ id: 'share', label: labels.share, icon: <icons.share className=\"size-4\" />, onSelect: share }] : []),\n ...(compactHeader && host?.onFullscreenChange ? [{ id: 'fullscreen', label: host.fullscreen ? labels.exitFullscreen : labels.enterFullscreen, icon: <icons.enterFullscreen className=\"size-4\" />, onSelect: () => host.onFullscreenChange!(!host.fullscreen) }] : []),\n ...(onRefresh ? [{ id: 'refresh', separatorBefore: true, label: labels.reload, icon: <RefreshCwIcon className=\"size-4\" />, disabled: busy === 'refresh', onSelect: refresh }] : []),\n ...(onCopy ? [{ id: 'copy', label: labels.copy, icon: copied ? <CheckIcon className=\"size-4\" /> : <CopyIcon className=\"size-4\" />, onSelect: copy }] : []),\n ...(onDownload ? [{ id: 'download', label: labels.download, icon: <DownloadIcon className=\"size-4\" />, onSelect: () => run(onDownload) }] : []),\n ...(onOpenInNewTab ? [{ id: 'open', label: labels.openInNewTab, icon: <ExternalLinkIcon className=\"size-4\" />, onSelect: () => run(onOpenInNewTab) }] : []),\n ...(menu ?? []).map((item, index) => ({ ...item, separatorBefore: index === 0 || item.separatorBefore })),\n ...menuActions(hostActions).map((action, index) => ({ ...actionMenuItem(action, testId), separatorBefore: index === 0 })),\n ] as ViewerMenuItem[]).map(entry => ({ ...entry, testId: entry.testId ?? `${testId}-${entry.id}` }));\n\n const stacked = !compactHeader && host?.headerLayout === 'stacked';\n const toolbar = controls && <div data-testid={`${testId}-controls`} className={cn('flex shrink-0 items-center gap-1', compactHeader && 'max-[641px]:hidden', stacked && 'flex-wrap px-3 py-2')}>{controls}</div>;\n const titlebar = <>\n {compactHeader && !headerStart && onClose && <Button variant=\"ghost\" size=\"icon-sm\" aria-label={labels.close} title={labels.close} data-testid={`${testId}-close`} onClick={() => run(onClose)}><ArrowLeftIcon data-icon=\"inline-start\" className=\"min-[641px]:hidden\" aria-hidden=\"true\" /><icons.close data-icon=\"inline-start\" className=\"hidden min-[641px]:block\" aria-hidden=\"true\" /></Button>}\n {headerStart}\n <div className=\"group/title relative min-w-0 flex-1 basis-0 text-left\">\n <h2 data-testid={titleTestId ?? `${testId}-title`} title={title} className=\"m-0 truncate text-sm leading-5 font-semibold tracking-tight\">{title}</h2>\n {(subtitle || reading) && <p data-testid={`${testId}-subtitle`} className={cn('m-0 flex min-w-0 items-center gap-x-1.5 text-xs leading-4 whitespace-nowrap text-muted-foreground', compactHeader ? 'hidden group-hover/title:flex max-[641px]:hidden' : 'overflow-hidden')}>{subtitle}{subtitle && reading && <span aria-hidden=\"true\">·</span>}{reading && <span data-testid={`${testId}-readonly`}>{labels.readOnly}</span>}</p>}\n </div>\n {!stacked && toolbar}\n {mainControl && <div data-testid={`${testId}-main`} className=\"flex shrink-0 items-center\">{mainControl}</div>}\n {status && !reading && (showSavedStatus || status.kind !== 'saved' && status.label !== labels.saved) && <StatusMark status={status} testId={testId} />}\n <div className=\"flex shrink-0 items-center gap-0.5\">\n {headerActions(hostActions).map(action => <ActionButton key={action.id} action={action} testId={testId} size=\"icon-sm\" />)}\n {!compactHeader && onShare && <Button variant=\"ghost\" size=\"icon-sm\" aria-label={labels.share} title={labels.share} data-testid={`${testId}-share`} disabled={busy === 'share'} onClick={share}><icons.share data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>}\n {(menuItems.length > 0 || controls || menuContent) && <ViewerActions label={labels.moreActions} testId={`${testId}-more`} icon={<icons.more data-icon=\"inline-start\" aria-hidden=\"true\" />} items={menuItems}>{menuContent}{menuContent && menuItems.length > 0 && <DropdownMenuSeparator />}{compactHeader && controls && <div className=\"boring-viewer flex flex-wrap items-center gap-2 p-2 min-[641px]:hidden\">{controls}</div>}</ViewerActions>}\n {!compactHeader && host?.onFullscreenChange && <Toggle size=\"sm\" aria-label={host.fullscreen ? labels.exitFullscreen : labels.enterFullscreen} title={host.fullscreen ? labels.exitFullscreen : labels.enterFullscreen} data-testid={`${testId}-fullscreen`} pressed={host.fullscreen === true}\n onPressedChange={pressed => host.onFullscreenChange!(pressed)}>{host.fullscreen ? <icons.exitFullscreen data-icon=\"inline-start\" aria-hidden=\"true\" /> : <icons.enterFullscreen data-icon=\"inline-start\" aria-hidden=\"true\" />}</Toggle>}\n {(!compactHeader || headerStart) && onClose && <Button variant=\"ghost\" size=\"icon-sm\" className={compactHeader ? 'max-[641px]:hidden' : undefined} aria-label={labels.close} title={labels.close} data-testid={`${testId}-close`} onClick={() => run(onClose)}><icons.close data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>}\n </div>\n </>;\n const header = <header ref={bar} data-testid={`${testId}-bar`} data-layout={stacked ? 'stacked' : undefined}\n className={cn('relative flex shrink-0', stacked ? 'flex-col' : 'h-13 flex-nowrap items-center gap-x-1.5 px-2 sm:px-3')}>\n {stacked ? <><div data-testid={`${testId}-titlebar`} className=\"flex min-h-14 min-w-0 items-center gap-x-1.5 px-3 py-1.5\">{titlebar}</div>{toolbar}</> : titlebar}\n {notice && <Badge role=\"status\" aria-live=\"polite\" data-testid={`${testId}-notice`} data-tone={notice.tone} variant={notice.tone === 'danger' ? 'destructive' : 'secondary'}>{notice.text}</Badge>}\n {manual && <ManualCopy testId={testId} link={manual.link} text={manual.text} labels={labels} onClose={() => setManual(undefined)} />}\n </header>;\n return <ViewerBarContext.Provider value={space}>\n <section data-boring=\"viewer-frame\" data-testid={`${testId}-frame`} data-tight={space.tight || undefined}\n className={cn('boring-viewer isolate flex h-full min-h-0 min-w-0 flex-col bg-background text-foreground', className)}>\n {host?.renderHeader ? host.renderHeader(header) : header}\n <div data-testid={`${testId}-body`} className=\"flex min-h-0 min-w-0 flex-1 flex-col\">{children}</div>\n </section>\n </ViewerBarContext.Provider>;\n}\n",
50
+ "content": "'use client';\n\nimport { createContext, useCallback, useContext, useEffect, useLayoutEffect, useRef, useState } from 'react';\nimport type { ReactNode } from 'react';\nimport { ArrowLeftIcon, CheckIcon, CircleIcon, CopyIcon, DownloadIcon, ExternalLinkIcon, RefreshCwIcon } from 'lucide-react';\nimport { Button } from '@/components/ui/button';\nimport { Toggle } from '@/components/ui/toggle';\nimport { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';\nimport { Badge } from '@/components/ui/badge';\nimport { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';\nimport { Textarea } from '@/components/ui/textarea';\nimport { DropdownMenuSeparator } from '@/components/ui/dropdown-menu';\nimport { ActionButton, actionMenuItem, headerActions, menuActions } from '../button/actions';\nimport type { BlockAction } from '../button/actions';\nimport { ViewerActions } from './menu';\nimport type { ViewerMenuItem } from './menu';\nimport { useViewerText, useViewerWindow } from './viewer-window';\nimport type { ViewerIcons, ViewerLabels } from './viewer-window';\nimport { cn } from '../utils/utils';\nimport type { ManualCopyError } from '../utils/utils';\nimport type { ViewerShare } from './share';\n\nexport type { ViewerShare, ViewerShareRequest, ViewerShareResult } from './share';\nexport { ViewerWindowProvider, defaultViewerIcons, defaultViewerLabels, useViewerText } from './viewer-window';\nexport type { ViewerIcons, ViewerLabels, ViewerWindow } from './viewer-window';\n\nexport type ViewerTone = 'neutral' | 'success' | 'warning' | 'danger';\n/**\n * `kind` says what the status is whatever its (translated) label: the bar hides a plain `saved` (unless `showSavedStatus`) and shows\n * `read-only` as a fact. The bar shows a dot in the default shadcn roles: `success` primary, `warning` and `neutral` muted-foreground, `danger` destructive. Only a\n * `danger` status also shows its words, because the person must act on it; the others keep the label in the tooltip and for screen readers.\n */\nexport interface ViewerStatus { readonly label: string; readonly tone?: ViewerTone; readonly kind?: 'saved' | 'read-only' | undefined }\n\nexport interface ViewerFrameProps {\n /** What is shown, for example the file name. */\n readonly title: string;\n /** Type, version, path, size: short facts under the title. */\n readonly subtitle?: ReactNode;\n /** Saved, Unsaved, Saving, Changed elsewhere, Read-only. */\n readonly status?: ViewerStatus | undefined;\n /** Keep a quiet saved acknowledgement visible, for autosaving editors. */\n readonly showSavedStatus?: boolean | undefined;\n /** Opaque identifiers handed to `onShare` so the host can build a link to this exact thing. */\n readonly target?: unknown;\n readonly revision?: string | undefined;\n /** Leading header content, for example Back before the file title. */\n readonly headerStart?: ReactNode;\n /** Viewer-specific controls that stay visible: a mode toggle, Save while there are edits, zoom steps, a version switcher when there are several. */\n readonly controls?: ReactNode;\n /**\n * The one control that matters most here (Comment on a version to try, for example), kept in the bar at every width. With the compact\n * header a phone folds `controls` into the \"…\" menu; this one stays beside it, so the main action is one tap away. Test id `<testId>-main`.\n */\n readonly mainControl?: ReactNode;\n /** Viewer-specific secondary actions, listed in the \"…\" menu after the standard ones. */\n readonly menu?: readonly ViewerMenuItem[];\n /** Viewer-specific native dropdown groups, before the standard secondary actions. */\n readonly menuContent?: ReactNode;\n /**\n * The host's actions for this bar, after the window's (`ViewerWindow.actions`): `header` ones as buttons beside Share, `menu` ones at the\n * end of the \"…\" menu. Test ids `<testId>-<id>`.\n */\n readonly actions?: readonly BlockAction[] | undefined;\n /** This bar's words and icons, over the window's and the defaults (`defaultViewerLabels`, `defaultViewerIcons`). */\n readonly labels?: Partial<ViewerLabels> | undefined;\n readonly icons?: Partial<ViewerIcons> | undefined;\n /** Each standard action appears only when its handler is supplied. Share is always visible; Reload, Copy, Download and Open in new tab live in the \"…\" menu. */\n readonly onRefresh?: () => unknown;\n readonly onShare?: ViewerShare;\n readonly onCopy?: () => unknown;\n readonly onDownload?: () => unknown;\n readonly onOpenInNewTab?: () => unknown;\n readonly onClose?: () => unknown;\n /** Prefix of the `data-testid` of the bar and its actions (`viewer-share`, `viewer-more`, and `viewer-refresh` for the menu's Reload, ...). */\n readonly testId?: string;\n readonly titleTestId?: string;\n /** Quiet one-row bar with secondary actions in the menu. False retains the former visible Share/fullscreen/close layout. */\n readonly compactHeader?: boolean;\n readonly className?: string;\n readonly children?: ReactNode;\n}\n\n\n/** Narrow panes drop optional controls. */\nconst TIGHT_WIDTH = { touch: 440, pointer: 340 };\nconst touchScreen = () => typeof matchMedia === 'function' && matchMedia('(max-width: 639px), (pointer: coarse)').matches;\n\n/** How much room the bar has, for a pane that wants to drop optional controls (the zoom percentage, for example) on a narrow panel. */\nexport interface ViewerBarSpace { readonly tight: boolean }\nconst ViewerBarContext = createContext<ViewerBarSpace>({ tight: false });\nexport const useViewerBar = (): ViewerBarSpace => useContext(ViewerBarContext);\nconst NOTICE_MS = 2600;\n\n/**\n * One choice of a mode toggle, for example Rich | Source. Icon only: `label` is the accessible name and the tooltip, `text` the short word\n * for hosts that want it (shown when there is no icon). A disabled choice shows `reason` as its tooltip.\n */\nexport interface ViewerToggleOption<T extends string> { readonly id: T; readonly label: string; readonly text: string; readonly icon?: ReactNode; readonly disabled?: boolean; readonly reason?: string | undefined; readonly testId?: string }\n\n/** A segmented control over the consumer's outlined ToggleGroup: the pressed segment is filled with a full-contrast icon, the others muted. */\nexport function ViewerToggle<T extends string>({ label, value, options, onChange }: { readonly label: string; readonly value: T; readonly options: readonly ViewerToggleOption<T>[]; readonly onChange: (id: T) => void }) {\n return <ToggleGroup aria-label={label} variant=\"outline\" size=\"sm\" spacing={1} value={[value]} onValueChange={values => {\n const selected = options.find(option => option.id === values[0] && !option.disabled);\n if (selected && selected.id !== value) onChange(selected.id);\n }}>\n {options.map(option => <ToggleGroupItem key={option.id} value={option.id} aria-label={option.label} title={option.disabled && option.reason ? option.reason : option.label} disabled={option.disabled}\n className=\"disabled:pointer-events-auto disabled:cursor-not-allowed\" data-testid={option.testId} data-mode={option.id}>\n <span className={cn('inline-flex items-center', value !== option.id && 'text-muted-foreground')}>{option.icon ?? option.text}</span></ToggleGroupItem>)}\n </ToggleGroup>;\n}\n\n/** The copy helper throws this when the browser allowed no automatic copy at all; the person copies by hand instead. */\nconst needsManualCopy = (error: unknown): error is ManualCopyError => (error as { name?: string } | null)?.name === 'ManualCopyError' && typeof (error as { text?: unknown }).text === 'string';\n\n/** Last resort for Copy and Share: the text, already selected, so Ctrl/Cmd+C or the long-press menu finishes the job. */\nfunction ManualCopy({ testId, link, text, onClose, labels }: { readonly testId: string; readonly link: boolean; readonly text: string; readonly onClose: () => void; readonly labels: ViewerLabels }) {\n const field = useRef<HTMLTextAreaElement>(null);\n return <Dialog open onOpenChange={open => { if (!open) onClose(); }}>\n <DialogContent showCloseButton={false} className=\"boring-viewer\" data-testid={`${testId}-manual-copy`} initialFocus={field}>\n <DialogHeader><DialogTitle>{labels.manualCopy(link)}</DialogTitle></DialogHeader>\n <Textarea ref={field} readOnly rows={Math.min(6, Math.max(2, Math.ceil(text.length / 48)))} value={text} data-testid={`${testId}-manual-copy-text`} onFocus={event => event.currentTarget.select()} />\n <div className=\"flex justify-end\"><Button variant=\"ghost\" size=\"sm\" data-testid={`${testId}-manual-copy-close`} onClick={onClose}>{labels.done}</Button></div>\n </DialogContent>\n </Dialog>;\n}\n\n/** Dot colours: default shadcn roles only. */\nconst DOTS: Record<ViewerTone, string> = {\n neutral: 'fill-muted-foreground',\n success: 'fill-primary',\n warning: 'fill-muted-foreground',\n danger: 'fill-destructive',\n};\n\n/** The save status as a coloured dot; the label is the tooltip and the screen-reader text, and is shown only when the person must act (`danger`). */\nfunction StatusMark({ status, testId }: { readonly status: ViewerStatus; readonly testId: string }) {\n const tone = status.tone ?? 'neutral';\n return <span role=\"status\" aria-label={status.label} title={status.label} data-testid={`${testId}-status`} data-tone={tone} data-kind={status.kind}\n className=\"inline-flex h-6 min-w-6 shrink-0 cursor-default items-center justify-center gap-1.5 text-xs\">\n <CircleIcon aria-hidden=\"true\" strokeWidth={0} className={cn('size-2 shrink-0', DOTS[tone])} />\n <span data-testid={`${testId}-status-text`} className={tone === 'danger' ? 'max-w-48 truncate text-destructive' : 'sr-only'}>{status.label}</span>\n </span>;\n}\n\n/** The one top bar of every viewer, always a single row: title and facts, viewer controls, a status dot when something needs attention, Share, full screen, Close and the \"…\" menu. */\nexport function ViewerFrame({ title, subtitle, status, showSavedStatus = false, target, revision, headerStart, controls, mainControl, menu, menuContent, actions, labels: ownLabels, icons: ownIcons, onRefresh, onShare, onCopy, onDownload, onOpenInNewTab, onClose, testId = 'viewer', titleTestId, compactHeader = true, className, children }: ViewerFrameProps) {\n const bar = useRef<HTMLElement>(null);\n const [space, setSpace] = useState<ViewerBarSpace>({ tight: false });\n const [busy, setBusy] = useState<'refresh' | 'share' | undefined>();\n const [notice, setNotice] = useState<{ readonly text: string; readonly tone: ViewerTone } | undefined>();\n const [copied, setCopied] = useState(false);\n const [manual, setManual] = useState<{ readonly text: string; readonly link: boolean } | undefined>();\n const host = useViewerWindow();\n const { labels, icons } = useViewerText(ownLabels, ownIcons);\n useLayoutEffect(() => {\n const element = bar.current;\n if (!element || typeof ResizeObserver === 'undefined') return;\n const measure = () => {\n const width = element.getBoundingClientRect().width;\n const next = { tight: width > 0 && width < (touchScreen() ? TIGHT_WIDTH.touch : TIGHT_WIDTH.pointer) };\n setSpace(now => now.tight === next.tight ? now : next);\n };\n measure();\n const observer = new ResizeObserver(measure);\n observer.observe(element);\n return () => observer.disconnect();\n }, [host?.renderHeader]);\n useEffect(() => {\n if (!notice) return;\n const timer = setTimeout(() => setNotice(undefined), NOTICE_MS);\n return () => clearTimeout(timer);\n }, [notice]);\n\n // A result after unmount updates nothing: React ignores a state update on an unmounted component.\n const guarded = useCallback(async (kind: 'refresh' | 'share', run: () => unknown, failed: string) => {\n setBusy(kind);\n setNotice(undefined);\n try { return await run(); }\n catch (error) { if (needsManualCopy(error)) setManual({ text: error.text, link: kind === 'share' }); else setNotice({ text: failed, tone: 'danger' }); return undefined; }\n finally { setBusy(undefined); }\n }, []);\n const refresh = () => { if (onRefresh) void guarded('refresh', onRefresh, labels.refreshFailed); };\n const share = () => {\n if (!onShare) return;\n void guarded('share', () => onShare({ title, target, revision }), labels.shareFailed).then(result => {\n if (result === 'copied') setNotice({ text: labels.linkCopied, tone: 'success' });\n else if (result === 'shared') setNotice({ text: labels.shared, tone: 'success' });\n });\n };\n const copy = () => {\n void Promise.resolve().then(onCopy).then(() => { setCopied(true); setNotice({ text: labels.copied, tone: 'success' }); setTimeout(() => setCopied(false), 1500); })\n .catch(error => { if (needsManualCopy(error)) setManual({ text: error.text, link: false }); else setNotice({ text: labels.copyFailed, tone: 'danger' }); });\n };\n const run = (action: (() => unknown) | undefined) => { void Promise.resolve().then(action).catch(() => setNotice({ text: labels.actionFailed, tone: 'danger' })); };\n // The host's actions: the window's (every viewer in the panel), then this frame's. Each runs through `run`, so a failure shows as a notice.\n const hostActions = [...host?.actions ?? [], ...actions ?? []].map(action => ({ ...action, onSelect: () => run(action.onSelect) }));\n\n const reading = status?.kind === 'read-only' || status?.label === labels.readOnly;\n const menuItems: ViewerMenuItem[] = ([\n ...(compactHeader && onShare ? [{ id: 'share', label: labels.share, icon: <icons.share className=\"size-4\" />, onSelect: share }] : []),\n ...(compactHeader && host?.onFullscreenChange ? [{ id: 'fullscreen', label: host.fullscreen ? labels.exitFullscreen : labels.enterFullscreen, icon: <icons.enterFullscreen className=\"size-4\" />, onSelect: () => host.onFullscreenChange!(!host.fullscreen) }] : []),\n ...(onRefresh ? [{ id: 'refresh', separatorBefore: true, label: labels.reload, icon: <RefreshCwIcon className=\"size-4\" />, disabled: busy === 'refresh', onSelect: refresh }] : []),\n ...(onCopy ? [{ id: 'copy', label: labels.copy, icon: copied ? <CheckIcon className=\"size-4\" /> : <CopyIcon className=\"size-4\" />, onSelect: copy }] : []),\n ...(onDownload ? [{ id: 'download', label: labels.download, icon: <DownloadIcon className=\"size-4\" />, onSelect: () => run(onDownload) }] : []),\n ...(onOpenInNewTab ? [{ id: 'open', label: labels.openInNewTab, icon: <ExternalLinkIcon className=\"size-4\" />, onSelect: () => run(onOpenInNewTab) }] : []),\n ...(menu ?? []).map((item, index) => ({ ...item, separatorBefore: index === 0 || item.separatorBefore })),\n ...menuActions(hostActions).map((action, index) => ({ ...actionMenuItem(action, testId), separatorBefore: index === 0 })),\n ] as ViewerMenuItem[]).map(entry => ({ ...entry, testId: entry.testId ?? `${testId}-${entry.id}` }));\n\n const stacked = !compactHeader && host?.headerLayout === 'stacked';\n const toolbar = controls && <div data-testid={`${testId}-controls`} className={cn('flex shrink-0 items-center gap-1', compactHeader && 'max-[641px]:hidden', stacked && 'flex-wrap px-3 py-2')}>{controls}</div>;\n const titlebar = <>\n {compactHeader && !headerStart && onClose && <Button variant=\"ghost\" size=\"icon-sm\" aria-label={labels.close} title={labels.close} data-testid={`${testId}-close`} onClick={() => run(onClose)}><ArrowLeftIcon data-icon=\"inline-start\" className=\"min-[641px]:hidden\" aria-hidden=\"true\" /><icons.close data-icon=\"inline-start\" className=\"hidden min-[641px]:block\" aria-hidden=\"true\" /></Button>}\n {headerStart}\n <div className=\"group/title relative min-w-0 flex-1 basis-0 text-left\">\n <h2 data-testid={titleTestId ?? `${testId}-title`} title={title} className=\"m-0 truncate text-sm leading-5 font-semibold tracking-tight\">{title}</h2>\n {(subtitle || reading) && <p data-testid={`${testId}-subtitle`} className={cn('m-0 flex min-w-0 items-center gap-x-1.5 text-xs leading-4 whitespace-nowrap text-muted-foreground', compactHeader ? 'hidden group-hover/title:flex max-[641px]:hidden' : 'overflow-hidden')}>{subtitle}{subtitle && reading && <span aria-hidden=\"true\">·</span>}{reading && <span data-testid={`${testId}-readonly`}>{labels.readOnly}</span>}</p>}\n </div>\n {!stacked && toolbar}\n {mainControl && <div data-testid={`${testId}-main`} className=\"flex shrink-0 items-center\">{mainControl}</div>}\n {status && !reading && (showSavedStatus || status.kind !== 'saved' && status.label !== labels.saved) && <StatusMark status={status} testId={testId} />}\n <div className=\"flex shrink-0 items-center gap-0.5\">\n {headerActions(hostActions).map(action => <ActionButton key={action.id} action={action} testId={testId} size=\"icon-sm\" />)}\n {!compactHeader && onShare && <Button variant=\"ghost\" size=\"icon-sm\" aria-label={labels.share} title={labels.share} data-testid={`${testId}-share`} disabled={busy === 'share'} onClick={share}><icons.share data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>}\n {(menuItems.length > 0 || controls || menuContent) && <ViewerActions label={labels.moreActions} testId={`${testId}-more`} icon={<icons.more data-icon=\"inline-start\" aria-hidden=\"true\" />} items={menuItems}>{menuContent}{menuContent && menuItems.length > 0 && <DropdownMenuSeparator />}{compactHeader && controls && <div className=\"boring-viewer flex flex-wrap items-center gap-2 p-2 min-[641px]:hidden\">{controls}</div>}</ViewerActions>}\n {!compactHeader && host?.onFullscreenChange && <Toggle size=\"sm\" aria-label={host.fullscreen ? labels.exitFullscreen : labels.enterFullscreen} title={host.fullscreen ? labels.exitFullscreen : labels.enterFullscreen} data-testid={`${testId}-fullscreen`} pressed={host.fullscreen === true}\n onPressedChange={pressed => host.onFullscreenChange!(pressed)}>{host.fullscreen ? <icons.exitFullscreen data-icon=\"inline-start\" aria-hidden=\"true\" /> : <icons.enterFullscreen data-icon=\"inline-start\" aria-hidden=\"true\" />}</Toggle>}\n {(!compactHeader || headerStart) && onClose && <Button variant=\"ghost\" size=\"icon-sm\" className={compactHeader ? 'max-[641px]:hidden' : undefined} aria-label={labels.close} title={labels.close} data-testid={`${testId}-close`} onClick={() => run(onClose)}><icons.close data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>}\n </div>\n </>;\n const header = <header ref={bar} data-testid={`${testId}-bar`} data-layout={stacked ? 'stacked' : undefined}\n className={cn('relative flex shrink-0', stacked ? 'flex-col' : 'h-13 flex-nowrap items-center gap-x-1.5 px-2 sm:px-3')}>\n {stacked ? <><div data-testid={`${testId}-titlebar`} className=\"flex min-h-14 min-w-0 items-center gap-x-1.5 px-3 py-1.5\">{titlebar}</div>{toolbar}</> : titlebar}\n {notice && <Badge role=\"status\" aria-live=\"polite\" data-testid={`${testId}-notice`} data-tone={notice.tone} variant={notice.tone === 'danger' ? 'destructive' : 'secondary'}>{notice.text}</Badge>}\n {manual && <ManualCopy testId={testId} link={manual.link} text={manual.text} labels={labels} onClose={() => setManual(undefined)} />}\n </header>;\n return <ViewerBarContext.Provider value={space}>\n <section data-boring=\"viewer-frame\" data-testid={`${testId}-frame`} data-tight={space.tight || undefined}\n className={cn('boring-viewer isolate flex h-full min-h-0 min-w-0 flex-col bg-background text-foreground', className)}>\n {host?.renderHeader ? host.renderHeader(header) : header}\n <div data-testid={`${testId}-body`} className=\"flex min-h-0 min-w-0 flex-1 flex-col\">{children}</div>\n </section>\n </ViewerBarContext.Provider>;\n}\n",
59
51
  "type": "registry:component",
60
52
  "target": "components/viewer-frame/viewer-frame.tsx"
61
53
  },