@hachej/boring-ui-kit 0.4.3 → 0.4.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.md +56 -0
  2. package/dist/chat-outbox.d.ts +18 -0
  3. package/dist/chat-outbox.js +12 -0
  4. package/dist/mark-store.d.ts +38 -0
  5. package/dist/mark-store.js +84 -0
  6. package/dist/markdown-editor.d.ts +6 -0
  7. package/dist/markdown-editor.js +17 -2
  8. package/dist/markdown-highlight-layer.d.ts +26 -0
  9. package/dist/markdown-highlight-layer.js +107 -0
  10. package/dist/markdown-quote.d.ts +23 -0
  11. package/dist/markdown-quote.js +39 -0
  12. package/dist/native-chat.d.ts +21 -0
  13. package/dist/native-chat.js +111 -7
  14. package/dist/remote-chat.js +16 -14
  15. package/dist/shared-chat/error.d.ts +10 -0
  16. package/dist/shared-chat/error.js +13 -0
  17. package/dist/shared-chat/fake.d.ts +39 -0
  18. package/dist/shared-chat/fake.js +90 -0
  19. package/dist/shared-chat/index.d.ts +51 -0
  20. package/dist/shared-chat/index.js +255 -0
  21. package/dist/shared-chat/socket.d.ts +20 -0
  22. package/dist/shared-chat/socket.js +114 -0
  23. package/dist/shared-chat/source.d.ts +117 -0
  24. package/dist/shared-chat/source.js +6 -0
  25. package/dist/voice-capture.d.ts +43 -0
  26. package/dist/voice-capture.js +186 -0
  27. package/package.json +19 -3
  28. package/r/agent-target.json +36 -0
  29. package/r/app-shell.json +26 -11
  30. package/r/board-viewer.json +5 -5
  31. package/r/button.json +1 -1
  32. package/r/diagram-viewer.json +1 -1
  33. package/r/dock.json +1 -1
  34. package/r/documents.json +10 -10
  35. package/r/feedback-preview.json +1 -1
  36. package/r/feedback.json +5 -5
  37. package/r/file-tree.json +5 -5
  38. package/r/highlights.json +29 -0
  39. package/r/html-viewer.json +3 -3
  40. package/r/maquette-viewer.json +2 -2
  41. package/r/markdown-editor.json +3 -3
  42. package/r/mockup-viewer.json +2 -2
  43. package/r/pi-ambient.json +7 -7
  44. package/r/pi-app.json +15 -14
  45. package/r/pi-chat.json +50 -20
  46. package/r/pi-workspace.json +4 -4
  47. package/r/provider-setup.json +2 -2
  48. package/r/record-viewer.json +40 -0
  49. package/r/registry.json +380 -86
  50. package/r/requests-board.json +41 -0
  51. package/r/shared-chat.json +79 -0
  52. package/r/task-list-viewer.json +2 -2
  53. package/r/theme.json +15 -0
  54. package/r/utils.json +6 -0
  55. package/r/version-history.json +3 -3
  56. package/r/viewers.json +15 -9
  57. package/r/windows.json +28 -0
@@ -0,0 +1,41 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "requests-board",
4
+ "title": "Requests board",
5
+ "description": "Host-fed compact requests and responsive list/detail with questions and decisions.",
6
+ "dependencies": [
7
+ "react@^19.0.0"
8
+ ],
9
+ "registryDependencies": [
10
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.5/r/utils.json",
11
+ "badge",
12
+ "button",
13
+ "input",
14
+ "toggle-group",
15
+ "item",
16
+ "empty",
17
+ "textarea",
18
+ "card"
19
+ ],
20
+ "files": [
21
+ {
22
+ "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",
24
+ "type": "registry:component",
25
+ "target": "components/requests-board/requests-board.tsx"
26
+ },
27
+ {
28
+ "path": "registry/requests-board/requests.ts",
29
+ "content": "/** Host-owned request projections, not execution state or approval authority. `since` is an ISO timestamp of the last move. */\nexport type RequestState = 'question' | 'try' | 'validation' | 'building' | 'checking' | 'live' | 'abandoned';\nexport type RequestFilter = 'yours' | 'active' | 'live' | 'abandoned';\nexport type RequestQuestion = { readonly kind: 'answer' | 'approval' | 'release'; readonly text: string };\nexport interface RequestAction { readonly kind: 'preview' | 'pr'; readonly href: string }\nexport interface RequestLink { readonly kind: 'parent' | 'child' | 'blocked_by'; readonly number: string }\nexport interface RequestView {\n readonly number: string;\n readonly title: string;\n readonly state: RequestState;\n readonly line: string;\n readonly since: string;\n readonly waitsOn?: string;\n readonly question?: RequestQuestion;\n readonly action?: RequestAction;\n readonly links: readonly RequestLink[];\n readonly history: readonly { readonly at: string; readonly text: string }[];\n readonly repo?: string;\n readonly master?: string;\n}\nexport const requestFilters: readonly RequestFilter[] = ['yours', 'active', 'live', 'abandoned'];\nconst matches = (view: RequestView, filter: RequestFilter) => filter === 'yours' ? Boolean(view.question) : filter === 'active' ? view.state !== 'live' && view.state !== 'abandoned' : view.state === filter;\nconst recent = (a: RequestView, b: RequestView) => (Date.parse(b.since) || 0) - (Date.parse(a.since) || 0);\n\n/** Questions first, then versions to try, then the most recently moved; never mutate the host's array. */\nexport function compactRows(views: readonly RequestView[]): readonly RequestView[] {\n const rank = (view: RequestView) => view.question ? 0 : view.state === 'try' ? 1 : 2;\n return [...views].sort((a, b) => rank(a) - rank(b) || recent(a, b)).slice(0, 5);\n}\nexport function filterViews(views: readonly RequestView[], filter: RequestFilter, query: string, repo?: string): readonly RequestView[] {\n const search = query.trim().toLocaleLowerCase();\n return views.filter(view => matches(view, filter) && (!repo || view.repo === repo) && `${view.number} ${view.title} ${view.line}`.toLocaleLowerCase().includes(search)).sort(recent);\n}\n/** Counts use the same search and repository scope as the list. */\nexport function requestCounts(views: readonly RequestView[], query = '', repo?: string): Readonly<Record<RequestFilter, number>> {\n return Object.fromEntries(requestFilters.map(filter => [filter, filterViews(views, filter, query, repo).length])) as Record<RequestFilter, number>;\n}\n",
30
+ "type": "registry:lib",
31
+ "target": "components/requests-board/requests.ts"
32
+ },
33
+ {
34
+ "path": "registry/requests-board/requests-labels.ts",
35
+ "content": "import type { RequestFilter, RequestLink, RequestState } from './requests';\n\nexport type RequestsLocale = 'en' | 'fr';\nconst dateLabel = (locale: RequestsLocale) => {\n const formatter = new Intl.DateTimeFormat(locale, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' });\n return (value: string) => {\n const date = new Date(value);\n return Number.isNaN(date.getTime()) ? value : formatter.format(date);\n };\n};\nexport const defaultRequestsLabels = {\n title: 'Requests', search: 'Search requests', repositories: 'Repositories', allRepos: 'All repositories',\n filters: { yours: 'For you', active: 'In progress', live: 'Live', abandoned: 'Abandoned' } as Record<RequestFilter, string>,\n states: { question: 'Question', try: 'To try', validation: 'Approval', building: 'Building', checking: 'Checking', live: 'Live', abandoned: 'Abandoned' } as Record<RequestState, string>,\n links: { parent: 'Parent', child: 'Child', blocked_by: 'Blocked by' } as Record<RequestLink['kind'], string>,\n formatDate: dateLabel('en'),\n openAll: (count: number) => `See all ${count} requests`,\n changed: (number: string, state: string) => `${number}: ${state.toLocaleLowerCase()}`,\n back: 'Back', choose: 'Choose a request', empty: 'No requests', emptyDescription: 'Try another filter or search.',\n since: 'Since', waitsOn: 'Waiting on', history: 'History', related: 'Related requests', master: 'Master',\n tryVersion: 'Try this version', answer: 'Answer', yourAnswer: 'Your answer', approve: 'Approve', later: 'Later',\n};\nexport type RequestsLabels = typeof defaultRequestsLabels;\nexport const frenchRequestsLabels: RequestsLabels = {\n title: 'Demandes', search: 'Rechercher une demande', repositories: 'Dépôts', allRepos: 'Tous les dépôts',\n filters: { yours: 'À vous', active: 'En cours', live: 'En ligne', abandoned: 'Abandonnées' },\n states: { question: 'Question', try: 'À essayer', validation: 'Validation', building: 'En construction', checking: 'En vérification', live: 'En ligne', abandoned: 'Abandonnée' },\n links: { parent: 'Parente', child: 'Enfant', blocked_by: 'Bloquée par' },\n formatDate: dateLabel('fr'),\n openAll: count => `Voir les ${count} demandes`, changed: (number, state) => `${number} : ${state.toLocaleLowerCase('fr')}`,\n back: 'Retour', choose: 'Choisissez une demande', empty: 'Aucune demande', emptyDescription: 'Essayez un autre filtre ou une autre recherche.',\n since: 'Depuis', waitsOn: 'Attend', history: 'Historique', related: 'Demandes liées', master: 'Master',\n tryVersion: 'Essayer cette version', answer: 'Répondre', yourAnswer: 'Votre réponse', approve: 'Approuver', later: 'Plus tard',\n};\nexport const requestsLabelsFor = (locale: RequestsLocale | undefined, labels?: Partial<RequestsLabels>): RequestsLabels =>\n ({ ...(locale === 'fr' ? frenchRequestsLabels : defaultRequestsLabels), ...labels });\n",
36
+ "type": "registry:lib",
37
+ "target": "components/requests-board/requests-labels.ts"
38
+ }
39
+ ],
40
+ "type": "registry:block"
41
+ }
@@ -0,0 +1,79 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "shared-chat",
4
+ "title": "Shared chat",
5
+ "description": "Optional multi-party host log presentation over PiChat's generic composition seams. No agent runtime or speaking policy.",
6
+ "dependencies": [
7
+ "@hachej/boring-ui-kit@^0.4.5",
8
+ "react@^19.0.0",
9
+ "lucide-react@^1.51.0"
10
+ ],
11
+ "registryDependencies": [
12
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.5/r/pi-chat.json",
13
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.5/r/theme.json",
14
+ "avatar",
15
+ "badge",
16
+ "button",
17
+ "card",
18
+ "drawer",
19
+ "popover",
20
+ "tooltip",
21
+ "trace",
22
+ "spinner",
23
+ "item",
24
+ "message",
25
+ "bubble",
26
+ "message-scroller"
27
+ ],
28
+ "files": [
29
+ {
30
+ "path": "registry/shared-chat/shared-chat.tsx",
31
+ "content": "'use client';\nimport { PiChat } from '../pi-chat/pi-chat';\nimport type { PiChatProps } from '../pi-chat/pi-chat';\nimport type { SharedChatController } from '@hachej/boring-ui-kit/shared-chat';\nimport type { SharedLabels } from './shared-labels';\nimport { useSharedChatProps } from './use-shared-chat';\n\nexport { useSharedChat, useSharedChatProps } from './use-shared-chat';\nexport { defaultSharedLabels } from './shared-labels';\nexport { frenchSharedLabels } from './shared-labels-fr';\nexport type { SharedLabels } from './shared-labels';\nexport { createSharedChat, createChannelSocket, createFakeChannelSource, channelAvatarStates, ChannelRequestError } from '@hachej/boring-ui-kit/shared-chat';\nexport type { ChannelSource, ChannelEvent, ChannelMessage, Member, Conversation, Card, Step, AvatarState, SharedChatController } from '@hachej/boring-ui-kit/shared-chat';\n\nexport interface SharedChatProps extends Omit<PiChatProps, 'controller'> {\n readonly controller: SharedChatController;\n readonly participantLabels?: Partial<SharedLabels>;\n}\n/** Optional composition. PiChat still owns the regular shell, draft, send and composer lifecycle. */\nexport function SharedChat({ participantLabels, ...props }: SharedChatProps) {\n const composition = useSharedChatProps(props.controller, { locale: props.locale, labels: participantLabels });\n return <PiChat {...props} {...composition} controls={<>{composition.controls}{props.controls}</>} composerChips={<>{props.composerChips}{composition.composerChips}</>} />;\n}\n",
32
+ "type": "registry:component",
33
+ "target": "components/shared-chat/shared-chat.tsx"
34
+ },
35
+ {
36
+ "path": "registry/shared-chat/use-shared-chat.tsx",
37
+ "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 { Badge } from '@/components/ui/badge';\nimport { Button } from '@/components/ui/button';\nimport { ItemContent, ItemDescription, ItemMedia, ItemTitle } from '@/components/ui/item';\nimport { agentTone, 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 members = state?.shared.members ?? [];\n const labels = { ...(options.locale === 'fr' ? frenchSharedLabels : defaultSharedLabels), ...options.labels };\n const mentions = useMemo<MentionsConfig>(() => ({\n listLabel: labels.sharedMembers, icon: <UserIcon aria-hidden=\"true\" />,\n label: id => members.find(member => member.id === id)?.name ?? id,\n search: async query => members.filter(member => `${member.name} ${member.id}`.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)?.name ?? id}</Badge>,\n renderSent: id => {\n const member = members.find(member => member.id === id) ?? { id, name: id, kind: 'person' };\n return <span data-testid=\"message-mention\" data-path={id} data-agent-color={agentTone(member)?.token} className={`inline-flex items-baseline gap-1 align-baseline font-medium ${agentTone(member)?.text ?? ''}`}><span aria-hidden=\"true\"><MemberAvatar member={member} compact interactive={false} /></span><span data-testid=\"mention-name\">{member.name}</span></span>;\n },\n renderOption: item => <><ItemMedia variant=\"icon\"><UserIcon aria-hidden=\"true\" /></ItemMedia><ItemContent className=\"min-w-0\"><ItemTitle>{item.label}</ItemTitle><ItemDescription className=\"m-0 truncate\">{item.path}</ItemDescription></ItemContent></>,\n }), [members, labels.sharedMembers]);\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 ? 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)} 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 };\n}\n\n/** Owns only the adapters it creates; each adapter borrows the host source. */\nexport function useSharedChat(source: ChannelSource, options: SharedTextOptions = {}) {\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 });\n setBinding({ source, id: conversationId, controller });\n return () => { void controller.dispose(); };\n }, [source, conversationId]);\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",
38
+ "type": "registry:component",
39
+ "target": "components/shared-chat/use-shared-chat.tsx"
40
+ },
41
+ {
42
+ "path": "registry/shared-chat/messages.tsx",
43
+ "content": "'use client';\n\nimport { useEffect, useRef, useState, useSyncExternalStore } from 'react';\nimport type { ComponentProps, ReactNode } from 'react';\nimport { channelAvatarStates } from '@hachej/boring-ui-kit/shared-chat';\nimport type { Card as Request, ChannelMessage, SharedChatController, Member, SharedChatState } from '@hachej/boring-ui-kit/shared-chat';\nimport { BotIcon, CircleIcon, ReplyIcon, Trash2Icon, XIcon } from 'lucide-react';\nimport { Avatar, AvatarFallback, AvatarGroup, AvatarGroupCount, AvatarImage } from '@/components/ui/avatar';\nimport { Badge } from '@/components/ui/badge';\nimport { Button } from '@/components/ui/button';\nimport { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';\nimport { Drawer, DrawerContent, DrawerTitle } from '@/components/ui/drawer';\nimport { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';\nimport { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';\nimport { relativeTime } from '../pi-chat/history';\nimport { useChatText } from './text';\n\n// Text shares one treatment across headings and muted bubbles; only AA-qualified default palette entries colour it.\nconst agentTones = [\n { token: 'chart-1', icon: 'text-chart-1', text: 'text-foreground' },\n { token: 'chart-2', icon: 'text-chart-2', text: 'text-foreground dark:text-chart-2' },\n { token: 'chart-3', icon: 'text-chart-3', text: 'text-chart-3' },\n { token: 'chart-4', icon: 'text-chart-4', text: 'text-foreground' },\n { token: 'chart-5', icon: 'text-chart-5', text: 'text-foreground' },\n];\nexport function agentTone(member: Pick<Member, 'id' | 'kind'>) {\n if (member.kind !== 'agent') return undefined;\n let hash = 0;\n for (const character of member.id) hash = (Math.imul(hash, 31) + character.charCodeAt(0)) >>> 0;\n return agentTones[hash % agentTones.length];\n}\n\nfunction usePhone() {\n const [phone, setPhone] = useState(() => globalThis.matchMedia?.('(max-width: 640px)').matches === true);\n useEffect(() => { const media = globalThis.matchMedia?.('(max-width: 640px)'); if (!media) return; const change = () => setPhone(media.matches); media.addEventListener('change', change); return () => media.removeEventListener('change', change); }, []);\n return phone;\n}\nconst latestCard = (shared: SharedChatState, id: string) => shared.messages.findLast(message => message.author === id && message.card)?.card;\nconst memberOf = (shared: SharedChatState, id: string): Member => shared.members.find(member => member.id === id) ?? { id, name: id, kind: 'person' };\nexport function sharedQuote(shared: SharedChatState, id: string | undefined) {\n const message = shared.messages.find(message => message.id === id);\n return message ? { message, author: memberOf(shared, message.author) } : undefined;\n}\n\nexport function MemberAvatar({ member, card, interactive = true, size = 'default', compact = false }: { readonly member: Member; readonly card?: Request | undefined; readonly interactive?: boolean; readonly size?: ComponentProps<typeof Avatar>['size']; readonly compact?: boolean }) {\n const { labels } = useChatText();\n const phone = usePhone();\n const [open, setOpen] = useState(false);\n useEffect(() => {\n if (!open) return;\n const close = () => setOpen(false);\n document.addEventListener('scroll', close, true);\n return () => document.removeEventListener('scroll', close, true);\n }, [open]);\n const state = member.kind === 'agent' && card?.state && Object.hasOwn(channelAvatarStates, card.state) ? channelAvatarStates[card.state] ?? 'neutral' : 'neutral';\n const tone = state === 'blocked' ? 'text-destructive' : state === 'waiting' ? 'text-muted-foreground' : 'text-primary';\n const agent = agentTone(member);\n const avatar = <Avatar size={compact ? 'sm' : size} data-agent-color={agent?.token} data-testid=\"member-avatar\" role=\"img\" aria-label={member.kind === 'agent' ? `${member.name}, ${labels.sharedAgent}` : member.name} data-state={state}>\n {member.avatarUrl && <AvatarImage src={member.avatarUrl} alt={member.name} />}\n <AvatarFallback>{member.kind === 'agent' ? <BotIcon data-lucide=\"bot\" className={`${compact ? 'size-3' : 'size-4'} ${agent?.icon ?? ''}`} aria-hidden=\"true\" /> : member.name.replace(/[()]/g, '').split(/\\s+/).slice(0, 2).map(word => word[0]).join('').toUpperCase()}</AvatarFallback>\n {state !== 'neutral' && <span data-testid=\"avatar-indicator\" data-state={state} className={`absolute right-0 bottom-0 ${tone}`}>\n <CircleIcon className=\"size-2 fill-current\" aria-hidden=\"true\" />\n </span>}\n </Avatar>;\n if (member.kind !== 'agent' || !card || !interactive) return avatar;\n const trigger = <span role=\"button\" tabIndex={0} className=\"inline-flex shrink-0\" data-testid={`agent-avatar-${member.id}`} data-state={state} aria-label={`${member.name}: ${card.line}`}>{avatar}</span>;\n return phone\n ? <Popover open={open} onOpenChange={setOpen}><PopoverTrigger nativeButton={false} render={trigger} /><PopoverContent data-testid=\"agent-state-popover\" side=\"bottom\" align=\"start\" className=\"max-w-[calc(100vw-1rem)]\">{card.line}</PopoverContent></Popover>\n : <Tooltip><TooltipTrigger render={trigger} /><TooltipContent>{card.line}</TooltipContent></Tooltip>;\n}\nfunction MemberBadge({ member }: { readonly member: Member }) {\n const { labels } = useChatText();\n return member.kind === 'admin' ? <Badge data-testid=\"author-badge\" variant=\"secondary\">{labels.sharedAdmin}</Badge> : null;\n}\nfunction Dot({ value }: { readonly value: SharedChatState['status'] | Member['presence'] }) {\n return <CircleIcon aria-hidden=\"true\" className={`size-2 fill-current ${value === 'live' || value === 'here' ? 'text-primary' : value === 'offline' ? 'text-destructive' : 'text-muted-foreground'}`} />;\n}\n\n/** Mounted in the existing bar. The source owns membership and visibility, including the admin. */\nexport function SharedMembers({ controller }: { readonly controller: SharedChatController }) {\n const state = useSyncExternalStore(controller.subscribe, controller.getSnapshot, controller.getSnapshot);\n const { labels } = useChatText();\n const [open, setOpen] = useState(false);\n const phone = usePhone();\n const shared = state.shared;\n if (!shared) return null;\n const name = shared.status === 'live' ? labels.live : shared.status === 'reconnecting' ? labels.reconnecting : labels.sharedOffline;\n const content = <ul data-testid=\"member-list\" className=\"m-0 flex min-w-0 list-none flex-col gap-3 p-4\">\n {shared.members.map(member => <li key={member.id} data-member-id={member.id} className=\"flex min-w-0 items-center gap-2\">\n <MemberAvatar member={member} card={latestCard(shared, member.id)} /><span className=\"min-w-0 flex-1 truncate\">{member.name}</span>\n <Badge variant=\"secondary\">{member.role ?? member.kind}</Badge>\n <span data-testid=\"presence\" data-presence={member.presence ?? 'away'} role=\"img\" aria-label={member.presence === 'here' ? labels.sharedHere : labels.sharedAway}><Dot value={member.presence} /></span>\n </li>)}\n </ul>;\n const stack = <AvatarGroup>{shared.members.slice(0, phone ? 1 : 3).map(member => <MemberAvatar key={member.id} member={member} card={latestCard(shared, member.id)} interactive={false} />)}<AvatarGroupCount>{shared.members.length}</AvatarGroupCount></AvatarGroup>;\n const trigger = <Button variant=\"ghost\" data-testid=\"shared-members\" aria-label={labels.sharedMemberCount(shared.members.length)} aria-haspopup=\"dialog\" aria-expanded={open}>{stack}</Button>;\n return <div className=\"flex shrink-0 items-center gap-2\">\n <span data-testid=\"shared-connection-announcement\" className=\"sr-only\" aria-live=\"polite\" aria-atomic=\"true\">{name}</span>\n {shared.status !== 'live' && <Tooltip><TooltipTrigger render={<span tabIndex={0} data-testid=\"shared-status\" data-status={shared.status} role=\"img\" aria-label={name}><Dot value={shared.status} /></span>} /><TooltipContent>{name}</TooltipContent></Tooltip>}\n {phone ? <><Button variant=\"ghost\" data-testid=\"shared-members\" aria-label={labels.sharedMemberCount(shared.members.length)} aria-haspopup=\"dialog\" aria-expanded={open} onClick={() => setOpen(true)}>{stack}</Button>\n {open && <Drawer open onOpenChange={setOpen} showSwipeHandle><DrawerContent className=\"pi-chat max-h-[80dvh]\"><div className=\"flex shrink-0 items-center justify-between p-4\"><DrawerTitle>{labels.sharedMembers}</DrawerTitle><Button variant=\"ghost\" size=\"icon\" data-testid=\"shared-members-close\" aria-label={labels.close} onClick={() => setOpen(false)}><XIcon aria-hidden=\"true\" /></Button></div><div className=\"min-h-0 overflow-y-auto\">{content}</div></DrawerContent></Drawer>}</>\n : <Popover open={open} onOpenChange={setOpen}><PopoverTrigger render={trigger} /><PopoverContent role=\"dialog\" aria-label={labels.sharedMembers} align=\"end\" className=\"max-w-[calc(100vw-1rem)]\">{content}</PopoverContent></Popover>}\n </div>;\n}\n\nexport function RequestCard({ card, action }: { readonly card: Request; readonly action: (card: Request) => Promise<void> }) {\n const { labels } = useChatText();\n const [busy, setBusy] = useState(false), [error, setError] = useState<string>();\n return <Card data-testid=\"request-card\">\n <CardHeader><CardTitle>{card.title}</CardTitle></CardHeader>\n <CardContent><p className=\"m-0\">{card.line}</p>{card.since && <time dateTime={card.since} className=\"text-muted-foreground\">{labels.sharedSince(relativeTime(card.since, Date.now(), labels))}</time>}{error && <p role=\"alert\">{error}</p>}</CardContent>\n {card.action && <CardFooter><Button disabled={busy} onClick={() => { setBusy(true); setError(undefined); void action(card).catch(error => setError(error instanceof Error ? error.message : labels.actionFailedShort)).finally(() => setBusy(false)); }}>{card.action.label}</Button></CardFooter>}\n </Card>;\n}\n\n/** Both the composer chip and the timeline quote use this one parent reference. No thread or routing policy. */\nexport function ReplyQuote({ targetId, quote, onRemove, onReveal }: { readonly targetId: string; readonly quote: ReturnType<typeof sharedQuote>; readonly onRemove?: () => void; readonly onReveal?: ((messageId: string) => void) | undefined }) {\n const { labels } = useChatText();\n const text = quote?.message.removed ? labels.sharedRemoved : quote?.message.card?.title ?? quote?.message.text?.split(/\\r?\\n/)[0] ?? labels.sharedEarlierMessage;\n return <div data-testid={onRemove ? 'composer-reply' : 'message-quote'} className=\"flex w-full min-w-0 items-center gap-1\">\n <Button variant=\"ghost\" className=\"min-w-0 max-w-full justify-start\" onClick={event => {\n const chat = event.currentTarget.closest('[data-boring=pi-chat]');\n const parent = [...(chat?.querySelectorAll<HTMLElement>('[data-shared-message]') ?? [])].find(element => element.dataset.sharedMessage === targetId);\n if (parent) parent.scrollIntoView({ block: 'center', behavior: 'instant' }); else onReveal?.(targetId);\n }}><ReplyIcon aria-hidden=\"true\" /><span className=\"truncate text-muted-foreground\">{quote ? `${quote.author.name}: ${text}` : text}</span></Button>\n {onRemove && <Tooltip><TooltipTrigger render={<Button variant=\"ghost\" size=\"icon\" data-testid=\"composer-reply-remove\" aria-label={labels.sharedCancelReply} onClick={onRemove}><XIcon aria-hidden=\"true\" /></Button>} /><TooltipContent>{labels.sharedCancelReply}</TooltipContent></Tooltip>}\n </div>;\n}\n\n/** Participant metadata surrounds the existing bubbles, Markdown, cards and tool trace. */\nexport function SharedMessage({ shared, children, remove, canRemove, openFile, onReply, onReveal }: {\n readonly shared: SharedItem; readonly children: ReactNode;\n readonly remove?: ((message: ChannelMessage) => Promise<void>) | undefined;\n readonly canRemove?: ((message: ChannelMessage) => boolean) | undefined;\n readonly openFile?: ((path: string) => void) | undefined;\n readonly onReply?: ((messageId: string) => void) | undefined;\n readonly onReveal?: ((messageId: string) => void) | undefined;\n}) {\n const { labels } = useChatText();\n const [error, setError] = useState<string>();\n const [actionsOpen, setActionsOpen] = useState(false);\n const phone = usePhone();\n const element = useRef<HTMLDivElement>(null);\n const press = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);\n const start = useRef<{ x: number; y: number } | undefined>(undefined);\n const cancelPress = () => { clearTimeout(press.current); start.current = undefined; };\n useEffect(() => cancelPress, []);\n useEffect(() => {\n if (!actionsOpen) return;\n const outside = (event: PointerEvent) => { if (event.target instanceof Node && !element.current?.contains(event.target)) setActionsOpen(false); };\n document.addEventListener('pointerdown', outside);\n return () => document.removeEventListener('pointerdown', outside);\n }, [actionsOpen]);\n const { message, author, first, own, lastItem } = shared;\n const replyable = Boolean(onReply && ((author.kind === 'agent' && !message.via) || message.card));\n const removable = Boolean(remove && canRemove?.(message));\n const actionable = !message.removed && (replyable || removable);\n const heading = first && !own && <div data-testid=\"message-author-row\" className=\"ml-8 flex min-w-0 items-center gap-2\">\n <div className=\"flex min-w-0 items-center gap-2\">\n <span data-testid=\"message-author\" data-agent-color={agentTone(author)?.token} className={`min-w-0 truncate text-sm ${agentTone(author)?.text ?? 'text-muted-foreground'}`}>{author.name}</span>\n {message.via && <span data-testid=\"message-via\" className={`min-w-0 truncate text-muted-foreground${own ? '' : ' text-sm'}`}>{labels.sharedVia(message.via)}</span>}\n {!(message.via && author.kind === 'agent') && <MemberBadge member={author} />}\n </div>\n </div>;\n const body = <>\n {message.replyTo && <ReplyQuote targetId={message.replyTo} quote={shared.quote} onReveal={onReveal} />}\n {message.removed ? <p className=\"text-muted-foreground\">{labels.sharedRemoved}</p> : children}\n </>;\n return <div ref={element} data-shared-message={message.id} className=\"group/shared-message relative flex min-w-0 flex-col gap-1\"\n tabIndex={phone && actionable ? 0 : undefined} aria-keyshortcuts={phone && actionable ? 'Shift+F10' : undefined}\n onKeyDown={event => {\n if (!phone || !actionable) return;\n if (event.key === 'ContextMenu' || event.key === 'F10' && event.shiftKey) { event.preventDefault(); setActionsOpen(true); }\n if (event.key === 'Escape') { setActionsOpen(false); element.current?.focus(); }\n }}\n onPointerDown={event => {\n if (!phone || !actionable || event.pointerType !== 'touch' || event.target instanceof Element && event.target.closest('button, a, input, textarea, [role=button], [role=link]')) return;\n cancelPress(); start.current = { x: event.clientX, y: event.clientY };\n press.current = setTimeout(() => setActionsOpen(true), 400);\n }}\n onPointerMove={event => { if (start.current && (Math.abs(event.clientX - start.current.x) > 8 || Math.abs(event.clientY - start.current.y) > 8)) cancelPress(); }}\n onPointerUp={cancelPress} onPointerCancel={cancelPress}\n onContextMenu={event => { if (phone && actionable && event.target instanceof Element && !event.target.closest('button, a, input, textarea, [role=button], [role=link]')) { event.preventDefault(); setActionsOpen(true); } }}>\n {heading}\n <div className=\"flex min-w-0 items-end gap-2\">\n {!own && <div data-testid=\"message-avatar\" className=\"flex w-6 shrink-0 items-end\">{lastItem && <MemberAvatar member={author} card={message.via ? undefined : shared.latestCard} size=\"sm\" />}</div>}\n <div data-testid=\"shared-message-content\" className=\"flex min-w-0 flex-1 flex-col gap-1\">\n {body}\n {!message.removed && message.attachments?.map(file => <Button key={file.path} variant=\"outline\" disabled={!openFile} onClick={() => openFile?.(file.path)} className=\"max-w-full justify-start\"><span className=\"truncate\">{file.title ?? file.path}</span></Button>)}\n {error && <p role=\"alert\">{error}</p>}\n </div></div>\n {actionable && <div data-testid=\"shared-message-actions\" hidden={phone && !actionsOpen} className={`absolute right-0 top-0 ${phone && !actionsOpen ? 'hidden' : 'flex'} items-center gap-1${phone ? '' : ' opacity-0 group-hover/shared-message:opacity-100 group-focus-within/shared-message:opacity-100'}`}>\n {replyable && onReply && <Tooltip><TooltipTrigger render={<Button variant=\"ghost\" size={phone ? 'icon' : 'icon-xs'} data-testid=\"shared-reply\" aria-label={labels.sharedReply} onClick={() => { setActionsOpen(false); onReply(message.id); }}><ReplyIcon aria-hidden=\"true\" /></Button>} /><TooltipContent>{labels.sharedReply}</TooltipContent></Tooltip>}\n {removable && remove && <Tooltip><TooltipTrigger render={<Button variant=\"ghost\" size={phone ? 'icon' : 'icon-xs'} aria-label={labels.delete} onClick={() => { void remove(message).catch(error => setError(error instanceof Error ? error.message : labels.actionFailedShort)); }}><Trash2Icon aria-hidden=\"true\" /></Button>} /><TooltipContent>{labels.delete}</TooltipContent></Tooltip>}\n </div>}\n </div>;\n}\n\nexport interface SharedItem {\n readonly key: string; readonly message: ChannelMessage; readonly author: Member; readonly own: boolean; readonly first: boolean; readonly lastItem: boolean; readonly separator: boolean;\n readonly showSpinner: boolean; readonly latestCard: Request | undefined; readonly quote: ReturnType<typeof sharedQuote>;\n}\nexport function sharedItems(shared: SharedChatState): SharedItem[] {\n const working = new Map(shared.messages.filter(message => message.streaming).map(message => [message.author, message.id]));\n const groups: ChannelMessage[][] = [];\n for (const message of shared.messages) {\n const group = groups.at(-1), previous = group?.at(-1);\n const elapsed = previous ? Date.parse(message.at) - Date.parse(previous.at) : NaN;\n if (group && previous?.author === message.author && previous.via === message.via && elapsed >= 0 && elapsed <= 15 * 60_000) group.push(message);\n else groups.push([message]);\n }\n return groups.flatMap((group, groupIndex) => {\n const lastItem = group.at(-1);\n return group.map((message, index): SharedItem => {\n const previous = index > 0 ? group[index - 1] : groups[groupIndex - 1]?.at(-1);\n return { key: `shared:${message.id}`, message, author: memberOf(shared, message.author), own: message.author === shared.me.id,\n first: index === 0, lastItem: message === lastItem,\n separator: !previous || Date.parse(message.at) - Date.parse(previous.at) > 15 * 60_000,\n showSpinner: working.get(message.author) === message.id, latestCard: latestCard(shared, message.author), quote: sharedQuote(shared, message.replyTo) };\n });\n });\n}\n",
44
+ "type": "registry:component",
45
+ "target": "components/shared-chat/messages.tsx"
46
+ },
47
+ {
48
+ "path": "registry/shared-chat/timeline.tsx",
49
+ "content": "'use client';\nimport { Children, useEffect, 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';\nimport { useTranscript } 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 { MessageScroller, MessageScrollerViewport, MessageScrollerContent, MessageScrollerItem, MessageScrollerButton } from '@/components/ui/message-scroller';\nimport { SharedMessage, RequestCard, sharedItems } from './messages';\nimport { HostTrace } from './host-trace';\nimport { useChatText } from './text';\n\nexport interface TimelineApi { readonly reset: () => void; readonly reveal: (id: string) => void }\nexport function SharedTimeline({ controller, api, focusComposer, openFile, mentions, pendingVoice }: {\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;\n}) {\n const state = useSyncExternalStore(controller.subscribe, controller.getSnapshot, controller.getSnapshot);\n const { labels, icons } = useChatText();\n const items = useMemo(() => sharedItems(state.shared), [state.shared]);\n const connected = state.connection.kind === 'connected';\n const transcript = useTranscript(items, 60, 40, { state: state.history, connected: !state.disposed && connected, load: controller.loadEarlier });\n const { stick, hidden } = transcript;\n const root = useRef<HTMLDivElement>(null);\n const [revealing, setRevealing] = useState<string>();\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 = `shared:${revealing}`;\n if (items.some(item => item.key === 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 const context = { pieces: { mentions: true, skills: [], renderMention: mentions.renderSent }, openFile };\n const renderMention = (path: string, text: string) => mentions.renderSent?.(path) ?? text;\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 {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 className=\"mx-auto w-full max-w-[720px] px-4 pt-6 pb-8 sm:px-6\">\n <MessageScrollerContent aria-label={labels.messages} aria-live=\"polite\" data-testid=\"transcript\" className=\"gap-0 [&>[data-local-voice]]:mt-6\">\n {transcript.visible(items).map((item, index) => {\n const { message, author, own } = item;\n const person = own || author.kind !== 'agent' || message.via !== undefined;\n return <MessageScrollerItem key={item.key} messageId={item.key} className={`[content-visibility:visible]${index > 0 ? item.first ? ' mt-6' : ' mt-2' : ''}`}>\n {item.separator && <div data-testid=\"time-separator\" className=\"mb-6 flex justify-center text-xs text-muted-foreground\"><time dateTime={message.at}>{labels.sharedTimestamp(message.at)}</time></div>}\n <SharedMessage shared={item} remove={controller.remove} canRemove={controller.canRemove} openFile={openFile} onReply={id => { controller.setReplyTo(id); focusComposer(); }} onReveal={setRevealing}>\n {message.card ? <article data-row-id={item.key} data-role=\"event\" className=\"max-w-[80%]\"><RequestCard card={message.card} action={controller.action} /></article>\n : <article data-row-id={item.key} data-role={person ? 'user' : 'assistant'} data-streaming={message.streaming ? 'true' : undefined} className=\"group/message\"><Message align={own ? 'end' : 'start'}><MessageContent>\n {own ? <MessageText text={message.text ?? ''} context={context} /> : <Bubble variant=\"muted\"><BubbleContent>\n {message.text && <Markdown text={message.text} streaming={Boolean(message.streaming)} renderMention={renderMention} />}\n {!message.via && author.kind === 'agent' && (message.streaming || message.steps?.length) && <HostTrace participant={author.name} steps={message.steps ?? []} streaming={Boolean(message.streaming)} active={item.showSpinner} />}\n </BubbleContent></Bubble>}\n </MessageContent></Message></article>}\n </SharedMessage>\n </MessageScrollerItem>;\n })}\n {pendingVoice}\n </MessageScrollerContent>\n </div>\n </MessageScrollerViewport>\n {hasContent && <div data-testid=\"transcript-navigation\" className=\"flex h-12 shrink-0 items-center justify-center\">{!stick.isAtBottom && <MessageScrollerButton className=\"static inset-auto translate-x-0\" data-testid=\"jump-latest\" aria-label={labels.jumpToLatest} title={labels.jumpToLatest} onClick={() => api.current?.reset()}><icons.jumpToLatest aria-hidden=\"true\" /></MessageScrollerButton>}</div>}\n </MessageScroller>;\n}\n",
50
+ "type": "registry:component",
51
+ "target": "components/shared-chat/timeline.tsx"
52
+ },
53
+ {
54
+ "path": "registry/shared-chat/host-trace.tsx",
55
+ "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",
56
+ "type": "registry:component",
57
+ "target": "components/shared-chat/host-trace.tsx"
58
+ },
59
+ {
60
+ "path": "registry/shared-chat/text.tsx",
61
+ "content": "'use client';\nimport { createContext, useContext, useMemo } from 'react';\nimport type { ReactNode } from 'react';\nimport { useChatText as useNativeText } from '../pi-chat/labels';\nimport { defaultSharedLabels } from './shared-labels';\nimport type { SharedLabels } from './shared-labels';\nimport { frenchSharedLabels } from './shared-labels-fr';\n\nexport interface SharedTextOptions { readonly locale?: 'en' | 'fr' | undefined; readonly labels?: Partial<SharedLabels> | undefined }\nconst Context = createContext(defaultSharedLabels);\nexport function SharedTextProvider({ children, locale, labels }: SharedTextOptions & { readonly children: ReactNode }) {\n const value = useMemo(() => ({ ...(locale === 'fr' ? frenchSharedLabels : defaultSharedLabels), ...labels }), [locale, labels]);\n return <Context.Provider value={value}>{children}</Context.Provider>;\n}\nexport function useChatText() {\n const native = useNativeText(), labels = useContext(Context);\n return { ...native, labels: { ...native.labels, ...labels } };\n}\n",
62
+ "type": "registry:component",
63
+ "target": "components/shared-chat/text.tsx"
64
+ },
65
+ {
66
+ "path": "registry/shared-chat/shared-labels.ts",
67
+ "content": "export function formatTimestamp(at: string, locale: string) {\n const date = new Date(at);\n if (!Number.isFinite(date.getTime())) return at;\n return `${new Intl.DateTimeFormat(locale, { weekday: 'short', day: 'numeric', month: 'short' }).format(date)}, ${new Intl.DateTimeFormat(locale, { hour: '2-digit', minute: '2-digit' }).format(date)}`;\n}\nexport const defaultSharedLabels = {\n sharedTimestamp: (at: string) => formatTimestamp(at, 'en'),\n sharedMembers: 'Members', sharedMemberCount: (count: number) => `${count} members`,\n sharedAgent: 'Agent', sharedVia: (name: string) => `via ${name}`,\n sharedWorking: (name: string) => `${name} is working…`,\n sharedReply: 'Reply', sharedCancelReply: 'Cancel reply', sharedEarlierMessage: 'Earlier message',\n sharedAdmin: 'Admin', sharedHere: 'Here', sharedAway: 'Away', sharedOffline: 'Offline',\n sharedRemoved: 'Message removed', sharedSince: (time: string) => `Since ${time}`,\n};\nexport type SharedLabels = typeof defaultSharedLabels;\n",
68
+ "type": "registry:lib",
69
+ "target": "components/shared-chat/shared-labels.ts"
70
+ },
71
+ {
72
+ "path": "registry/shared-chat/shared-labels-fr.ts",
73
+ "content": "import type { SharedLabels } from './shared-labels';\nimport { formatTimestamp } from './shared-labels';\nexport const frenchSharedLabels: SharedLabels = {\n sharedTimestamp: (at: string) => formatTimestamp(at, 'fr'),\n sharedMembers: 'Membres', sharedMemberCount: (count: number) => `${count} membres`,\n sharedAgent: 'Agent', sharedVia: (name: string) => `via ${name}`,\n sharedWorking: (name: string) => `${name} travaille…`,\n sharedReply: 'Répondre', sharedCancelReply: 'Annuler la réponse', sharedEarlierMessage: 'Message précédent',\n sharedAdmin: 'Admin', sharedHere: 'Présent', sharedAway: 'Absent', sharedOffline: 'Hors ligne',\n sharedRemoved: 'Message supprimé', sharedSince: (time: string) => `Depuis ${time}`,\n};\n",
74
+ "type": "registry:lib",
75
+ "target": "components/shared-chat/shared-labels-fr.ts"
76
+ }
77
+ ],
78
+ "type": "registry:block"
79
+ }
@@ -4,8 +4,8 @@
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.3",
8
- "@hachej/boring-files@^0.4.3",
7
+ "@hachej/boring-ui-kit@^0.4.5",
8
+ "@hachej/boring-files@^0.4.5",
9
9
  "react@^19.0.0",
10
10
  "react-dom@^19.0.0",
11
11
  "zod@^4.6.5"
package/r/theme.json CHANGED
@@ -23,6 +23,11 @@
23
23
  "color-border": "var(--border)",
24
24
  "color-input": "var(--input)",
25
25
  "color-ring": "var(--ring)",
26
+ "color-chart-1": "var(--chart-1)",
27
+ "color-chart-2": "var(--chart-2)",
28
+ "color-chart-3": "var(--chart-3)",
29
+ "color-chart-4": "var(--chart-4)",
30
+ "color-chart-5": "var(--chart-5)",
26
31
  "radius-sm": "calc(var(--radius) - 4px)",
27
32
  "radius-md": "calc(var(--radius) - 2px)",
28
33
  "radius-lg": "var(--radius)",
@@ -57,6 +62,11 @@
57
62
  "border": "oklch(0.92 0.004 286.32)",
58
63
  "input": "oklch(0.89 0.005 286.32)",
59
64
  "ring": "oklch(0.55 0.02 286.067)",
65
+ "chart-1": "oklch(0.646 0.222 41.116)",
66
+ "chart-2": "oklch(0.6 0.118 184.704)",
67
+ "chart-3": "oklch(0.398 0.07 227.392)",
68
+ "chart-4": "oklch(0.828 0.189 84.429)",
69
+ "chart-5": "oklch(0.769 0.188 70.08)",
60
70
  "shell": "oklch(0.975 0.002 286)",
61
71
  "shadow-xs": "0 1px 2px 0 rgb(0 0 0 / 0.05)",
62
72
  "shadow-sm": "0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)",
@@ -84,6 +94,11 @@
84
94
  "border": "oklch(1 0 0 / 11%)",
85
95
  "input": "oklch(1 0 0 / 16%)",
86
96
  "ring": "oklch(0.65 0.02 286.067)",
97
+ "chart-1": "oklch(0.488 0.243 264.376)",
98
+ "chart-2": "oklch(0.696 0.17 162.48)",
99
+ "chart-3": "oklch(0.769 0.188 70.08)",
100
+ "chart-4": "oklch(0.627 0.265 303.9)",
101
+ "chart-5": "oklch(0.645 0.246 16.439)",
87
102
  "shell": "oklch(0.13 0.005 285.823)",
88
103
  "shadow-xs": "0 1px 2px 0 rgb(0 0 0 / 0.05)",
89
104
  "shadow-sm": "0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)",
package/r/utils.json CHANGED
@@ -9,6 +9,12 @@
9
9
  "react@^19.0.0"
10
10
  ],
11
11
  "files": [
12
+ {
13
+ "path": "registry/utils/use-draggable.ts",
14
+ "content": "'use client';\n\nimport { useEffect, useLayoutEffect, useRef, useState } from 'react';\nimport type { PointerEvent, RefObject } from 'react';\n\nexport interface DragPosition { readonly left: number; readonly top: number }\n\n/** Pointer-capture drag shared with the agent layer. Controls remain clickable and the whole surface stays reachable. */\nexport function useDraggable({ box, position, onPositionChange, enabled }: {\n readonly box: RefObject<HTMLElement | null>;\n readonly position: DragPosition | null;\n readonly onPositionChange: (position: DragPosition) => void;\n readonly enabled: boolean;\n}) {\n const drag = useRef<{ dx: number; dy: number } | null>(null);\n const [viewport, setViewport] = useState(0);\n useEffect(() => { const resize = () => setViewport(count => count + 1); window.addEventListener('resize', resize); return () => window.removeEventListener('resize', resize); }, []);\n useLayoutEffect(() => {\n const rect = box.current?.getBoundingClientRect();\n if (!position || !enabled || !rect || !rect.width) return;\n const left = Math.min(Math.max(position.left, 0), Math.max(0, window.innerWidth - rect.width)), top = Math.min(Math.max(position.top, 0), Math.max(0, window.innerHeight - rect.height));\n if (left !== position.left || top !== position.top) onPositionChange({ left, top });\n }, [box, position, enabled, viewport, onPositionChange]);\n const onPointerDown = (event: PointerEvent<HTMLElement>) => {\n const rect = box.current?.getBoundingClientRect();\n if (!enabled || !rect || event.button !== 0 || (event.target as HTMLElement).closest('button, a[href]')) return;\n drag.current = { dx: event.clientX - rect.left, dy: event.clientY - rect.top };\n event.currentTarget.setPointerCapture?.(event.pointerId);\n };\n const onPointerMove = (event: PointerEvent<HTMLElement>) => {\n const grab = drag.current, rect = box.current?.getBoundingClientRect();\n if (!grab || !rect) return;\n onPositionChange({ left: Math.min(Math.max(event.clientX - grab.dx, 0), Math.max(0, window.innerWidth - rect.width)), top: Math.min(Math.max(event.clientY - grab.dy, 0), Math.max(0, window.innerHeight - rect.height)) });\n };\n const end = () => { drag.current = null; };\n return { onPointerDown, onPointerMove, onPointerUp: end, onPointerCancel: end };\n}\n",
15
+ "type": "registry:hook",
16
+ "target": "components/utils/use-draggable.ts"
17
+ },
12
18
  {
13
19
  "path": "registry/utils/utils.ts",
14
20
  "content": "import { clsx } from 'clsx';\nimport type { ClassValue } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\nexport function cn(...inputs: ClassValue[]): string { return twMerge(clsx(inputs)); }\n\n/** Thrown when no automatic copy worked; `text` is what the person must copy by hand (a viewer shows it selected). */\nexport class ManualCopyError extends Error {\n readonly text: string;\n constructor(text: string) { super('Copy the text by hand'); this.name = 'ManualCopyError'; this.text = text; }\n}\n\n/**\n * Copies `text` using the host's callback when given, else the async Clipboard API (secure contexts only), else a hidden\n * textarea and `document.execCommand('copy')` (works over plain HTTP). Throws `ManualCopyError` when none worked.\n * This is the one place this item touches the clipboard; `npm run check` keeps it that way.\n */\nexport async function copyText(text: string, onCopy?: (text: string) => Promise<void>): Promise<void> {\n if (onCopy) return onCopy(text);\n try { const clipboard = globalThis.navigator?.clipboard; if (clipboard) { await clipboard.writeText(text); return; } }\n catch { /* denied or unfocused: use the selection route */ }\n const doc = globalThis.document;\n if (doc?.body && typeof doc.execCommand === 'function') {\n const area = doc.createElement('textarea');\n area.value = text;\n area.setAttribute('readonly', '');\n area.style.cssText = 'position:fixed;top:0;left:0;width:1px;height:1px;opacity:0;pointer-events:none';\n const focus = doc.activeElement instanceof HTMLElement ? doc.activeElement : undefined;\n doc.body.append(area);\n try { area.select(); area.setSelectionRange(0, text.length); if (doc.execCommand('copy')) return; }\n catch { /* fall through */ }\n finally { area.remove(); focus?.focus({ preventScroll: true }); }\n }\n throw new ManualCopyError(text);\n}\n\n/**\n * A block's `labels` or `icons`: `defaults` with each partial override on top, later ones winning. A key an override leaves `undefined`\n * keeps the value below it, so a host can pass `{ title: maybe }` safely.\n */\nexport function withDefaults<T extends object>(defaults: T, ...overrides: readonly (Partial<T> | undefined)[]): T {\n const merged: Record<string, unknown> = { ...defaults as Record<string, unknown> };\n for (const override of overrides) for (const [key, value] of Object.entries(override ?? {})) if (value !== undefined) merged[key] = value;\n return merged as T;\n}\n",
@@ -4,12 +4,12 @@
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.3",
7
+ "@hachej/boring-files@^0.4.5",
8
8
  "react@^19.0.0"
9
9
  ],
10
10
  "registryDependencies": [
11
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.3/r/theme.json",
12
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.3/r/button.json",
11
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.5/r/theme.json",
12
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.5/r/button.json",
13
13
  "button"
14
14
  ],
15
15
  "files": [
package/r/viewers.json CHANGED
@@ -2,10 +2,10 @@
2
2
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
3
  "name": "viewers",
4
4
  "title": "Viewers",
5
- "description": "The standard viewer top bar (title, status, viewer controls, Refresh, Share, Copy, Download, Open in new tab, Close, overflow menu) with Tailwind panes over it: the Markdown editor with a formatting toolbar and prose styling, the passive HTML preview and source editor, an image viewer with zoom and pan, and a PDF viewer. Owned, editable source over the borrowed controllers; no AI SDK.",
5
+ "description": "The standard viewer top bar (title, status, viewer controls, Refresh, Share, Copy, Download, Open in new tab, Close, overflow menu) with Tailwind panes over it: the Markdown editor with a formatting toolbar and prose styling, the passive HTML preview and source editor, an image viewer with zoom and pan, a PDF viewer, and LocalFileViewer for browser-only files. Owned, editable source over the borrowed controllers; no AI SDK.",
6
6
  "dependencies": [
7
- "@hachej/boring-ui-kit@^0.4.3",
8
- "@hachej/boring-files@^0.4.3",
7
+ "@hachej/boring-ui-kit@^0.4.5",
8
+ "@hachej/boring-files@^0.4.5",
9
9
  "react@^19.0.0",
10
10
  "react-dom@^19.0.0",
11
11
  "marked@^17.0.6",
@@ -25,12 +25,12 @@
25
25
  "pdfjs-dist@6.3.289"
26
26
  ],
27
27
  "registryDependencies": [
28
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.3/r/theme.json",
29
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.3/r/utils.json",
30
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.3/r/button.json",
28
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.5/r/theme.json",
29
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.5/r/utils.json",
30
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.5/r/button.json",
31
31
  "button",
32
32
  "toggle",
33
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.3/r/version-history.json",
33
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.5/r/version-history.json",
34
34
  "toggle-group",
35
35
  "dialog",
36
36
  "textarea",
@@ -62,6 +62,12 @@
62
62
  "type": "registry:component",
63
63
  "target": "components/viewers/interactive-html.tsx"
64
64
  },
65
+ {
66
+ "path": "registry/viewers/local-file-viewer.tsx",
67
+ "content": "'use client';\n\nimport { useEffect, useMemo, useState } from 'react';\nimport { mediaTypeOf } from '@hachej/boring-files/file-types';\nimport { downloadFile } from './download';\nimport { IMAGE_TYPES, ImagePane } from './image-pane';\nimport { formatBytes } from './media';\nimport { PdfPane } from './pdf-pane';\nimport { ViewerFrame } from './viewer-frame';\nimport type { ViewerFrameProps } from './viewer-frame';\nimport { useViewerText } from './viewer-window';\n\nexport interface LocalFileViewerProps extends Pick<ViewerFrameProps, 'subtitle' | 'headerStart' | 'controls' | 'onClose' | 'testId' | 'titleTestId' | 'className'> {\n readonly name: string;\n /** Browser-only bytes. Nothing is uploaded or retained on reload. A File is also a Blob. */\n readonly blob: Blob;\n}\n\n/** A read-only browser file, without a resource client or locator. A specific Blob type wins; otherwise the name supplies the type. */\nexport function LocalFileViewer({ name, blob, ...frame }: LocalFileViewerProps) {\n const declared = blob.type.split(';')[0]?.trim().toLowerCase();\n const mediaType = !declared || declared === 'application/octet-stream' ? mediaTypeOf(name) : declared;\n const media = useMemo(() => blob.type === mediaType ? blob : blob.slice(0, blob.size, mediaType), [blob, mediaType]);\n if (Object.hasOwn(IMAGE_TYPES, mediaType)) return <ImagePane key={name} name={name} blob={media} mediaType={mediaType} {...frame} />;\n if (mediaType === 'application/pdf') return <PdfPane key={name} name={name} blob={media} {...frame} />;\n const text = mediaType.startsWith('text/') || mediaType === 'application/json' || mediaType === 'application/xml' || mediaType.endsWith('+json') || mediaType.endsWith('+xml');\n return <LocalText name={name} blob={blob} mediaType={mediaType} text={text} {...frame} />;\n}\n\ntype Preview = { readonly blob: Blob; readonly text: string | undefined };\n\n/** Text stays literal, including HTML. Unknown types and failed UTF-8 reads keep the original download. */\nfunction LocalText({ name, blob, mediaType, text: canRead, subtitle, testId = 'viewer', ...frame }: LocalFileViewerProps & { readonly mediaType: string; readonly text: boolean }) {\n const { labels } = useViewerText();\n const [preview, setPreview] = useState<Preview>();\n useEffect(() => {\n if (!canRead) return;\n let active = true;\n void (async () => {\n let text: string | undefined;\n try {\n const bytes = new Uint8Array(await blob.arrayBuffer());\n if (!bytes.includes(0)) text = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes);\n } catch { /* A failed read leaves the original file available for download. */ }\n if (active) setPreview({ blob, text });\n })();\n return () => { active = false; };\n }, [blob, canRead]);\n const loading = canRead && preview?.blob !== blob;\n const text = canRead && preview?.blob === blob ? preview.text : undefined;\n const title = name.split('/').pop() || name;\n return <ViewerFrame title={title} testId={testId} {...frame}\n subtitle={<>{subtitle}{subtitle && <span aria-hidden=\"true\">·</span>}{mediaType} · {formatBytes(blob.size)}</>}\n onDownload={() => downloadFile(title, blob, blob.type || mediaType)}>\n {text === undefined\n ? <p role=\"status\" className=\"m-0 p-4 text-sm text-muted-foreground\">{loading ? labels.loading : labels.noPreview}</p>\n : <div className=\"min-h-0 flex-1 overflow-auto p-3\"><pre data-testid={`${testId}-text`} className=\"m-0 font-mono text-sm leading-relaxed break-words whitespace-pre-wrap\">{text}</pre></div>}\n </ViewerFrame>;\n}\n",
68
+ "type": "registry:component",
69
+ "target": "components/viewers/local-file-viewer.tsx"
70
+ },
65
71
  {
66
72
  "path": "registry/viewers/markdown-pane.tsx",
67
73
  "content": "'use client';\n\nimport { useCallback, useMemo, useRef, useState, useSyncExternalStore } from 'react';\nimport type { ReactNode } from 'react';\nimport { CodeXmlIcon, PencilLineIcon, SaveIcon } from 'lucide-react';\nimport { MarkdownEditor } from '@hachej/boring-ui-kit/markdown-editor';\nimport type { MarkdownEditorProps, MarkdownImageResolver, MarkdownMountedTools, MarkdownRichSafety } from '@hachej/boring-ui-kit/markdown-editor';\nimport type { MarkdownController } from '@hachej/boring-ui-kit/markdown';\nimport { Button } from '@/components/ui/button';\nimport { editorControls } from '../button/editor-controls';\nimport { downloadFile, fileNameFor } from './download';\nimport { copyText } from '../utils/utils';\nimport { ViewerFrame, ViewerToggle } from './viewer-frame';\nimport type { ViewerShare, ViewerStatus } from './viewer-frame';\nimport { defaultViewerLabels, useViewerText, useViewerWindow } from './viewer-window';\nimport type { ViewerLabels } from './viewer-window';\n\nexport interface MarkdownPaneProps {\n /** Borrowed: the host owns the controller and disposes it. */\n readonly controller: MarkdownController;\n readonly title?: string;\n readonly subtitle?: ReactNode;\n readonly target?: unknown;\n readonly revision?: string;\n readonly initialMode?: 'rich' | 'source';\n /** Reports the person's choice, so a host that remounts the pane (for a new revision) can start it in the same mode. */\n readonly onModeChange?: (mode: 'rich' | 'source') => void;\n readonly placeholder?: string;\n /** Defaults to 1200ms idle autosave; false keeps the Save button. */\n readonly autosave?: MarkdownEditorProps['autosave'];\n /** Registers the flush for owner teardown; this is not a navigation guard. */\n readonly onAutosaveChange?: MarkdownEditorProps['onAutosaveChange'];\n /** Registers with a host navigation guard whose onClose handles refused saves and deliberate discard. */\n readonly onBeforeLeaveChange?: MarkdownEditorProps['onAutosaveChange'];\n /** Pinned by default on desktop. Phones always use the selection bubble. */\n readonly formattingToolbar?: MarkdownEditorProps['formattingToolbar'];\n /** Leading header content, before the title. */\n readonly headerStart?: ReactNode;\n /** Extra viewer controls, for example a version switcher. */\n readonly controls?: ReactNode;\n /** Defaults to the controller's refresh: a clean document follows the saved one, unsaved edits are kept and shown as changed elsewhere. */\n readonly onRefresh?: () => unknown;\n readonly onShare?: ViewerShare;\n readonly onOpenInNewTab?: () => unknown;\n readonly onClose?: () => unknown;\n /** Host mapping from an image address in the document to a loadable URL. Images stay inert without it. */\n readonly resolveImage?: MarkdownImageResolver;\n readonly onMountedTools?: (tools: MarkdownMountedTools | null) => void;\n readonly testId?: string;\n /** Overrides the title's `data-testid` (default `<testId>-title`). */\n readonly titleTestId?: string;\n readonly className?: string;\n /** The pane's and its bar's words, over the window's and `defaultViewerLabels`. */\n readonly labels?: Partial<ViewerLabels> | undefined;\n}\n\n/** The status dot for a Markdown controller: Saved (primary), Unsaved or Saving (muted), Save unconfirmed or Changed elsewhere (destructive, with words), Read-only. */\nexport function markdownStatus(state: ReturnType<MarkdownController['getSnapshot']>, labels: ViewerLabels = defaultViewerLabels): ViewerStatus {\n if (state.lifecycle === 'disposed') return { label: labels.closed };\n if (state.save.kind === 'pending') return { label: labels.saving, tone: 'warning' };\n if (state.save.kind === 'settled' && state.save.result.kind === 'unknown') return { label: labels.saveUnconfirmed, tone: 'danger' };\n if (state.save.kind === 'settled' && state.save.result.kind === 'conflict' || state.remote !== null) return { label: labels.changedElsewhere, tone: 'danger' };\n if (state.readOnly) return { label: labels.readOnly, kind: 'read-only' };\n return state.dirty ? { label: labels.unsaved, tone: 'warning' } : { label: labels.saved, tone: 'success', kind: 'saved' };\n}\n\n/** The Markdown editor in the standard viewer frame: mode toggle, Save while there are edits, Share, and Reload, Copy and Download under the \"…\" menu. */\nexport function MarkdownPane({ controller, title: givenTitle, labels: ownLabels, subtitle, target, revision, initialMode = 'rich', onModeChange, placeholder, formattingToolbar = 'pinned', autosave: ownAutosave, onAutosaveChange, onBeforeLeaveChange, headerStart, controls, onRefresh, onShare, onOpenInNewTab, onClose, resolveImage, onMountedTools, testId = 'viewer', titleTestId, className }: MarkdownPaneProps) {\n const state = useSyncExternalStore(controller.subscribe, controller.getSnapshot, controller.getSnapshot);\n const { labels } = useViewerText(ownLabels);\n const window = useViewerWindow();\n const autosave = ownAutosave ?? window?.autosave ?? true;\n const flush = useRef<(() => Promise<boolean>) | null>(null);\n const registerAutosave = useCallback((save: (() => Promise<boolean>) | null) => { flush.current = save; onAutosaveChange?.(save); onBeforeLeaveChange?.(save); }, [onAutosaveChange, onBeforeLeaveChange]);\n const close = onClose ? async () => {\n if (autosave !== false && flush.current && !await flush.current() && !onBeforeLeaveChange) return false;\n return onClose();\n } : undefined;\n const title = givenTitle ?? labels.document;\n const [requested, setRequestedState] = useState<'rich' | 'source'>(initialMode);\n const setRequested = (next: 'rich' | 'source') => { setRequestedState(next); onModeChange?.(next); };\n const [safety, setSafety] = useState<MarkdownRichSafety>({ safe: true });\n const mode = safety.safe ? requested : 'source';\n const unavailable = state.readOnly || state.lifecycle !== 'active';\n const pending = state.save.kind === 'pending';\n const unconfirmed = state.save.kind === 'settled' && state.save.result.kind === 'unknown';\n const [refused, setRefused] = useState<{ readonly save: typeof state.save; readonly label: string } | null>(null);\n const report: NonNullable<MarkdownEditorProps['onSaveResult']> = result => {\n if (result.kind === 'saved' || result.kind === 'conflict') setRefused(null);\n else if (result.kind === 'denied' || result.kind === 'unavailable' || result.kind === 'file_too_large') {\n const reason = result.reason ?? result.kind;\n setRefused({ save: controller.getSnapshot().save, label: labels.notSaved(reason.length > 48 ? `${reason.slice(0, 47)}…` : reason) });\n }\n };\n const save = () => {\n setRefused(null);\n void controller.flush(controller.actions.selection()).then(report, () => setRefused({ save: controller.getSnapshot().save, label: labels.notSaved(labels.saveFailedReason) }));\n };\n const filename = useMemo(() => fileNameFor(title, 'md'), [title]);\n return <ViewerFrame showSavedStatus title={title} subtitle={subtitle} headerStart={headerStart} status={refused && refused.save === state.save && state.save.kind !== 'pending' ? { label: refused.label, tone: 'danger' } : markdownStatus(state, labels)} target={target} labels={labels} revision={revision} testId={testId} {...(titleTestId ? { titleTestId } : {})} className={className}\n onRefresh={onRefresh ?? (() => controller.actions.refresh())} onShare={onShare} onCopy={() => copyText(controller.getSnapshot().text)}\n onDownload={() => downloadFile(filename, controller.getSnapshot().text, 'text/markdown;charset=utf-8')} onOpenInNewTab={onOpenInNewTab} onClose={close}\n controls={<>\n <ViewerToggle label={labels.editorMode} value={mode} onChange={setRequested} options={[\n { id: 'rich', label: labels.richText, text: labels.rich, icon: <PencilLineIcon data-icon=\"inline-start\" aria-hidden=\"true\" />, disabled: !safety.safe, reason: safety.safe ? undefined : labels.richUnavailable(safety.reasons.join(', ')), testId: `${testId}-mode-rich` },\n { id: 'source', label: labels.markdownSource, text: labels.source, icon: <CodeXmlIcon data-icon=\"inline-start\" aria-hidden=\"true\" />, testId: `${testId}-mode-source` }]} />\n {autosave === false && !state.readOnly && state.dirty && <Button variant=\"ghost\" size=\"icon-sm\" aria-label={labels.save} title={labels.save} data-testid={`${testId}-save`} disabled={unavailable || pending || unconfirmed} onClick={save}><SaveIcon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>}\n {controls}\n </>}>\n <MarkdownEditor components={editorControls} controller={controller} title={title} header={false} autosave={autosave} onAutosaveChange={registerAutosave} onSaveResult={report} formattingToolbar={formattingToolbar} mode={mode} onModeChange={setRequested} onRichSafety={setSafety}\n {...(placeholder === undefined ? {} : { placeholder })} {...(resolveImage ? { resolveImage } : {})} {...(onMountedTools ? { onMountedTools } : {})} className=\"boring-markdown-editor\" />\n </ViewerFrame>;\n}\n",
@@ -106,13 +112,13 @@
106
112
  },
107
113
  {
108
114
  "path": "registry/viewers/viewer-window.ts",
109
- "content": "/*\n * The window a viewer sits in, and the viewers' words and icons as plain data. A host passes a partial `labels` (and `icons`) object to\n * `ViewerWindowProvider` (every viewer below reads it) or to one `ViewerFrame`; what it leaves out keeps the default below.\n */\nimport { createContext, useContext, useMemo } from 'react';\nimport { EllipsisIcon, Maximize2Icon, Minimize2Icon, Share2Icon, XIcon } from 'lucide-react';\nimport type { ReactNode } from 'react';\nimport type { MarkdownEditorProps } from '@hachej/boring-ui-kit/markdown-editor';\nimport type { BlockAction, BlockIcon } from '../button/actions';\nimport { withDefaults } from '../utils/utils';\nimport { defaultVersionHistoryLabels } from '../version-history/version-history';\n\nexport const defaultViewerLabels = {\n share: 'Share',\n moreActions: 'More actions',\n reload: 'Reload',\n copy: 'Copy',\n download: 'Download',\n openInNewTab: 'Open in new tab',\n enterFullscreen: 'Enter full screen',\n exitFullscreen: 'Exit full screen',\n close: 'Close',\n readOnly: 'Read-only',\n refreshFailed: 'Refresh failed',\n shareFailed: 'Could not share',\n linkCopied: 'Link copied',\n shared: 'Shared',\n copied: 'Copied',\n copyFailed: 'Could not copy',\n actionFailed: 'The action failed',\n /** Shown when the browser allowed no automatic copy: `link` is true for Share, false for Copy. */\n manualCopy: (link: boolean) => `This browser blocked automatic copying. Copy the ${link ? 'link' : 'text'} below.`,\n done: 'Done',\n ...defaultVersionHistoryLabels,\n versionViewOnly: 'Viewing an older version. Restore is not available in this viewer.',\n latest: 'Latest',\n loading: 'Loading…',\n // Save status\n saved: 'Saved',\n unsaved: 'Unsaved',\n saving: 'Saving…',\n saveUnconfirmed: 'Save unconfirmed',\n changedElsewhere: 'Changed elsewhere',\n closed: 'Closed',\n save: 'Save',\n checkSave: 'Check save outcome',\n abandon: 'Abandon and refresh, keeping my draft',\n discardLocal: 'Discard local edits',\n // Modes\n view: 'View',\n preview: 'Preview',\n source: 'Source',\n htmlPreview: 'HTML preview',\n htmlSource: 'HTML source',\n editorMode: 'Editor mode',\n rich: 'Rich',\n richText: 'Rich text',\n markdownSource: 'Markdown source',\n /** Tooltip of the disabled Text mode: what the rich editor could not keep exactly. */\n richUnavailable: (reasons: string) => `Rich text is off: this document contains ${reasons}, which it could not keep exactly`,\n document: 'Document',\n // Images and PDFs\n zoom: 'Zoom',\n zoomIn: 'Zoom in',\n zoomOut: 'Zoom out',\n zoomLevel: (percent: string) => `Zoom ${percent}, fit to view`,\n fitToView: 'Fit to view',\n actualSize: 'Actual size',\n imageUnreadable: 'This file could not be read as an image.',\n imageUnsupported: (mediaType: string) => `This image type (${mediaType || 'unknown'}) cannot be shown here. You can still download it.`,\n imageStage: (name: string) => `${name} image`,\n pdfUnsupported: 'This browser cannot show PDFs inline.',\n pdfDocument: (name: string) => `${name} pages`,\n /** The page indicator of the drawn pages, for example `3 / 12`. */\n pdfPage: (current: number, count: number) => `${current} / ${count}`,\n pdfHint: (name: string, canOpen: boolean) => `Download ${name} to read it in a PDF app${canOpen ? ', or open it in a new tab' : ''}.`,\n notSaved: (reason: string) => `Not saved: ${reason}`,\n saveFailedReason: 'the save failed',\n changedElsewhereReason: 'changed elsewhere',\n operationFailed: 'The document operation failed. Your source has been retained.',\n};\nexport type ViewerLabels = typeof defaultViewerLabels;\n\nexport const defaultViewerIcons = {\n share: Share2Icon as BlockIcon,\n more: EllipsisIcon as BlockIcon,\n close: XIcon as BlockIcon,\n enterFullscreen: Maximize2Icon as BlockIcon,\n exitFullscreen: Minimize2Icon as BlockIcon,\n};\nexport type ViewerIcons = typeof defaultViewerIcons;\n\n/**\n * The window a viewer sits in, when the host lays viewers out in a panel (pi-app's `AgentWorkspace`, the ambient window). With\n * `onFullscreenChange`, every `ViewerFrame` below shows an \"Enter full screen\" / \"Exit full screen\" button; `actions` adds the host's\n * actions to every viewer bar (`header` ones as buttons, `menu` ones in its \"…\" menu, test ids `<frame testId>-<id>`); `labels` and\n * `icons` reach every viewer below. Without a provider nothing changes.\n */\nexport interface ViewerWindow {\n /** Markdown autosave policy for every pane below. Defaults to true, with a 1200ms idle delay. */\n readonly autosave?: MarkdownEditorProps['autosave'];\n /** Place the existing bar in a host-owned shared header (for example with createPortal). Default: above the viewer body. */\n readonly renderHeader?: ((header: ReactNode) => ReactNode) | undefined;\n /** `stacked` keeps the title and window actions above a separate content toolbar. Default: `inline`, one row. */\n readonly headerLayout?: 'inline' | 'stacked' | undefined;\n readonly fullscreen?: boolean | undefined;\n readonly onFullscreenChange?: ((next: boolean) => void) | undefined;\n readonly actions?: readonly BlockAction[] | undefined;\n readonly labels?: Partial<ViewerLabels> | undefined;\n readonly icons?: Partial<ViewerIcons> | undefined;\n}\nconst ViewerWindowContext = createContext<ViewerWindow | undefined>(undefined);\nexport const ViewerWindowProvider = ViewerWindowContext.Provider;\nexport const useViewerWindow = (): ViewerWindow | undefined => useContext(ViewerWindowContext);\n\n/** The viewers' labels and icons: the defaults, then the window's, then the frame's own. */\nexport function useViewerText(labels?: Partial<ViewerLabels>, icons?: Partial<ViewerIcons>): { readonly labels: ViewerLabels; readonly icons: ViewerIcons } {\n const host = useViewerWindow();\n return useMemo(() => ({ labels: withDefaults(defaultViewerLabels, host?.labels, labels), icons: withDefaults(defaultViewerIcons, host?.icons, icons) }), [host?.labels, host?.icons, labels, icons]);\n}\n",
115
+ "content": "/*\n * The window a viewer sits in, and the viewers' words and icons as plain data. A host passes a partial `labels` (and `icons`) object to\n * `ViewerWindowProvider` (every viewer below reads it) or to one `ViewerFrame`; what it leaves out keeps the default below.\n */\nimport { createContext, useContext, useMemo } from 'react';\nimport { EllipsisIcon, Maximize2Icon, Minimize2Icon, Share2Icon, XIcon } from 'lucide-react';\nimport type { ReactNode } from 'react';\nimport type { MarkdownEditorProps } from '@hachej/boring-ui-kit/markdown-editor';\nimport type { BlockAction, BlockIcon } from '../button/actions';\nimport { withDefaults } from '../utils/utils';\nimport { defaultVersionHistoryLabels } from '../version-history/version-history';\n\nexport const defaultViewerLabels = {\n share: 'Share',\n moreActions: 'More actions',\n reload: 'Reload',\n copy: 'Copy',\n download: 'Download',\n openInNewTab: 'Open in new tab',\n enterFullscreen: 'Enter full screen',\n exitFullscreen: 'Exit full screen',\n close: 'Close',\n readOnly: 'Read-only',\n refreshFailed: 'Refresh failed',\n shareFailed: 'Could not share',\n linkCopied: 'Link copied',\n shared: 'Shared',\n copied: 'Copied',\n copyFailed: 'Could not copy',\n actionFailed: 'The action failed',\n /** Shown when the browser allowed no automatic copy: `link` is true for Share, false for Copy. */\n manualCopy: (link: boolean) => `This browser blocked automatic copying. Copy the ${link ? 'link' : 'text'} below.`,\n done: 'Done',\n ...defaultVersionHistoryLabels,\n versionViewOnly: 'Viewing an older version. Restore is not available in this viewer.',\n latest: 'Latest',\n loading: 'Loading…',\n // Save status\n saved: 'Saved',\n unsaved: 'Unsaved',\n saving: 'Saving…',\n saveUnconfirmed: 'Save unconfirmed',\n changedElsewhere: 'Changed elsewhere',\n closed: 'Closed',\n save: 'Save',\n checkSave: 'Check save outcome',\n abandon: 'Abandon and refresh, keeping my draft',\n discardLocal: 'Discard local edits',\n // Modes\n view: 'View',\n preview: 'Preview',\n source: 'Source',\n htmlPreview: 'HTML preview',\n htmlSource: 'HTML source',\n editorMode: 'Editor mode',\n rich: 'Rich',\n richText: 'Rich text',\n markdownSource: 'Markdown source',\n /** Tooltip of the disabled Text mode: what the rich editor could not keep exactly. */\n richUnavailable: (reasons: string) => `Rich text is off: this document contains ${reasons}, which it could not keep exactly`,\n document: 'Document',\n // Images and PDFs\n zoom: 'Zoom',\n zoomIn: 'Zoom in',\n zoomOut: 'Zoom out',\n zoomLevel: (percent: string) => `Zoom ${percent}, fit to view`,\n fitToView: 'Fit to view',\n actualSize: 'Actual size',\n noPreview: 'No preview available. You can still download this file.',\n imageUnreadable: 'This file could not be read as an image.',\n imageUnsupported: (mediaType: string) => `This image type (${mediaType || 'unknown'}) cannot be shown here. You can still download it.`,\n imageStage: (name: string) => `${name} image`,\n pdfUnsupported: 'This browser cannot show PDFs inline.',\n pdfDocument: (name: string) => `${name} pages`,\n /** The page indicator of the drawn pages, for example `3 / 12`. */\n pdfPage: (current: number, count: number) => `${current} / ${count}`,\n pdfHint: (name: string, canOpen: boolean) => `Download ${name} to read it in a PDF app${canOpen ? ', or open it in a new tab' : ''}.`,\n notSaved: (reason: string) => `Not saved: ${reason}`,\n saveFailedReason: 'the save failed',\n changedElsewhereReason: 'changed elsewhere',\n operationFailed: 'The document operation failed. Your source has been retained.',\n};\nexport type ViewerLabels = typeof defaultViewerLabels;\n\nexport const defaultViewerIcons = {\n share: Share2Icon as BlockIcon,\n more: EllipsisIcon as BlockIcon,\n close: XIcon as BlockIcon,\n enterFullscreen: Maximize2Icon as BlockIcon,\n exitFullscreen: Minimize2Icon as BlockIcon,\n};\nexport type ViewerIcons = typeof defaultViewerIcons;\n\n/**\n * The window a viewer sits in, when the host lays viewers out in a panel (pi-app's `AgentWorkspace`, the ambient window). With\n * `onFullscreenChange`, every `ViewerFrame` below shows an \"Enter full screen\" / \"Exit full screen\" button; `actions` adds the host's\n * actions to every viewer bar (`header` ones as buttons, `menu` ones in its \"…\" menu, test ids `<frame testId>-<id>`); `labels` and\n * `icons` reach every viewer below. Without a provider nothing changes.\n */\nexport interface ViewerWindow {\n /** Markdown autosave policy for every pane below. Defaults to true, with a 1200ms idle delay. */\n readonly autosave?: MarkdownEditorProps['autosave'];\n /** Place the existing bar in a host-owned shared header (for example with createPortal). Default: above the viewer body. */\n readonly renderHeader?: ((header: ReactNode) => ReactNode) | undefined;\n /** `stacked` keeps the title and window actions above a separate content toolbar. Default: `inline`, one row. */\n readonly headerLayout?: 'inline' | 'stacked' | undefined;\n readonly fullscreen?: boolean | undefined;\n readonly onFullscreenChange?: ((next: boolean) => void) | undefined;\n readonly actions?: readonly BlockAction[] | undefined;\n readonly labels?: Partial<ViewerLabels> | undefined;\n readonly icons?: Partial<ViewerIcons> | undefined;\n}\nconst ViewerWindowContext = createContext<ViewerWindow | undefined>(undefined);\nexport const ViewerWindowProvider = ViewerWindowContext.Provider;\nexport const useViewerWindow = (): ViewerWindow | undefined => useContext(ViewerWindowContext);\n\n/** The viewers' labels and icons: the defaults, then the window's, then the frame's own. */\nexport function useViewerText(labels?: Partial<ViewerLabels>, icons?: Partial<ViewerIcons>): { readonly labels: ViewerLabels; readonly icons: ViewerIcons } {\n const host = useViewerWindow();\n return useMemo(() => ({ labels: withDefaults(defaultViewerLabels, host?.labels, labels), icons: withDefaults(defaultViewerIcons, host?.icons, icons) }), [host?.labels, host?.icons, labels, icons]);\n}\n",
110
116
  "type": "registry:lib",
111
117
  "target": "components/viewers/viewer-window.ts"
112
118
  },
113
119
  {
114
120
  "path": "registry/viewers/viewer-labels-fr.ts",
115
- "content": "/* The viewers' words in French, the `locale=\"fr\"` base of `AgentWorkspace`'s viewers. Typed as `ViewerLabels`, so a key added in English must be added here too. */\nimport type { ViewerLabels } from './viewer-window';\nimport { frenchVersionHistoryLabels } from '../version-history/version-history';\n\nexport const frenchViewerLabels: ViewerLabels = {\n share: 'Partager',\n moreActions: 'Plus d’actions',\n reload: 'Recharger',\n copy: 'Copier',\n download: 'Télécharger',\n openInNewTab: 'Ouvrir dans un nouvel onglet',\n enterFullscreen: 'Plein écran',\n exitFullscreen: 'Quitter le plein écran',\n close: 'Fermer',\n readOnly: 'Lecture seule',\n refreshFailed: 'Échec de l’actualisation',\n shareFailed: 'Partage impossible',\n linkCopied: 'Lien copié',\n shared: 'Partagé',\n copied: 'Copié',\n copyFailed: 'Copie impossible',\n actionFailed: 'L’action a échoué',\n manualCopy: link => `Ce navigateur a bloqué la copie automatique. Copiez ${link ? 'le lien' : 'le texte'} ci-dessous.`,\n done: 'Terminé',\n ...frenchVersionHistoryLabels,\n versionViewOnly: 'Ancienne version en lecture seule. La restauration n’est pas disponible dans cette vue.',\n latest: 'Dernière version',\n loading: 'Chargement…',\n saved: 'Enregistré',\n unsaved: 'Non enregistré',\n saving: 'Enregistrement',\n saveUnconfirmed: 'Enregistrement non confirmé',\n changedElsewhere: 'Modifié ailleurs',\n closed: 'Fermé',\n save: 'Enregistrer',\n checkSave: 'Vérifier l’enregistrement',\n abandon: 'Abandonner et actualiser, en gardant mon brouillon',\n discardLocal: 'Annuler mes modifications',\n view: 'Affichage',\n preview: 'Aperçu',\n source: 'Source',\n htmlPreview: 'Aperçu HTML',\n htmlSource: 'Source HTML',\n editorMode: 'Mode d’édition',\n rich: 'Mise en forme',\n richText: 'Texte mis en forme',\n markdownSource: 'Source Markdown',\n richUnavailable: reasons => `Texte mis en forme indisponible : ce document contient ${reasons}, qu’il ne pourrait pas conserver à l’identique`,\n document: 'Document',\n zoom: 'Zoom',\n zoomIn: 'Agrandir',\n zoomOut: 'Réduire',\n zoomLevel: percent => `Zoom ${percent}, ajuster à la vue`,\n fitToView: 'Ajuster à la vue',\n actualSize: 'Taille réelle',\n imageUnreadable: 'Ce fichier n’a pas pu être lu comme une image.',\n imageUnsupported: mediaType => `Ce type d’image (${mediaType || 'inconnu'}) ne peut pas être affiché ici. Vous pouvez toujours le télécharger.`,\n imageStage: name => `Image ${name}`,\n pdfUnsupported: 'Ce navigateur ne peut pas afficher les PDF directement.',\n pdfDocument: name => `Pages de ${name}`,\n pdfPage: (current, count) => `${current} / ${count}`,\n pdfHint: (name, canOpen) => `Téléchargez ${name} pour le lire dans une application PDF${canOpen ? ', ou ouvrez-le dans un nouvel onglet' : ''}.`,\n notSaved: reason => `Non enregistré : ${reason}`,\n saveFailedReason: 'l’enregistrement a échoué',\n changedElsewhereReason: 'modifié ailleurs',\n operationFailed: 'L’opération sur le document a échoué. Votre source est conservée.',\n};\n",
121
+ "content": "/* The viewers' words in French, the `locale=\"fr\"` base of `AgentWorkspace`'s viewers. Typed as `ViewerLabels`, so a key added in English must be added here too. */\nimport type { ViewerLabels } from './viewer-window';\nimport { frenchVersionHistoryLabels } from '../version-history/version-history';\n\nexport const frenchViewerLabels: ViewerLabels = {\n share: 'Partager',\n moreActions: 'Plus d’actions',\n reload: 'Recharger',\n copy: 'Copier',\n download: 'Télécharger',\n openInNewTab: 'Ouvrir dans un nouvel onglet',\n enterFullscreen: 'Plein écran',\n exitFullscreen: 'Quitter le plein écran',\n close: 'Fermer',\n readOnly: 'Lecture seule',\n refreshFailed: 'Échec de l’actualisation',\n shareFailed: 'Partage impossible',\n linkCopied: 'Lien copié',\n shared: 'Partagé',\n copied: 'Copié',\n copyFailed: 'Copie impossible',\n actionFailed: 'L’action a échoué',\n manualCopy: link => `Ce navigateur a bloqué la copie automatique. Copiez ${link ? 'le lien' : 'le texte'} ci-dessous.`,\n done: 'Terminé',\n ...frenchVersionHistoryLabels,\n versionViewOnly: 'Ancienne version en lecture seule. La restauration n’est pas disponible dans cette vue.',\n latest: 'Dernière version',\n loading: 'Chargement…',\n saved: 'Enregistré',\n unsaved: 'Non enregistré',\n saving: 'Enregistrement',\n saveUnconfirmed: 'Enregistrement non confirmé',\n changedElsewhere: 'Modifié ailleurs',\n closed: 'Fermé',\n save: 'Enregistrer',\n checkSave: 'Vérifier l’enregistrement',\n abandon: 'Abandonner et actualiser, en gardant mon brouillon',\n discardLocal: 'Annuler mes modifications',\n view: 'Affichage',\n preview: 'Aperçu',\n source: 'Source',\n htmlPreview: 'Aperçu HTML',\n htmlSource: 'Source HTML',\n editorMode: 'Mode d’édition',\n rich: 'Mise en forme',\n richText: 'Texte mis en forme',\n markdownSource: 'Source Markdown',\n richUnavailable: reasons => `Texte mis en forme indisponible : ce document contient ${reasons}, qu’il ne pourrait pas conserver à l’identique`,\n document: 'Document',\n zoom: 'Zoom',\n zoomIn: 'Agrandir',\n zoomOut: 'Réduire',\n zoomLevel: percent => `Zoom ${percent}, ajuster à la vue`,\n fitToView: 'Ajuster à la vue',\n actualSize: 'Taille réelle',\n noPreview: 'Aucun aperçu disponible. Vous pouvez toujours télécharger ce fichier.',\n imageUnreadable: 'Ce fichier n’a pas pu être lu comme une image.',\n imageUnsupported: mediaType => `Ce type d’image (${mediaType || 'inconnu'}) ne peut pas être affiché ici. Vous pouvez toujours le télécharger.`,\n imageStage: name => `Image ${name}`,\n pdfUnsupported: 'Ce navigateur ne peut pas afficher les PDF directement.',\n pdfDocument: name => `Pages de ${name}`,\n pdfPage: (current, count) => `${current} / ${count}`,\n pdfHint: (name, canOpen) => `Téléchargez ${name} pour le lire dans une application PDF${canOpen ? ', ou ouvrez-le dans un nouvel onglet' : ''}.`,\n notSaved: reason => `Non enregistré : ${reason}`,\n saveFailedReason: 'l’enregistrement a échoué',\n changedElsewhereReason: 'modifié ailleurs',\n operationFailed: 'L’opération sur le document a échoué. Votre source est conservée.',\n};\n",
116
122
  "type": "registry:lib",
117
123
  "target": "components/viewers/viewer-labels-fr.ts"
118
124
  }
package/r/windows.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "windows",
4
+ "title": "Floating windows and launcher",
5
+ "description": "Host utility windows with shared pointer-capture drag, resize, focus stacking, minimize/close, remembered rectangles and a keyboard launcher; full-screen Drawer below 641px. No agent or content persistence.",
6
+ "dependencies": [
7
+ "react@^19.0.0",
8
+ "react-dom@^19.0.0",
9
+ "lucide-react@^1.51.0"
10
+ ],
11
+ "registryDependencies": [
12
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.5/r/utils.json",
13
+ "button",
14
+ "card",
15
+ "badge",
16
+ "drawer",
17
+ "dropdown-menu"
18
+ ],
19
+ "files": [
20
+ {
21
+ "path": "registry/windows/windows.tsx",
22
+ "content": "'use client';\n\n// Host concern: Pi has no window layout. These surfaces borrow their content and remember presentation only.\nimport { createPortal } from 'react-dom';\nimport { createContext, useCallback, useContext, useLayoutEffect, useMemo, useRef, useState, useId } from 'react';\nimport type { KeyboardEvent, PointerEvent, ReactNode, RefObject } from 'react';\nimport { GripIcon, MinusIcon, PanelsTopLeftIcon, XIcon } from 'lucide-react';\nimport { Button } from '@/components/ui/button';\nimport { Card, CardAction, CardContent, CardHeader, CardTitle } from '@/components/ui/card';\nimport { Badge } from '@/components/ui/badge';\nimport { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from '@/components/ui/drawer';\nimport { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';\nimport { useDraggable } from '../utils/use-draggable';\nexport { useDraggable } from '../utils/use-draggable';\nexport type { DragPosition } from '../utils/use-draggable';\n\nexport interface WindowRect { readonly x: number; readonly y: number; readonly width: number; readonly height: number }\nexport type WindowStatus = 'closed' | 'open' | 'minimized';\nexport interface WindowApi {\n readonly id: string;\n readonly status: WindowStatus;\n readonly rect: WindowRect;\n readonly open: () => void;\n readonly close: () => void;\n readonly minimize: () => void;\n}\ninterface WindowRecord { readonly rect: WindowRect; readonly status: WindowStatus }\ninterface WindowState { readonly records: Readonly<Record<string, WindowRecord>>; readonly order: readonly string[]; readonly focus: string | null; readonly request: number }\ninterface WindowsContext {\n readonly state: WindowState;\n readonly phone: boolean;\n readonly host: HTMLDivElement | null;\n readonly launcher: RefObject<HTMLButtonElement | null>;\n readonly register: (id: string, rect: WindowRect, open: boolean) => () => void;\n readonly change: (id: string, status: WindowStatus) => void;\n readonly raise: (id: string) => void;\n readonly resize: (id: string, rect: WindowRect) => void;\n}\nconst Windows = createContext<WindowsContext | null>(null);\nconst DEFAULT_RECT: WindowRect = { x: 48, y: 64, width: 400, height: 320 };\nconst MIN_WIDTH = 240, MIN_HEIGHT = 160;\nconst windowRecord = (records: WindowState['records'], id: string): WindowRecord | undefined => Object.hasOwn(records, id) ? records[id] : undefined;\nconst useWindows = () => { const value = useContext(Windows); if (!value) throw new Error('Windows need a WindowLayer'); return value; };\nconst storage = (persist: 'session' | 'local') => { try { return persist === 'local' ? window.localStorage : window.sessionStorage; } catch { return undefined; } };\nconst clamp = (rect: WindowRect): WindowRect => {\n const width = Math.min(Math.max(rect.width, MIN_WIDTH), window.innerWidth), height = Math.min(Math.max(rect.height, MIN_HEIGHT), window.innerHeight);\n return { width, height, x: Math.min(Math.max(rect.x, 0), Math.max(0, window.innerWidth - width)), y: Math.min(Math.max(rect.y, 0), Math.max(0, window.innerHeight - height)) };\n};\nconst validRect = (rect: unknown): rect is WindowRect => !!rect && typeof rect === 'object' && ['x', 'y', 'width', 'height'].every(key => typeof (rect as Record<string, unknown>)[key] === 'number' && Number.isFinite((rect as Record<string, number>)[key])) && (rect as WindowRect).width > 0 && (rect as WindowRect).height > 0;\n\n/** One viewport window stack. Like DockLayout, persistence is tab-local unless `persist=\"local\"` is selected. */\nexport function WindowLayer({ children, storageKey = 'boring.windows', persist = 'session' }: { readonly children: ReactNode; readonly storageKey?: string; readonly persist?: 'session' | 'local' }) {\n const [state, setState] = useState<WindowState>({ records: {}, order: [], focus: null, request: 0 });\n const current = useRef(state), mounted = useRef(new Set<string>()), launcher = useRef<HTMLButtonElement>(null);\n const [phone, setPhone] = useState(false);\n const [host, setHost] = useState<HTMLDivElement | null>(null);\n const commit = useCallback((next: WindowState) => { current.current = next; setState(next); }, []);\n const save = useCallback((id: string, record: WindowRecord) => { try { storage(persist)?.setItem(`${storageKey}.${id}`, JSON.stringify(record)); } catch { /* the layout is a convenience */ } }, [persist, storageKey]);\n const register = useCallback((id: string, rect: WindowRect, open: boolean) => {\n if (mounted.current.has(id)) throw new Error(`Duplicate floating window id: ${id}`);\n mounted.current.add(id);\n let record = windowRecord(current.current.records, id) ?? { rect, status: open ? 'open' : 'closed' } as WindowRecord;\n try {\n const saved = JSON.parse(storage(persist)?.getItem(`${storageKey}.${id}`) ?? 'null');\n if (saved && validRect(saved.rect) && ['closed', 'open', 'minimized'].includes(saved.status)) record = saved;\n } catch { /* malformed or unavailable storage leaves the default */ }\n record = { ...record, rect: window.innerWidth < 641 ? record.rect : clamp(record.rect) };\n const previous = current.current, order = previous.order.filter(key => key !== id);\n if (record.status === 'open') order.push(id);\n commit({ ...previous, records: { ...previous.records, [id]: record }, order, focus: record.status === 'open' ? id : previous.focus, request: previous.request + (record.status === 'open' ? 1 : 0) });\n return () => {\n mounted.current.delete(id);\n const previous = current.current, { [id]: _removed, ...records } = previous.records;\n commit({ ...previous, records, order: previous.order.filter(key => key !== id) });\n };\n }, [commit, persist, storageKey]);\n const change = useCallback((id: string, status: WindowStatus) => {\n const previous = current.current, record = windowRecord(previous.records, id); if (!record || !mounted.current.has(id)) return;\n const order = previous.order.filter(key => key !== id); if (status === 'open') order.push(id);\n const next = { ...record, status }; save(id, next);\n commit({ records: { ...previous.records, [id]: next }, order, focus: status === 'open' ? id : order.at(-1) ?? null, request: previous.request + 1 });\n }, [commit, save]);\n const raise = useCallback((id: string) => {\n const previous = current.current;\n if (windowRecord(previous.records, id)?.status !== 'open' || previous.order.at(-1) === id) return;\n commit({ ...previous, order: [...previous.order.filter(key => key !== id), id] });\n }, [commit]);\n const resize = useCallback((id: string, rect: WindowRect) => {\n const previous = current.current, record = windowRecord(previous.records, id); if (!record || !validRect(rect)) return;\n const next = { ...record, rect: clamp(rect) }; save(id, next); commit({ ...previous, records: { ...previous.records, [id]: next } });\n }, [commit, save]);\n useLayoutEffect(() => {\n const measure = () => {\n const narrow = window.innerWidth < 641; setPhone(narrow);\n if (!narrow) for (const [id, record] of Object.entries(current.current.records)) resize(id, record.rect);\n };\n measure(); window.addEventListener('resize', measure); return () => window.removeEventListener('resize', measure);\n }, [resize]);\n useLayoutEffect(() => { if (state.request && state.focus === null) launcher.current?.focus({ preventScroll: true }); }, [state.focus, state.request]);\n const value = useMemo(() => ({ state, phone, host, launcher, register, change, raise, resize }), [state, phone, host, register, change, raise, resize]);\n return <Windows.Provider value={value}>{children}<div ref={setHost} data-boring=\"window-layer\" className=\"pointer-events-none fixed inset-0 z-40 isolate\" /></Windows.Provider>;\n}\n\n/** Reach a window from controls anywhere inside its layer (undefined until the FloatingWindow registers). */\nexport function useWindow(id: string): WindowApi | undefined {\n const { state, change } = useWindows(), record = windowRecord(state.records, id);\n return useMemo(() => record ? { id, ...record, open: () => change(id, 'open'), close: () => change(id, 'closed'), minimize: () => change(id, 'minimized') } : undefined, [id, record, change]);\n}\n\n/** Non-modal on desktop; the front window uses the host's full-screen Drawer below 641px. */\nexport function FloatingWindow({ id, title, children, defaultRect = DEFAULT_RECT, defaultOpen = false }: {\n readonly id: string; readonly title: string; readonly children: ReactNode; readonly defaultRect?: WindowRect; readonly defaultOpen?: boolean;\n}) {\n const { state, phone, host, launcher, register, change, raise, resize } = useWindows();\n const initial = useRef({ defaultRect, defaultOpen });\n useLayoutEffect(() => register(id, initial.current.defaultRect, initial.current.defaultOpen), [id, register]);\n const record = windowRecord(state.records, id), rect = record?.rect ?? defaultRect, visible = record?.status === 'open';\n const box = useRef<HTMLDivElement>(null), titleId = useId();\n const front = state.order.at(-1) === id;\n const position = useMemo(() => ({ left: rect.x, top: rect.y }), [rect.x, rect.y]);\n const move = useCallback((next: { left: number; top: number }) => resize(id, { ...rect, x: next.left, y: next.top }), [id, rect, resize]);\n const drag = useDraggable({ box, position, onPositionChange: move, enabled: !phone && visible });\n const focus = () => box.current?.querySelector<HTMLElement>('button, input, textarea, select, a[href], [tabindex=\"0\"]') ?? box.current;\n useLayoutEffect(() => { if (visible && state.focus === id) focus()?.focus({ preventScroll: true }); }, [visible, state.focus, state.request, id, phone]);\n const sizing = useRef<{ x: number; y: number; rect: WindowRect } | null>(null);\n const startResize = (event: PointerEvent<HTMLElement>) => {\n if (event.button !== 0) return;\n event.preventDefault(); event.currentTarget.focus(); event.currentTarget.setPointerCapture?.(event.pointerId);\n sizing.current = { x: event.clientX, y: event.clientY, rect };\n };\n const resizeKey = (event: KeyboardEvent<HTMLElement>) => {\n const step = event.shiftKey ? 96 : 24;\n const width = rect.width + (event.key === 'ArrowRight' ? step : event.key === 'ArrowLeft' ? -step : 0), height = rect.height + (event.key === 'ArrowDown' ? step : event.key === 'ArrowUp' ? -step : 0);\n if (width === rect.width && height === rect.height) return;\n event.preventDefault(); resize(id, { ...rect, width, height });\n };\n const controls = <>\n <Button variant=\"ghost\" size=\"icon\" aria-label={`Minimize ${title}`} onClick={() => change(id, 'minimized')}><MinusIcon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>\n <Button variant=\"ghost\" size=\"icon\" aria-label={`Close ${title}`} onClick={() => change(id, 'closed')}><XIcon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>\n </>;\n if (phone) return <Drawer open={visible && front} onOpenChange={open => { if (!open && visible && front) change(id, 'closed'); }}>\n <DrawerContent ref={box} data-window={id} data-placement=\"fullscreen\" aria-describedby={undefined} initialFocus={focus} finalFocus={() => state.focus === null ? launcher.current : false}\n style={{ inset: 0, width: '100%', height: '100dvh', maxHeight: '100dvh', transform: 'none' }}>\n <DrawerHeader><div className=\"flex items-center gap-2\"><DrawerTitle className=\"flex-1\">{title}</DrawerTitle>{controls}</div></DrawerHeader>\n <div data-base-ui-swipe-ignore=\"\" className=\"flex min-h-0 flex-1 flex-col overflow-auto p-4\">{children}</div>\n </DrawerContent>\n </Drawer>;\n return host ? createPortal(<Card className=\"pointer-events-auto\" ref={box} role=\"dialog\" aria-modal={false} aria-labelledby={titleId} tabIndex={-1} hidden={!visible} inert={!visible} data-window={id} data-placement=\"floating\"\n style={{ position: 'fixed', left: rect.x, top: rect.y, width: rect.width, height: rect.height, zIndex: 1 + state.order.indexOf(id), display: visible ? undefined : 'none' }}\n onPointerDownCapture={() => raise(id)} onFocusCapture={() => raise(id)} onKeyDown={event => { if (event.key === 'Escape' && !event.defaultPrevented) { event.stopPropagation(); change(id, 'closed'); } }}>\n <CardHeader data-window-drag=\"\" {...drag} className=\"cursor-move touch-none select-none\">\n <CardTitle id={titleId}>{title}</CardTitle><CardAction className=\"flex items-center gap-1\">{controls}</CardAction>\n </CardHeader>\n <CardContent className=\"min-h-0 flex-1 overflow-auto\">{children}</CardContent>\n <Button variant=\"ghost\" size=\"icon-sm\" data-window-resize=\"\" aria-label={`Resize ${title}`} className=\"absolute bottom-0 right-0 cursor-se-resize touch-none\"\n onPointerDown={startResize} onPointerMove={event => { const start = sizing.current; if (start) resize(id, { ...start.rect, width: start.rect.width + event.clientX - start.x, height: start.rect.height + event.clientY - start.y }); }}\n onPointerUp={() => { sizing.current = null; }} onPointerCancel={() => { sizing.current = null; }} onKeyDown={resizeKey}>\n <GripIcon data-icon=\"inline-start\" aria-hidden=\"true\" />\n </Button>\n </Card>, host) : null;\n}\n\n/** The host supplies the available windows; the native menu owns arrow keys, typeahead and Escape. */\nexport function WindowLauncher({ windows, label = 'Windows' }: { readonly windows: readonly { readonly id: string; readonly title: string }[]; readonly label?: string }) {\n const { state, change, launcher } = useWindows(), selected = useRef(false);\n return <DropdownMenu onOpenChange={open => { if (open) selected.current = false; }}>\n <DropdownMenuTrigger render={<Button ref={launcher} variant=\"outline\" data-window-launcher=\"\" />}><PanelsTopLeftIcon data-icon=\"inline-start\" aria-hidden=\"true\" />{label}</DropdownMenuTrigger>\n <DropdownMenuContent finalFocus={() => selected.current ? false : true}>\n <DropdownMenuGroup>{windows.map(item => {\n const record = windowRecord(state.records, item.id);\n return <DropdownMenuItem key={item.id} disabled={!record} data-window-open={record && record.status !== 'closed' ? 'true' : undefined}\n onClick={() => { selected.current = true; change(item.id, 'open'); }}>\n <span className=\"flex-1\">{item.title}</span>{record && record.status !== 'closed' && <Badge variant=\"secondary\">{record.status === 'minimized' ? 'Minimized' : 'Open'}</Badge>}\n </DropdownMenuItem>;\n })}</DropdownMenuGroup>\n </DropdownMenuContent>\n </DropdownMenu>;\n}\n",
23
+ "type": "registry:component",
24
+ "target": "components/windows/windows.tsx"
25
+ }
26
+ ],
27
+ "type": "registry:block"
28
+ }