@hachej/boring-ui-kit 0.4.25 → 0.4.27
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +40 -3
- package/dist/chat-artifacts.d.ts +157 -0
- package/dist/chat-artifacts.js +274 -0
- package/dist/chat-dictation.d.ts +27 -0
- package/dist/chat-dictation.js +71 -0
- package/dist/chat-react-controller.d.ts +25 -0
- package/dist/chat-react-controller.js +72 -0
- package/dist/chat-react-conversations.d.ts +40 -0
- package/dist/chat-react-conversations.js +68 -0
- package/dist/chat-react-draft.d.ts +11 -0
- package/dist/chat-react-draft.js +91 -0
- package/dist/chat-react-remote.d.ts +33 -0
- package/dist/chat-react-remote.js +72 -0
- package/dist/chat-react-session.d.ts +170 -0
- package/dist/chat-react-session.js +367 -0
- package/dist/chat-react-transcript.d.ts +39 -0
- package/dist/chat-react-transcript.js +123 -0
- package/dist/chat-react.d.ts +62 -0
- package/dist/chat-react.js +95 -0
- package/dist/chat-text.d.ts +131 -0
- package/dist/chat-text.js +220 -0
- package/dist/chat-view.d.ts +2 -17
- package/dist/chat-view.js +1 -0
- package/dist/chat-voice-message.d.ts +65 -0
- package/dist/chat-voice-message.js +235 -0
- package/dist/chat-voice.d.ts +7 -0
- package/dist/chat-voice.js +4 -0
- package/dist/experience-catalog.d.ts +2 -2
- package/dist/experience-region-tree.d.ts +1 -1
- package/dist/markdown-editor-labels.d.ts +83 -0
- package/dist/markdown-editor-labels.js +75 -0
- package/dist/markdown-editor.d.ts +7 -1
- package/dist/markdown-editor.js +28 -18
- package/dist/markdown-extensions.d.ts +4 -2
- package/dist/markdown-extensions.js +2 -2
- package/dist/markdown-preserved.d.ts +5 -1
- package/dist/markdown-preserved.js +15 -4
- package/dist/markdown-proposal-diff.d.ts +3 -1
- package/dist/markdown-proposal-diff.js +6 -5
- package/dist/run-notifications.d.ts +42 -0
- package/dist/run-notifications.js +67 -0
- package/dist/shared-chat/index.d.ts +1 -1
- package/dist/shared-chat/index.js +5 -4
- package/dist/text-draft-controls.d.ts +5 -1
- package/dist/text-draft-controls.js +6 -5
- package/dist/text-draft-labels.d.ts +26 -0
- package/dist/text-draft-labels.js +47 -0
- package/dist/voice-attempt.d.ts +33 -0
- package/dist/voice-attempt.js +122 -0
- package/dist/voice-capture.d.ts +12 -0
- package/dist/voice-capture.js +17 -2
- package/package.json +14 -2
- package/r/agent-target.json +5 -9
- package/r/app-shell.json +19 -28
- package/r/board-viewer.json +8 -11
- package/r/button.json +2 -9
- package/r/diagram-viewer.json +0 -5
- package/r/dock.json +2 -2
- package/r/documents.json +13 -18
- package/r/feedback-preview.json +1 -7
- package/r/feedback.json +12 -19
- package/r/file-tree.json +4 -14
- package/r/highlights.json +4 -9
- package/r/html-viewer.json +3 -3
- package/r/looks-viewer.json +3 -6
- package/r/maquette-viewer.json +2 -20
- package/r/markdown-editor.json +4 -7
- package/r/mockup-viewer.json +2 -5
- package/r/pi-ambient.json +11 -19
- package/r/pi-app-kinds.json +10 -10
- package/r/pi-app.json +17 -27
- package/r/pi-artifacts.json +3 -4
- package/r/pi-chat-approval.json +2 -5
- package/r/pi-chat-attachments.json +2 -4
- package/r/pi-chat-markdown.json +3 -5
- package/r/pi-chat-mentions.json +2 -3
- package/r/pi-chat-pickers.json +2 -4
- package/r/pi-chat-plugins.json +11 -11
- package/r/pi-chat-question.json +1 -7
- package/r/pi-chat-slash.json +3 -6
- package/r/pi-chat-subagent.json +3 -8
- package/r/pi-chat-voice.json +7 -10
- package/r/pi-chat.json +25 -53
- package/r/pi-workspace.json +4 -7
- package/r/placed-agent.json +4 -5
- package/r/provider-setup.json +2 -8
- package/r/record-viewer.json +0 -9
- package/r/registry.json +182 -403
- package/r/requests-board.json +2 -10
- package/r/shared-chat.json +10 -24
- package/r/task-list-viewer.json +3 -9
- package/r/utils.json +14 -2
- package/r/version-history.json +3 -5
- package/r/viewer-code.json +2 -3
- package/r/viewer-frame.json +9 -17
- package/r/viewer-html.json +4 -5
- package/r/viewer-image.json +3 -4
- package/r/viewer-markdown.json +6 -8
- package/r/viewer-pdf.json +5 -7
- package/r/viewers.json +6 -6
- package/r/windows.json +3 -8
package/r/requests-board.json
CHANGED
|
@@ -7,20 +7,12 @@
|
|
|
7
7
|
"react@^19.0.0"
|
|
8
8
|
],
|
|
9
9
|
"registryDependencies": [
|
|
10
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
11
|
-
"badge",
|
|
12
|
-
"button",
|
|
13
|
-
"input",
|
|
14
|
-
"toggle-group",
|
|
15
|
-
"item",
|
|
16
|
-
"empty",
|
|
17
|
-
"textarea",
|
|
18
|
-
"card"
|
|
10
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.27/r/utils.json"
|
|
19
11
|
],
|
|
20
12
|
"files": [
|
|
21
13
|
{
|
|
22
14
|
"path": "registry/requests-board/requests-board.tsx",
|
|
23
|
-
"content": "'use client';\n\nimport { useEffect, useRef, useState } from 'react';\nimport type { KeyboardEvent } from 'react';\nimport { Badge } from '@/components/ui/badge';\nimport { Button } from '@/components/ui/button';\nimport { Input } from '@/components/ui/input';\nimport { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';\nimport { Item, ItemContent, ItemDescription, ItemGroup, ItemTitle } from '@/components/ui/item';\nimport { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from '@/components/ui/empty';\nimport { Textarea } from '@/components/ui/textarea';\nimport { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';\nimport { cn } from '../utils/utils';\nimport { compactRows, filterViews, requestCounts, requestFilters } from './requests';\nimport type { RequestAction, RequestFilter, RequestView } from './requests';\nimport { requestsLabelsFor } from './requests-labels';\nimport type { RequestsLabels, RequestsLocale } from './requests-labels';\n\nexport interface RequestsBoardProps {\n readonly views: readonly RequestView[];\n readonly mode: 'compact' | 'full';\n readonly repos?: readonly string[];\n readonly labels?: Partial<RequestsLabels>;\n readonly locale?: RequestsLocale;\n readonly changeToken?: string | number;\n /** Presentation only: the host must authorize every decision again. */\n readonly canApproveRelease?: boolean;\n readonly onOpenAll: () => void;\n readonly onOpen: (number: string) => void;\n readonly onAnswer?: (number: string, answer: string) => void;\n readonly onDecide?: (number: string, decision: 'approve' | 'later') => void;\n readonly onTry?: (number: string, action: RequestAction) => void;\n}\n\n/** Host presentation concern: no Pi execution, network, polling or subscription. Only local navigation and drafts live here. */\nexport function RequestsBoard(props: RequestsBoardProps) {\n const { views, mode, changeToken, onOpen, onOpenAll } = props;\n const labels = requestsLabelsFor(props.locale, props.labels);\n const [filter, setFilter] = useState<RequestFilter>('active');\n const [query, setQuery] = useState('');\n const [repo, setRepo] = useState<string>();\n const [selected, setSelected] = useState<string>();\n const [announcement, setAnnouncement] = useState('');\n const observed = useRef({ token: changeToken, views: views.map(view => ({ number: view.number, state: view.state, line: view.line, since: view.since })) });\n const rows = useRef(new Map<string, HTMLButtonElement>());\n const heading = useRef<HTMLHeadingElement>(null);\n const returning = useRef<string | undefined>(undefined);\n const detail = views.find(view => view.number === selected);\n const shown = mode === 'compact' ? compactRows(views) : filterViews(views, filter, query, repo);\n const counts = requestCounts(views, query, repo);\n useEffect(() => {\n if (Object.is(observed.current.token, changeToken)) return;\n const previous = new Map(observed.current.views.map(view => [view.number, view]));\n const moved = views.filter(view => {\n const before = previous.get(view.number);\n return !before || before.state !== view.state || before.line !== view.line || before.since !== view.since;\n });\n setAnnouncement(moved.map(view => labels.changed(view.number, labels.states[view.state])).join('; '));\n observed.current = { token: changeToken, views: views.map(view => ({ number: view.number, state: view.state, line: view.line, since: view.since })) };\n }, [changeToken, views, labels]);\n useEffect(() => {\n if (detail) heading.current?.focus();\n else if (returning.current !== undefined) {\n (rows.current.get(returning.current) ?? rows.current.values().next().value)?.focus();\n returning.current = undefined;\n }\n }, [detail?.number]);\n const open = (number: string) => { if (mode === 'full') setSelected(number); onOpen(number); };\n const back = () => { returning.current = selected; setSelected(undefined); };\n const navigate = (event: KeyboardEvent<HTMLButtonElement>, index: number) => {\n const next = event.key === 'ArrowDown' ? Math.min(shown.length - 1, index + 1) : event.key === 'ArrowUp' ? Math.max(0, index - 1) : event.key === 'Home' ? 0 : event.key === 'End' ? shown.length - 1 : undefined;\n if (next === undefined) return;\n event.preventDefault(); rows.current.get(shown[next]!.number)?.focus();\n };\n const list = <ItemGroup aria-label={labels.title} data-testid=\"requests-list\">\n {shown.map((view, index) => <Item key={view.number} role=\"listitem\" variant={selected === view.number ? 'muted' : 'default'}>\n <ItemContent className=\"min-w-0\">\n <Button ref={node => { if (node) rows.current.set(view.number, node); else rows.current.delete(view.number); }} variant=\"ghost\" className=\"w-full justify-start whitespace-normal text-start\"\n data-request={view.number} aria-current={selected === view.number ? 'true' : undefined} onKeyDown={event => navigate(event, index)} onClick={() => open(view.number)}>\n <span>{view.number} · {view.title}</span>\n </Button>\n </ItemContent>\n <Badge variant=\"secondary\">{labels.states[view.state]}</Badge>\n </Item>)}\n {shown.length === 0 && <Empty><EmptyHeader><EmptyTitle>{labels.empty}</EmptyTitle><EmptyDescription>{labels.emptyDescription}</EmptyDescription></EmptyHeader></Empty>}\n </ItemGroup>;\n return <section data-boring=\"requests-board\" data-mode={mode} className=\"flex h-full min-h-0 min-w-0 flex-col\" aria-label={labels.title}>\n <div role=\"status\" aria-live=\"polite\" aria-atomic=\"true\" className=\"sr-only\" data-testid=\"requests-updates\">{announcement}</div>\n {mode === 'compact' ? <>{list}<Button variant=\"link\" onClick={onOpenAll}>{labels.openAll(views.length)}</Button></> : <>\n <div className={cn('min-w-0 flex-col gap-3 p-4', detail ? 'hidden md:flex' : 'flex')}>\n <div className=\"overflow-x-auto\"><ToggleGroup value={[filter]} onValueChange={value => { if (value[0]) setFilter(value[0] as RequestFilter); }} aria-label={labels.title} variant=\"outline\">\n {requestFilters.map(value => <ToggleGroupItem key={value} value={value} data-filter={value}>{labels.filters[value]} · {counts[value]}</ToggleGroupItem>)}\n </ToggleGroup></div>\n <Input aria-label={labels.search} placeholder={labels.search} value={query} onChange={event => setQuery(event.currentTarget.value)} />\n {!!props.repos?.length && <div className=\"overflow-x-auto\"><ToggleGroup variant=\"outline\" aria-label={labels.repositories} value={[repo === undefined ? 'all' : `repo:${repo}`]} onValueChange={value => setRepo(value[0]?.startsWith('repo:') ? value[0].slice(5) : undefined)}>\n <ToggleGroupItem value=\"all\">{labels.allRepos}</ToggleGroupItem>\n {props.repos.map(value => <ToggleGroupItem value={`repo:${value}`} key={value}>{value}</ToggleGroupItem>)}\n </ToggleGroup></div>}\n </div>\n <div className=\"flex min-h-0 min-w-0 flex-1\">\n <div data-testid=\"requests-list-pane\" className={cn('min-h-0 w-full shrink-0 overflow-y-auto overscroll-contain md:w-80', detail ? 'hidden md:block' : 'block')}>{list}</div>\n <div data-testid=\"requests-detail-pane\" className={cn('min-h-0 min-w-0 flex-1 overflow-y-auto overscroll-contain p-4', detail ? 'block' : 'hidden md:block')}>\n {detail ? <>\n <div className=\"mb-4 md:hidden\"><Button variant=\"ghost\" onClick={back}>{labels.back}</Button></div>\n <h2 ref={heading} tabIndex={-1} className=\"m-0 mb-4 break-words\">{detail.number} · {detail.title}</h2>\n <RequestDetail key={detail.number} view={detail} labels={labels} canApproveRelease={props.canApproveRelease ?? false} onOpen={open} onAnswer={props.onAnswer} onDecide={props.onDecide} onTry={props.onTry} />\n </> : <Empty><EmptyHeader><EmptyTitle>{labels.choose}</EmptyTitle></EmptyHeader></Empty>}\n </div>\n </div>\n </>}\n </section>;\n}\n\nfunction RequestDetail({ view, labels, canApproveRelease, onOpen, onAnswer, onDecide, onTry }: {\n readonly view: RequestView; readonly labels: RequestsLabels; readonly canApproveRelease: boolean;\n readonly onOpen: (number: string) => void;\n readonly onAnswer: RequestsBoardProps['onAnswer']; readonly onDecide: RequestsBoardProps['onDecide']; readonly onTry: RequestsBoardProps['onTry'];\n}) {\n const question = view.question && (view.question.kind !== 'release' || canApproveRelease) ? view.question : undefined;\n return <div className=\"flex min-w-0 flex-col gap-4\">\n <Item variant=\"outline\"><ItemContent className=\"min-w-0\"><ItemTitle><Badge variant=\"secondary\">{labels.states[view.state]}</Badge></ItemTitle>\n <ItemDescription>{view.line}</ItemDescription><ItemDescription>{labels.since} <time dateTime={view.since}>{labels.formatDate(view.since)}</time>{view.waitsOn && ` · ${labels.waitsOn} ${view.waitsOn}`}</ItemDescription>\n {view.repo && !view.line.includes(view.repo) && <ItemDescription>{view.repo}</ItemDescription>}\n {view.master && <ItemDescription>{labels.master} · {view.master}</ItemDescription>}\n </ItemContent></Item>\n {view.action && <div><Button disabled={!onTry} onClick={() => { if (view.action) onTry?.(view.number, view.action); }}>{labels.tryVersion}</Button></div>}\n {question && <Question key={`${question.kind}:${question.text}`} view={view} labels={labels} onAnswer={onAnswer} onDecide={onDecide} />}\n <Card><CardHeader><CardTitle>{labels.history}</CardTitle></CardHeader><CardContent>\n <ItemGroup>{view.history.map((event, index) => <Item key={index} role=\"listitem\"><ItemContent><ItemTitle>{event.text}</ItemTitle><ItemDescription><time dateTime={event.at}>{labels.formatDate(event.at)}</time></ItemDescription></ItemContent></Item>)}</ItemGroup>\n </CardContent></Card>\n {view.links.length > 0 && <Card><CardHeader><CardTitle>{labels.related}</CardTitle></CardHeader><CardContent className=\"flex flex-wrap gap-2\">\n {view.links.map(link => <Button variant=\"link\" key={`${link.kind}:${link.number}`} onClick={() => onOpen(link.number)}>{labels.links[link.kind]} · {link.number}</Button>)}\n </CardContent></Card>}\n </div>;\n}\nfunction Question({ view, labels, onAnswer, onDecide }: { readonly view: RequestView; readonly labels: RequestsLabels; readonly onAnswer: RequestsBoardProps['onAnswer']; readonly onDecide: RequestsBoardProps['onDecide'] }) {\n const [text, setText] = useState('');\n return <Card data-testid=\"request-question\"><CardHeader><CardDescription>{labels.states.question}</CardDescription><CardTitle>{view.question!.text}</CardTitle></CardHeader>\n <CardContent className=\"flex flex-col gap-3\">\n {view.question!.kind === 'answer' ? <form className=\"flex flex-col gap-3\" onSubmit={event => { event.preventDefault(); if (text.trim()) onAnswer?.(view.number, text.trim()); }}>\n <Textarea aria-label={labels.yourAnswer} value={text} disabled={!onAnswer} onChange={event => setText(event.currentTarget.value)} />\n <div><Button type=\"submit\" disabled={!onAnswer || !text.trim()}>{labels.answer}</Button></div>\n </form> : <div><Button disabled={!onDecide} onClick={() => onDecide?.(view.number, 'approve')}>{labels.approve}</Button></div>}\n <div><Button variant=\"outline\" disabled={!onDecide} onClick={() => onDecide?.(view.number, 'later')}>{labels.later}</Button></div>\n </CardContent>\n </Card>;\n}\n",
|
|
15
|
+
"content": "'use client';\n\nimport { useEffect, useRef, useState } from 'react';\nimport type { KeyboardEvent } from 'react';\nimport { Badge } from '@/components/ui/badge';\nimport { Button } from '@/components/ui/button';\nimport { Input } from '@/components/ui/input';\nimport { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';\nimport { Item, ItemContent, ItemDescription, ItemGroup, ItemTitle } from '@/components/ui/item';\nimport { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from '@/components/ui/empty';\nimport { Textarea } from '@/components/ui/textarea';\nimport { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';\nimport { cn } from '../utils/utils';\nimport { compactRows, filterViews, requestCounts, requestFilters } from './requests';\nimport type { RequestAction, RequestFilter, RequestView } from './requests';\nimport { requestsLabelsFor } from './requests-labels';\nimport type { RequestsLabels, RequestsLocale } from './requests-labels';\n\nexport interface RequestsBoardProps {\n readonly views: readonly RequestView[];\n readonly mode: 'compact' | 'full';\n readonly repos?: readonly string[];\n readonly labels?: Partial<RequestsLabels>;\n readonly locale?: RequestsLocale;\n readonly changeToken?: string | number;\n /** Presentation only: the host must authorize every decision again. */\n readonly canApproveRelease?: boolean;\n readonly onOpenAll: () => void;\n readonly onOpen: (number: string) => void;\n readonly onAnswer?: (number: string, answer: string) => void;\n readonly onDecide?: (number: string, decision: 'approve' | 'later') => void;\n readonly onTry?: (number: string, action: RequestAction) => void;\n}\n\n/** Host presentation concern: no Pi execution, network, polling or subscription. Only local navigation and drafts live here. */\nexport function RequestsBoard(props: RequestsBoardProps) {\n const { views, mode, changeToken, onOpen, onOpenAll } = props;\n const labels = requestsLabelsFor(props.locale, props.labels);\n const [filter, setFilter] = useState<RequestFilter>('active');\n const [query, setQuery] = useState('');\n const [repo, setRepo] = useState<string>();\n const [selected, setSelected] = useState<string>();\n const [announcement, setAnnouncement] = useState('');\n const observed = useRef({ token: changeToken, views: views.map(view => ({ number: view.number, state: view.state, line: view.line, since: view.since })) });\n const rows = useRef(new Map<string, HTMLButtonElement>());\n const heading = useRef<HTMLHeadingElement>(null);\n const returning = useRef<string | undefined>(undefined);\n const detail = views.find(view => view.number === selected);\n const shown = mode === 'compact' ? compactRows(views) : filterViews(views, filter, query, repo);\n const counts = requestCounts(views, query, repo);\n useEffect(() => {\n if (Object.is(observed.current.token, changeToken)) return;\n const previous = new Map(observed.current.views.map(view => [view.number, view]));\n const moved = views.filter(view => {\n const before = previous.get(view.number);\n return !before || before.state !== view.state || before.line !== view.line || before.since !== view.since;\n });\n setAnnouncement(moved.map(view => labels.changed(view.number, labels.states[view.state])).join('; '));\n observed.current = { token: changeToken, views: views.map(view => ({ number: view.number, state: view.state, line: view.line, since: view.since })) };\n }, [changeToken, views, labels]);\n const detailNumber = detail?.number;\n useEffect(() => {\n if (detailNumber !== undefined) heading.current?.focus();\n else if (returning.current !== undefined) {\n (rows.current.get(returning.current) ?? rows.current.values().next().value)?.focus();\n returning.current = undefined;\n }\n }, [detailNumber]);\n const open = (number: string) => { if (mode === 'full') setSelected(number); onOpen(number); };\n const back = () => { returning.current = selected; setSelected(undefined); };\n const navigate = (event: KeyboardEvent<HTMLButtonElement>, index: number) => {\n const next = event.key === 'ArrowDown' ? Math.min(shown.length - 1, index + 1) : event.key === 'ArrowUp' ? Math.max(0, index - 1) : event.key === 'Home' ? 0 : event.key === 'End' ? shown.length - 1 : undefined;\n if (next === undefined) return;\n event.preventDefault(); rows.current.get(shown[next]!.number)?.focus();\n };\n const list = <ItemGroup aria-label={labels.title} data-testid=\"requests-list\">\n {shown.map((view, index) => <Item key={view.number} role=\"listitem\" variant={selected === view.number ? 'muted' : 'default'}>\n <ItemContent className=\"min-w-0\">\n <Button ref={node => { if (node) rows.current.set(view.number, node); else rows.current.delete(view.number); }} variant=\"ghost\" className=\"w-full justify-start whitespace-normal text-start\"\n data-request={view.number} aria-current={selected === view.number ? 'true' : undefined} onKeyDown={event => navigate(event, index)} onClick={() => open(view.number)}>\n <span>{view.number} · {view.title}</span>\n </Button>\n </ItemContent>\n <Badge variant=\"secondary\">{labels.states[view.state]}</Badge>\n </Item>)}\n {shown.length === 0 && <Empty><EmptyHeader><EmptyTitle>{labels.empty}</EmptyTitle><EmptyDescription>{labels.emptyDescription}</EmptyDescription></EmptyHeader></Empty>}\n </ItemGroup>;\n return <section data-boring=\"requests-board\" data-mode={mode} className=\"flex h-full min-h-0 min-w-0 flex-col\" aria-label={labels.title}>\n <div role=\"status\" aria-live=\"polite\" aria-atomic=\"true\" className=\"sr-only\" data-testid=\"requests-updates\">{announcement}</div>\n {mode === 'compact' ? <>{list}<Button variant=\"link\" onClick={onOpenAll}>{labels.openAll(views.length)}</Button></> : <>\n <div className={cn('min-w-0 flex-col gap-3 p-4', detail ? 'hidden md:flex' : 'flex')}>\n <div className=\"overflow-x-auto\"><ToggleGroup value={[filter]} onValueChange={value => { if (value[0]) setFilter(value[0] as RequestFilter); }} aria-label={labels.title} variant=\"outline\">\n {requestFilters.map(value => <ToggleGroupItem key={value} value={value} data-filter={value}>{labels.filters[value]} · {counts[value]}</ToggleGroupItem>)}\n </ToggleGroup></div>\n <Input aria-label={labels.search} placeholder={labels.search} value={query} onChange={event => setQuery(event.currentTarget.value)} />\n {!!props.repos?.length && <div className=\"overflow-x-auto\"><ToggleGroup variant=\"outline\" aria-label={labels.repositories} value={[repo === undefined ? 'all' : `repo:${repo}`]} onValueChange={value => setRepo(value[0]?.startsWith('repo:') ? value[0].slice(5) : undefined)}>\n <ToggleGroupItem value=\"all\">{labels.allRepos}</ToggleGroupItem>\n {props.repos.map(value => <ToggleGroupItem value={`repo:${value}`} key={value}>{value}</ToggleGroupItem>)}\n </ToggleGroup></div>}\n </div>\n <div className=\"flex min-h-0 min-w-0 flex-1\">\n <div data-testid=\"requests-list-pane\" className={cn('min-h-0 w-full shrink-0 overflow-y-auto overscroll-contain md:w-80', detail ? 'hidden md:block' : 'block')}>{list}</div>\n <div data-testid=\"requests-detail-pane\" className={cn('min-h-0 min-w-0 flex-1 overflow-y-auto overscroll-contain p-4', detail ? 'block' : 'hidden md:block')}>\n {detail ? <>\n <div className=\"mb-4 md:hidden\"><Button variant=\"ghost\" onClick={back}>{labels.back}</Button></div>\n <h2 ref={heading} tabIndex={-1} className=\"m-0 mb-4 break-words\">{detail.number} · {detail.title}</h2>\n <RequestDetail key={detail.number} view={detail} labels={labels} canApproveRelease={props.canApproveRelease ?? false} onOpen={open} onAnswer={props.onAnswer} onDecide={props.onDecide} onTry={props.onTry} />\n </> : <Empty><EmptyHeader><EmptyTitle>{labels.choose}</EmptyTitle></EmptyHeader></Empty>}\n </div>\n </div>\n </>}\n </section>;\n}\n\nfunction RequestDetail({ view, labels, canApproveRelease, onOpen, onAnswer, onDecide, onTry }: {\n readonly view: RequestView; readonly labels: RequestsLabels; readonly canApproveRelease: boolean;\n readonly onOpen: (number: string) => void;\n readonly onAnswer: RequestsBoardProps['onAnswer']; readonly onDecide: RequestsBoardProps['onDecide']; readonly onTry: RequestsBoardProps['onTry'];\n}) {\n const question = view.question && (view.question.kind !== 'release' || canApproveRelease) ? view.question : undefined;\n return <div className=\"flex min-w-0 flex-col gap-4\">\n <Item variant=\"outline\"><ItemContent className=\"min-w-0\"><ItemTitle><Badge variant=\"secondary\">{labels.states[view.state]}</Badge></ItemTitle>\n <ItemDescription>{view.line}</ItemDescription><ItemDescription>{labels.since} <time dateTime={view.since}>{labels.formatDate(view.since)}</time>{view.waitsOn && ` · ${labels.waitsOn} ${view.waitsOn}`}</ItemDescription>\n {view.repo && !view.line.includes(view.repo) && <ItemDescription>{view.repo}</ItemDescription>}\n {view.master && <ItemDescription>{labels.master} · {view.master}</ItemDescription>}\n </ItemContent></Item>\n {view.action && <div><Button disabled={!onTry} onClick={() => { if (view.action) onTry?.(view.number, view.action); }}>{labels.tryVersion}</Button></div>}\n {question && <Question key={`${question.kind}:${question.text}`} view={view} labels={labels} onAnswer={onAnswer} onDecide={onDecide} />}\n <Card><CardHeader><CardTitle>{labels.history}</CardTitle></CardHeader><CardContent>\n <ItemGroup>{view.history.map((event, index) => <Item key={index} role=\"listitem\"><ItemContent><ItemTitle>{event.text}</ItemTitle><ItemDescription><time dateTime={event.at}>{labels.formatDate(event.at)}</time></ItemDescription></ItemContent></Item>)}</ItemGroup>\n </CardContent></Card>\n {view.links.length > 0 && <Card><CardHeader><CardTitle>{labels.related}</CardTitle></CardHeader><CardContent className=\"flex flex-wrap gap-2\">\n {view.links.map(link => <Button variant=\"link\" key={`${link.kind}:${link.number}`} onClick={() => onOpen(link.number)}>{labels.links[link.kind]} · {link.number}</Button>)}\n </CardContent></Card>}\n </div>;\n}\nfunction Question({ view, labels, onAnswer, onDecide }: { readonly view: RequestView; readonly labels: RequestsLabels; readonly onAnswer: RequestsBoardProps['onAnswer']; readonly onDecide: RequestsBoardProps['onDecide'] }) {\n const [text, setText] = useState('');\n return <Card data-testid=\"request-question\"><CardHeader><CardDescription>{labels.states.question}</CardDescription><CardTitle>{view.question!.text}</CardTitle></CardHeader>\n <CardContent className=\"flex flex-col gap-3\">\n {view.question!.kind === 'answer' ? <form className=\"flex flex-col gap-3\" onSubmit={event => { event.preventDefault(); if (text.trim()) onAnswer?.(view.number, text.trim()); }}>\n <Textarea aria-label={labels.yourAnswer} value={text} disabled={!onAnswer} onChange={event => setText(event.currentTarget.value)} />\n <div><Button type=\"submit\" disabled={!onAnswer || !text.trim()}>{labels.answer}</Button></div>\n </form> : <div><Button disabled={!onDecide} onClick={() => onDecide?.(view.number, 'approve')}>{labels.approve}</Button></div>}\n <div><Button variant=\"outline\" disabled={!onDecide} onClick={() => onDecide?.(view.number, 'later')}>{labels.later}</Button></div>\n </CardContent>\n </Card>;\n}\n",
|
|
24
16
|
"type": "registry:component",
|
|
25
17
|
"target": "components/requests-board/requests-board.tsx"
|
|
26
18
|
},
|
package/r/shared-chat.json
CHANGED
|
@@ -4,30 +4,16 @@
|
|
|
4
4
|
"title": "Shared chat",
|
|
5
5
|
"description": "Optional multi-party host log presentation over PiChat's generic composition seams. No agent runtime or speaking policy.",
|
|
6
6
|
"dependencies": [
|
|
7
|
-
"@hachej/boring-ui-kit@^0.4.
|
|
7
|
+
"@hachej/boring-ui-kit@^0.4.27",
|
|
8
8
|
"react@^19.0.0",
|
|
9
9
|
"lucide-react@^1.51.0"
|
|
10
10
|
],
|
|
11
11
|
"registryDependencies": [
|
|
12
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
13
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
14
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
15
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
16
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
17
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.25/r/utils.json",
|
|
18
|
-
"avatar",
|
|
19
|
-
"badge",
|
|
20
|
-
"button",
|
|
21
|
-
"drawer",
|
|
22
|
-
"popover",
|
|
23
|
-
"separator",
|
|
24
|
-
"tooltip",
|
|
25
|
-
"trace",
|
|
26
|
-
"spinner",
|
|
27
|
-
"item",
|
|
28
|
-
"message",
|
|
29
|
-
"bubble",
|
|
30
|
-
"message-scroller"
|
|
12
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.27/r/pi-chat.json",
|
|
13
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.27/r/pi-chat-plugins.json",
|
|
14
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.27/r/pi-chat-markdown.json",
|
|
15
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.27/r/pi-chat-mentions.json",
|
|
16
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.27/r/utils.json"
|
|
31
17
|
],
|
|
32
18
|
"files": [
|
|
33
19
|
{
|
|
@@ -38,25 +24,25 @@
|
|
|
38
24
|
},
|
|
39
25
|
{
|
|
40
26
|
"path": "registry/shared-chat/use-shared-chat.tsx",
|
|
41
|
-
"content": "'use client';\nimport { useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';\nimport type { ReactNode } from 'react';\nimport { UserIcon } from 'lucide-react';\nimport { createSharedChat } from '@hachej/boring-ui-kit/shared-chat';\nimport type { ChannelSource, Conversation, SharedChatController } from '@hachej/boring-ui-kit/shared-chat';\nimport type { PiChatProps } from '../pi-chat/pi-chat';\nimport type { ConversationsConfig, MentionsConfig } from '../pi-chat/config';\nimport { MentionPicker } from '../pi-chat-mentions/mention-menu';\nimport { Badge } from '@/components/ui/badge';\nimport { Button } from '@/components/ui/button';\nimport { ItemContent, ItemDescription, ItemMedia, ItemTitle } from '@/components/ui/item';\nimport { namedMember } from './names';\nimport { MemberAvatar, SharedMembers, ReplyQuote, sharedQuote } from './messages';\nimport { SharedTimeline } from './timeline';\nimport type { TimelineApi } from './timeline';\nimport { SharedTextProvider } from './text';\nimport type { SharedTextOptions } from './text';\nimport { defaultSharedLabels } from './shared-labels';\nimport { frenchSharedLabels } from './shared-labels-fr';\n\nconst subscribeNothing = () => () => {};\n/** Explicit host composition through PiChat's generic transcript, composer and catalog seams. */\nexport function useSharedChatProps(controller: SharedChatController | undefined, options: SharedTextOptions = {}): Omit<PiChatProps, 'controller'> {\n const state = useSyncExternalStore(controller?.subscribe ?? subscribeNothing, () => controller?.getSnapshot(), () => controller?.getSnapshot());\n const api = useRef<TimelineApi | undefined>(undefined);\n const raw = state?.shared.members;\n // Plain names only: an id or a slug the host sent in a name's place never reaches the picker, a chip or a mention.\n const meId = state?.shared.me.id;\n const labels = { ...(options.locale === 'fr' ? frenchSharedLabels : defaultSharedLabels), ...options.labels };\n const nameOf = labels.sharedName;\n const members = useMemo(() => (raw ?? []).map(member => namedMember(member, nameOf)), [raw, nameOf]);\n const mentions = useMemo<MentionsConfig>(() => ({\n listLabel:
|
|
27
|
+
"content": "'use client';\nimport { useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';\nimport type { ReactNode } from 'react';\nimport { UserIcon } from 'lucide-react';\nimport { useControllerPerConversation } from '@hachej/boring-ui-kit/chat-react';\nimport { createSharedChat } from '@hachej/boring-ui-kit/shared-chat';\nimport type { ChannelSource, Conversation, SharedChatController } from '@hachej/boring-ui-kit/shared-chat';\nimport type { PiChatProps } from '../pi-chat/pi-chat';\nimport type { ConversationsConfig, MentionsConfig } from '../pi-chat/config';\nimport { MentionPicker } from '../pi-chat-mentions/mention-menu';\nimport { Badge } from '@/components/ui/badge';\nimport { Button } from '@/components/ui/button';\nimport { ItemContent, ItemDescription, ItemMedia, ItemTitle } from '@/components/ui/item';\nimport { namedMember } from './names';\nimport { MemberAvatar, SharedMembers, ReplyQuote, sharedQuote } from './messages';\nimport { SharedTimeline } from './timeline';\nimport type { TimelineApi } from './timeline';\nimport { SharedTextProvider } from './text';\nimport type { SharedTextOptions } from './text';\nimport { defaultSharedLabels } from './shared-labels';\nimport { frenchSharedLabels } from './shared-labels-fr';\n\nconst subscribeNothing = () => () => {};\n/** Explicit host composition through PiChat's generic transcript, composer and catalog seams. */\nexport function useSharedChatProps(controller: SharedChatController | undefined, options: SharedTextOptions = {}): Omit<PiChatProps, 'controller'> {\n const state = useSyncExternalStore(controller?.subscribe ?? subscribeNothing, () => controller?.getSnapshot(), () => controller?.getSnapshot());\n const api = useRef<TimelineApi | undefined>(undefined);\n const raw = state?.shared.members;\n // Plain names only: an id or a slug the host sent in a name's place never reaches the picker, a chip or a mention.\n const meId = state?.shared.me.id;\n const labels = { ...(options.locale === 'fr' ? frenchSharedLabels : defaultSharedLabels), ...options.labels };\n const nameOf = labels.sharedName, sharedMembers = labels.sharedMembers, sharedRole = labels.sharedRole;\n const members = useMemo(() => (raw ?? []).map(member => namedMember(member, nameOf)), [raw, nameOf]);\n const mentions = useMemo<MentionsConfig>(() => ({\n listLabel: sharedMembers, icon: <UserIcon aria-hidden=\"true\" />, Picker: MentionPicker,\n label: id => members.find(member => member.id === id)?.name ?? namedMember({ id, name: id, kind: 'person' }, nameOf).name,\n search: async query => members.filter(member => member.id !== meId && member.name.toLowerCase().includes(query.toLowerCase()))\n .sort((a, b) => Number(b.kind === 'agent') - Number(a.kind === 'agent')).map(member => ({ path: member.id, label: member.name })),\n render: id => <Badge variant=\"secondary\">@{(members.find(member => member.id === id) ?? namedMember({ id, name: id, kind: 'person' }, nameOf)).name}</Badge>,\n renderSent: id => {\n const member = members.find(member => member.id === id) ?? namedMember({ id, name: id, kind: 'person' }, nameOf);\n return <span data-testid=\"message-mention\" data-path={id} className=\"inline-flex items-baseline gap-1 align-baseline font-medium\"><span aria-hidden=\"true\"><MemberAvatar member={member} compact interactive={false} /></span><span data-testid=\"mention-name\">{member.name}</span></span>;\n },\n renderOption: item => {\n const member = members.find(member => member.id === item.path);\n const role = member ? sharedRole(member) : undefined;\n return <><ItemMedia variant=\"icon\">{member ? <MemberAvatar member={member} compact interactive={false} indicator={false} /> : <UserIcon aria-hidden=\"true\" />}</ItemMedia><ItemContent className=\"min-w-0\"><ItemTitle>{item.label}</ItemTitle>{role && <ItemDescription className=\"m-0 truncate\">{role}</ItemDescription>}</ItemContent></>;\n },\n }), [members, meId, nameOf, sharedMembers, sharedRole]);\n const wrap = (children: ReactNode) => <SharedTextProvider {...options}>{children}</SharedTextProvider>;\n return {\n locale: options.locale,\n timeline: { count: state?.shared.messages.length ?? 0,\n render: context => controller ? wrap(<SharedTimeline controller={controller} api={api} mentions={mentions} {...context} />) : null,\n afterSend: () => api.current?.reset() },\n controls: controller && options.presence !== false ? wrap(<SharedMembers controller={controller} />) : undefined,\n mentions,\n composerHeader: controller && state?.shared.replyTo ? wrap(<ReplyQuote targetId={state.shared.replyTo} quote={sharedQuote(state.shared, state.shared.replyTo, nameOf)} onReveal={id => api.current?.reveal(id)} onRemove={() => controller.setReplyTo(undefined)} />) : undefined,\n composerHasContent: Boolean(state?.shared.files.length),\n onPickFiles: files => { if (controller) controller.setFiles([...controller.getSnapshot().shared.files, ...files]); },\n composerChips: controller && wrap(state?.shared.files.map((file, index) => <Badge key={index} variant=\"secondary\">{file.name}<Button variant=\"ghost\" size=\"icon\" aria-label={`Remove ${file.name}`} onClick={() => controller.setFiles(controller.getSnapshot().shared.files.filter((_, at) => at !== index))}><span aria-hidden=\"true\">×</span></Button></Badge>)),\n renderConnectionError: error => error instanceof Error ? error.message : String(error),\n // WhatsApp's composer; a host spreading these props can still override either.\n composerLayout: 'pill', voiceReplacesSend: true,\n };\n}\n\n/** Owns only the adapters it creates; each adapter borrows the host source. `limit`: messages per history page read from the source (default 60). */\nexport function useSharedChat(source: ChannelSource, options: SharedTextOptions & { readonly limit?: number | undefined } = {}) {\n const [list, setList] = useState<{ source: ChannelSource; items: Conversation[] }>();\n const [selection, setSelection] = useState<{ source: ChannelSource; id: string }>();\n // A failure belongs to the source that raised it: another source starts clean.\n const [failure, setFailure] = useState<{ source: ChannelSource; message: string }>();\n const error = failure?.source === source ? failure.message : undefined;\n const items = list?.source === source ? list.items : [];\n const conversationId = selection?.source === source ? selection.id : items[0]?.id;\n useEffect(() => {\n let off = false;\n void source.conversations().then(items => { if (!off) setList({ source, items }); }, cause => { if (!off) setFailure({ source, message: String(cause) }); });\n return () => { off = true; };\n }, [source]);\n const principalId = source.me.id;\n const key = useMemo(() => conversationId === undefined ? undefined : { source, conversationId, principalId, limit: options.limit }, [source, conversationId, principalId, options.limit]);\n const selected = useControllerPerConversation({ key,\n create: () => createSharedChat(source, { conversationId: conversationId!, ...(options.limit === undefined ? {} : { limit: options.limit }) }),\n });\n const controller = selected.status === 'ready' ? selected.controller : undefined;\n const conversations: ConversationsConfig = { items, activeId: conversationId, loading: list?.source !== source, onSelect: id => setSelection({ source, id }) };\n const chat = useSharedChatProps(controller, options);\n return { controller, conversationId, conversations, error,\n chatProps: controller ? { ...chat, controller, conversations } : undefined,\n workspaceProps: { controller, conversationId, conversations, locale: options.locale, controls: chat.controls, chat },\n };\n}\n",
|
|
42
28
|
"type": "registry:component",
|
|
43
29
|
"target": "components/shared-chat/use-shared-chat.tsx"
|
|
44
30
|
},
|
|
45
31
|
{
|
|
46
32
|
"path": "registry/shared-chat/messages.tsx",
|
|
47
|
-
"content": "'use client';\n\nimport { useEffect, useRef, useState, useSyncExternalStore } from 'react';\nimport type { ComponentProps, ReactNode } from 'react';\nimport { cardActions, 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 { CheckIcon, CircleIcon, CopyIcon, HammerIcon, PinIcon, ReplyIcon, SparkleIcon, 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 { Drawer, DrawerContent, DrawerTitle } from '@/components/ui/drawer';\nimport { Separator } from '@/components/ui/separator';\nimport { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';\nimport { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';\nimport { copyText } from '../utils/utils';\nimport { BubbleTail, ClockSpacer, MessageClock as ChatMessageClock, clockRoom } from '../pi-chat/message';\nexport { BubbleTail, ClockSpacer, clockRoom };\nimport { useChatText, useSharedView } from './text';\nimport { agentKind, namedMember } from './names';\nimport { defaultSharedLabels } from './shared-labels';\nimport type { SharedLabels } from './shared-labels';\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;\ntype NameOf = SharedLabels['sharedName'];\nconst unknown = new WeakMap<NameOf, Map<string, Member>>();\nconst memberOf = (shared: SharedChatState, id: string, nameOf: NameOf): Member => {\n const found = shared.members.find(member => member.id === id);\n if (found) return namedMember(found, nameOf);\n let guests = unknown.get(nameOf);\n if (!guests) { guests = new Map(); unknown.set(nameOf, guests); }\n let guest = guests.get(id);\n if (!guest) { guest = namedMember({ id, name: id, kind: 'person' }, nameOf); guests.set(id, guest); }\n return guest;\n};\nexport function sharedQuote(shared: SharedChatState, id: string | undefined, nameOf: NameOf = defaultSharedLabels.sharedName) {\n const message = shared.messages.find(message => message.id === id);\n return message ? { message, author: memberOf(shared, message.author, nameOf) } : undefined;\n}\n\n/** Only https, `data:image/`, `blob:` and same-origin relative addresses load; anything else falls back to the initials. */\nexport function safeAvatarUrl(url: string | undefined): string | undefined {\n if (!url) return undefined;\n return /^(?:https:\\/\\/|data:image\\/|blob:)/i.test(url) || /^\\/(?![/\\\\])/.test(url) ? url : undefined;\n}\n\n/** People show initials; agents a clean sparkle glyph in their chart token. `indicator={false}` drops the card-state dot (the timeline). */\nexport function MemberAvatar({ member, card, interactive = true, size = 'default', compact = false, indicator = true, className }: { readonly className?: string | undefined; readonly member: Member; readonly card?: Request | undefined; readonly interactive?: boolean; readonly size?: ComponentProps<typeof Avatar>['size']; readonly compact?: boolean; readonly indicator?: 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 avatarSrc = safeAvatarUrl(member.avatarUrl);\n const kind = agentKind(member);\n // Agents are a rounded square with a glyph, in the agents' colour (info); people are a circle with their initial.\n const avatar = <Avatar size={compact ? 'sm' : size} className={`${kind ? 'rounded-md after:rounded-md ' : ''}${className ?? ''}`} data-agent-kind={kind} 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 {avatarSrc && <AvatarImage src={avatarSrc} alt={member.name} />}\n <AvatarFallback className={kind ? 'rounded-md bg-info-soft text-info-soft-foreground' : undefined}>{kind === 'builder' ? <HammerIcon data-lucide=\"hammer\" className={compact ? 'size-3' : 'size-3.5'} aria-hidden=\"true\" />\n : member.kind === 'agent' ? <SparkleIcon data-lucide=\"sparkle\" className={`${compact ? 'size-3' : 'size-3.5'} fill-current`} aria-hidden=\"true\" /> : member.name.replace(/[()]/g, '').split(/\\s+/).slice(0, 2).map(word => word[0]).join('').toUpperCase()}</AvatarFallback>\n {indicator && 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 => namedMember(member, labels.sharedName)).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\">{labels.sharedRole(member) ?? 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 => namedMember(member, labels.sharedName)).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\n/** The chat's time inside a bubble, with the shared chat's own clock and date labels. */\nexport function MessageClock(props: Omit<ComponentProps<typeof ChatMessageClock>, 'text' | 'title'>) {\n const { labels } = useChatText();\n return <ChatMessageClock {...props} className=\"text-[0.6875rem]\" text={labels.sharedClock(props.at)} title={labels.sharedTimestamp(props.at)} />;\n}\n\n/** The member's role line: the host's own `roleLabel`, else the labels' derivation from kind and role. */\nexport function useRoleLine(member: Member): string | undefined {\n const { labels } = useChatText();\n return member.roleLabel?.trim() || labels.sharedRole(member);\n}\n\n/**\n * Who spoke, above the first bubble of a run by someone else in a room of several people: a small avatar (a circle with the\n * initial for a person, a rounded square with a glyph for an agent) and the name. Its role shows only on tap (or keyboard).\n * `via`: the outside agent or app that posted with this person's account.\n */\nexport function AuthorName({ member, via }: { readonly member: Member; readonly via?: string | undefined }) {\n const role = useRoleLine(member);\n const { labels } = useChatText();\n const author = namedMember(member, labels.sharedName);\n const agent = agentKind(author) !== undefined;\n const name = <span data-testid=\"message-author\" data-agent-color={agentTone(author)?.token} className={`min-w-0 truncate text-xs font-medium ${agent ? 'text-info' : 'text-muted-foreground'}`}>{author.name}</span>;\n return <div data-testid=\"message-author-row\" className=\"flex min-h-5 min-w-0 items-center gap-1.5 pb-0.5\">\n <MemberAvatar member={author} compact interactive={false} indicator={false} className=\"size-5 data-[size=sm]:size-5\" />\n {role && !(via && agent)\n ? <Popover><PopoverTrigger nativeButton={false} render={<span role=\"button\" tabIndex={0} data-testid=\"message-author-toggle\" aria-label={`${author.name} · ${role}`} className=\"-my-3.5 inline-flex min-h-11 min-w-11 cursor-pointer items-center\" />}>{name}</PopoverTrigger>\n <PopoverContent data-testid=\"message-role\" side=\"top\" align=\"start\" className=\"w-auto max-w-[calc(100vw-2rem)]\">{author.name} · {role}</PopoverContent></Popover>\n : name}\n {via && <ViaLine via={via} />}\n {!(via && agent) && <MemberBadge member={author} />}\n </div>;\n}\n\n/** « via Claude Code »: the outside agent or app that posted with a person's account. */\nexport function ViaLine({ via, className = '' }: { readonly via: string; readonly className?: string }) {\n const { labels } = useChatText();\n return <span data-testid=\"message-via\" className={`min-w-0 truncate text-xs text-muted-foreground ${className}`}>{labels.sharedVia(via)}</span>;\n}\n\n/**\n * A request inside its bubble: one title line, one muted line with the time, and the choices as text buttons along the bottom\n * (« Try · Later »). The primary choice takes the theme's primary colour; no border box.\n */\nexport function RequestCard({ card, action, at, own = false }: { readonly card: Request; readonly action: (card: Request, actionId?: string) => Promise<void>; readonly at?: string | undefined; readonly own?: boolean }) {\n const { labels } = useChatText();\n const [busy, setBusy] = useState<string>(), [error, setError] = useState<string>();\n const actions = cardActions(card);\n const run = (id: string) => {\n setBusy(id); setError(undefined);\n void action(card, id).catch(error => setError(error instanceof Error ? error.message : labels.actionFailedShort)).finally(() => setBusy(undefined));\n };\n return <section data-testid=\"request-card\" aria-label={card.title} className=\"flex min-w-0 flex-col text-start\">\n <p data-testid=\"request-title\" className=\"m-0 truncate font-medium\">{card.title}</p>\n <div className=\"flex min-w-0 items-baseline gap-3\">\n <p data-testid=\"request-line\" className={`m-0 min-w-0 flex-1 truncate ${own ? 'opacity-80' : 'text-muted-foreground'}`}>{card.line}</p>\n {at && <MessageClock at={at} tone={own ? 'primary' : 'muted'} floating={false} />}\n </div>\n {error && <p role=\"alert\" className=\"m-0 text-destructive\">{error}</p>}\n {actions.length > 0 && <>\n <Separator className=\"mt-2\" />\n <div role=\"group\" aria-label={card.title} className=\"-mx-3 -mb-2 flex min-w-0\">\n {actions.map((choice, index) => {\n const primary = (choice.variant ?? (index === 0 ? 'primary' : 'secondary')) === 'primary';\n return <span key={choice.id} className={`contents ${primary && !own ? 'text-primary' : ''}`}>\n <Button data-testid=\"request-action\" data-action={choice.id} data-action-variant={primary ? 'primary' : 'secondary'} variant=\"ghost\" className=\"min-h-11 min-w-0 flex-1\"\n disabled={busy !== undefined} aria-busy={busy === choice.id || undefined} onClick={() => run(choice.id)}><span className=\"truncate\">{choice.label}</span></Button>\n </span>;\n })}\n </div>\n </>}\n </section>;\n}\n\n/**\n * An agent's question to the room: the prompt, its choices as text buttons along the bottom (one tap answers) and, when free text is\n * allowed, a Reply that opens the composer on this message. A reply to the question answers it; once answered the choices go away.\n */\nexport function QuestionCard({ message, answer, onReply, own = false }: { readonly message: ChannelMessage; readonly answer: (question: ChannelMessage, text: string) => Promise<unknown>; readonly onReply: (id: string) => void; readonly own?: boolean }) {\n const { labels } = useChatText();\n const [busy, setBusy] = useState<string>(), [error, setError] = useState<string>();\n const question = message.question!;\n const run = (option: string) => {\n setBusy(option); setError(undefined);\n void answer(message, option).catch(error => setError(error instanceof Error ? error.message : labels.actionFailedShort)).finally(() => setBusy(undefined));\n };\n const open = !question.answered;\n return <section data-testid=\"question-card\" data-answered={question.answered ? 'true' : undefined} aria-label={question.prompt} className=\"flex min-w-0 flex-col text-start\">\n <div className=\"flex min-w-0 items-baseline gap-3\">\n <p data-testid=\"question-prompt\" className=\"m-0 min-w-0 flex-1 whitespace-pre-wrap\">{question.prompt}</p>\n <MessageClock at={message.at} tone={own ? 'primary' : 'muted'} floating={false} />\n </div>\n {error && <p role=\"alert\" className=\"m-0 text-destructive\">{error}</p>}\n {!open && <p data-testid=\"question-answered\" className=\"m-0 flex items-center gap-1 text-xs text-muted-foreground\"><CheckIcon aria-hidden=\"true\" className=\"size-3\" />{labels.sharedAnswered}</p>}\n {open && (question.options.length > 0 || question.allowFreeText) && <div role=\"group\" aria-label={question.prompt} className=\"mt-2 flex min-w-0 flex-col gap-1.5\">\n {question.options.map((option, index) => <Button key={`${index}:${option}`} data-testid=\"question-option\" variant=\"outline\" className=\"min-h-11 min-w-0 justify-start\" disabled={busy !== undefined} aria-busy={busy === option || undefined} onClick={() => run(option)}><span className=\"truncate\">{option}</span></Button>)}\n {question.allowFreeText && <Button data-testid=\"question-reply\" variant=\"ghost\" className=\"min-h-11 min-w-0 justify-start\" onClick={() => onReply(message.id)}><ReplyIcon data-icon=\"inline-start\" aria-hidden=\"true\" /><span className=\"truncate\">{labels.sharedReply}</span></Button>}\n </div>}\n </section>;\n}\n\n/** A comment pinned on a spot: a compact quoted reply, a pin and where it is, then the comment itself. */\nexport function PinnedComment({ card, own, at }: { readonly card: Request; readonly own: boolean; readonly at?: string | undefined }) {\n const { labels } = useChatText();\n return <section data-testid=\"pinned-comment\" aria-label={`${labels.sharedPinned}: ${card.title}`} className=\"flex min-w-0 flex-col gap-1\">\n <div data-testid=\"pin-quote\" className=\"flex min-w-0 items-stretch gap-2\">\n <span aria-hidden=\"true\" className=\"w-0.5 shrink-0 bg-current opacity-50\" />\n <span className={`flex min-w-0 items-center gap-1 text-xs ${own ? 'opacity-80' : 'text-muted-foreground'}`}><PinIcon aria-hidden=\"true\" className=\"size-3 shrink-0\" /><span className=\"truncate\">{card.line}</span></span>\n </div>\n <p className=\"m-0 whitespace-pre-wrap\">{card.title}{at && <ClockSpacer />}</p>\n {at && <MessageClock at={at} tone={own ? 'primary' : 'muted'} />}\n </section>;\n}\n\n/** Both the composer chip and the in-bubble 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 const reveal = (event: { readonly currentTarget: Element }) => {\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 };\n if (!onRemove) return <div data-testid=\"message-quote\" className=\"-ms-1 flex min-w-0 items-stretch gap-1 pb-1\">\n <span aria-hidden=\"true\" className=\"ms-1 w-0.5 shrink-0 bg-current opacity-50\" />\n <Button variant=\"ghost\" size=\"sm\" className=\"min-w-0 max-w-full justify-start\" onClick={reveal}><span className=\"truncate opacity-80\">{quote ? `${quote.author.name}: ${text}` : text}</span></Button>\n </div>;\n return <div data-testid=\"composer-reply\" 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={reveal}><ReplyIcon aria-hidden=\"true\" /><span className=\"truncate text-muted-foreground\">{quote ? `${quote.author.name}: ${text}` : text}</span></Button>\n <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/** Host notices in a row: the latest line, small and centred; the earlier ones open on demand. */\nexport function SystemLines({ lines }: { readonly lines: readonly ChannelMessage[] }) {\n const { labels } = useChatText();\n const [open, setOpen] = useState(false);\n const shown = open ? lines : lines.slice(-1);\n return <div data-testid=\"system-lines\" data-count={lines.length} role=\"group\" className=\"flex min-w-0 flex-col items-center gap-0.5 text-center text-xs text-muted-foreground\">\n {shown.map(line => <p key={line.id} data-shared-message={line.id} data-testid=\"system-line\" className=\"m-0 max-w-[85%] [overflow-wrap:anywhere]\">{line.removed ? labels.sharedRemoved : line.text}</p>)}\n {lines.length > 1 && <Button variant=\"ghost\" size=\"sm\" data-testid=\"system-lines-toggle\" aria-expanded={open} className=\"max-sm:min-h-11 pointer-coarse:min-h-11\" onClick={() => setOpen(value => !value)}>\n {open ? labels.sharedFewerUpdates : labels.sharedMoreUpdates(lines.length - 1)}</Button>}\n </div>;\n}\n\n/** The row around one bubble: the avatar slot in multi-person channels, Reply and Delete on hover, focus or long-press. */\nexport function SharedMessage({ shared, children, remove, canRemove, openFile, onReply }: {\n readonly shared: SharedItem; readonly children: ReactNode;\n /** Multi-person channel (kept for hosts passing it): the avatar now rides in the author row above a group's first bubble (AuthorName). */\n readonly avatars?: boolean;\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 view = useSharedView();\n const [error, setError] = useState<string>();\n const [copied, setCopied] = useState(false);\n const copiedTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);\n useEffect(() => () => clearTimeout(copiedTimer.current), []);\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, own } = shared;\n const replyable = Boolean(onReply && ((author.kind === 'agent' && !message.via) || message.card));\n const removable = Boolean(remove && canRemove?.(message));\n // An answer: the app agent's own message (not a person's, not a card, not a host notice).\n const answer = !own && author.kind === 'agent' && !message.via && !message.card && message.kind !== 'system' && !message.removed;\n const plain = view.answerStyle === 'plain' && answer;\n const copyable = answer && Boolean(message.text) && view.answerActions?.includes('copy') === true;\n const copy = () => { void copyText(message.text ?? '').then(() => { setCopied(true); clearTimeout(copiedTimer.current); copiedTimer.current = setTimeout(() => setCopied(false), 1500); }, () => setError(labels.copyFailed)); };\n const footer = answer ? view.footer?.(message) : undefined;\n // In a plain answer the copy action is a visible text button under it; in a bubble it is an icon on hover with Reply and Delete.\n const copyInline = copyable && plain, copyOnHover = copyable && !plain;\n const actionable = !message.removed && (replyable || removable || copyOnHover);\n return <div ref={element} data-shared-message={message.id} data-own={own ? 'true' : undefined} className=\"group/shared-message relative flex min-w-0 flex-col\"\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 <div className=\"flex min-w-0 items-end gap-2.5\">\n <div data-testid=\"shared-message-content\" className={`flex min-w-0 flex-1 flex-col gap-1${own ? ' [&>button]:self-end' : ''}`}>\n {message.removed ? <p className=\"m-0 text-sm text-muted-foreground italic\">{labels.sharedRemoved}</p> : children}\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 {(copyInline || footer) && <div data-testid=\"message-footer\" className=\"flex min-w-0 flex-wrap items-center gap-x-3 text-xs text-muted-foreground\">\n {copyInline && <Button variant=\"ghost\" size=\"sm\" data-testid=\"message-copy\" onClick={copy}>{copied ? <CheckIcon data-icon=\"inline-start\" aria-hidden=\"true\" /> : <CopyIcon data-icon=\"inline-start\" aria-hidden=\"true\" />}{copied ? labels.copied : labels.copyResponse}</Button>}\n {footer}\n </div>}\n {error && <p role=\"alert\">{error}</p>}\n </div></div>\n {actionable && <div data-testid=\"shared-message-actions\" hidden={phone && !actionsOpen} className={`absolute ${own ? 'left-0' : '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 {copyOnHover && <Tooltip><TooltipTrigger render={<Button variant=\"ghost\" size={phone ? 'icon' : 'icon-xs'} data-testid=\"message-copy\" aria-label={copied ? labels.copied : labels.copyResponse} onClick={copy}>{copied ? <CheckIcon aria-hidden=\"true\" /> : <CopyIcon aria-hidden=\"true\" />}</Button>} /><TooltipContent>{copied ? labels.copied : labels.copyResponse}</TooltipContent></Tooltip>}\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;\n /** The first message of a calendar day: a day pill goes before it. */\n readonly separator: boolean;\n /** A run of consecutive host notices (`kind: 'system'`), shown as one collapsed item; `message` is its latest line. */\n readonly system?: readonly ChannelMessage[] | undefined;\n readonly showSpinner: boolean; readonly latestCard: Request | undefined; readonly quote: ReturnType<typeof sharedQuote>;\n}\nconst dayOf = (at: string) => { const date = new Date(at); return Number.isFinite(date.getTime()) ? date.toDateString() : at; };\n/**\n * WhatsApp's grouping: one author (and `via`) within fifteen minutes on the same day is a group; host notices end a group and\n * collapse together. Each day starts with a day pill.\n */\nexport function sharedItems(shared: SharedChatState, nameOf: NameOf = defaultSharedLabels.sharedName): 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 const sameDay = previous !== undefined && dayOf(previous.at) === dayOf(message.at);\n const system = message.kind === 'system';\n if (group && previous && sameDay && (system ? previous.kind === 'system'\n : previous.kind !== 'system' && previous.author === message.author && previous.via === message.via && elapsed >= 0 && elapsed <= 15 * 60_000)) group.push(message);\n else groups.push([message]);\n }\n // One pass for what every item reads, instead of a search through the whole thread per message.\n const cards = new Map<string, Request>(), byId = new Map<string, ChannelMessage>();\n for (const message of shared.messages) { byId.set(message.id, message); if (message.card) cards.set(message.author, message.card); }\n const quoteOf = (replyTo: string | undefined) => { const message = replyTo === undefined ? undefined : byId.get(replyTo); return message ? { message, author: memberOf(shared, message.author, nameOf) } : undefined; };\n return groups.flatMap((group, groupIndex): SharedItem[] => {\n const before = groups[groupIndex - 1]?.at(-1);\n const separator = !before || dayOf(before.at) !== dayOf(group[0]!.at);\n if (group[0]!.kind === 'system') {\n const message = group.at(-1)!;\n return [{ key: `shared:${group[0]!.id}`, message, author: memberOf(shared, message.author, nameOf), own: false, first: true, lastItem: true, separator, system: group,\n showSpinner: false, latestCard: undefined, quote: undefined }];\n }\n const lastItem = group.at(-1);\n return group.map((message, index): SharedItem => ({ key: `shared:${message.id}`, message, author: memberOf(shared, message.author, nameOf), own: message.author === shared.me.id,\n first: index === 0, lastItem: message === lastItem, separator: index === 0 && separator,\n showSpinner: working.get(message.author) === message.id, latestCard: cards.get(message.author), quote: quoteOf(message.replyTo) }));\n });\n}\n",
|
|
33
|
+
"content": "'use client';\n\nimport { useEffect, useRef, useState, useSyncExternalStore } from 'react';\nimport type { ComponentProps, ReactNode } from 'react';\nimport { cardActions, 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 { CheckIcon, CircleIcon, CopyIcon, HammerIcon, PinIcon, ReplyIcon, SparkleIcon, 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 { Drawer, DrawerContent, DrawerTitle } from '@/components/ui/drawer';\nimport { Separator } from '@/components/ui/separator';\nimport { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';\nimport { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';\nimport { copyText } from '../utils/utils';\nimport { usePhone } from '../utils/use-media';\nimport { BubbleTail, ClockSpacer, MessageClock as ChatMessageClock, clockRoom } from '../pi-chat/message';\nexport { BubbleTail, ClockSpacer, clockRoom };\nimport { useChatText, useSharedView } from './text';\nimport { agentKind, namedMember } from './names';\nimport { defaultSharedLabels } from './shared-labels';\nimport type { SharedLabels } from './shared-labels';\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\nconst latestCard = (shared: SharedChatState, id: string) => shared.messages.findLast(message => message.author === id && message.card)?.card;\ntype NameOf = SharedLabels['sharedName'];\nconst unknown = new WeakMap<NameOf, Map<string, Member>>();\nconst memberOf = (shared: SharedChatState, id: string, nameOf: NameOf): Member => {\n const found = shared.members.find(member => member.id === id);\n if (found) return namedMember(found, nameOf);\n let guests = unknown.get(nameOf);\n if (!guests) { guests = new Map(); unknown.set(nameOf, guests); }\n let guest = guests.get(id);\n if (!guest) { guest = namedMember({ id, name: id, kind: 'person' }, nameOf); guests.set(id, guest); }\n return guest;\n};\nexport function sharedQuote(shared: SharedChatState, id: string | undefined, nameOf: NameOf = defaultSharedLabels.sharedName) {\n const message = shared.messages.find(message => message.id === id);\n return message ? { message, author: memberOf(shared, message.author, nameOf) } : undefined;\n}\n\n/** Only https, `data:image/`, `blob:` and same-origin relative addresses load; anything else falls back to the initials. */\nexport function safeAvatarUrl(url: string | undefined): string | undefined {\n if (!url) return undefined;\n return /^(?:https:\\/\\/|data:image\\/|blob:)/i.test(url) || /^\\/(?![/\\\\])/.test(url) ? url : undefined;\n}\n\n/** People show initials; agents a clean sparkle glyph in their chart token. `indicator={false}` drops the card-state dot (the timeline). */\nexport function MemberAvatar({ member, card, interactive = true, size = 'default', compact = false, indicator = true, className }: { readonly className?: string | undefined; readonly member: Member; readonly card?: Request | undefined; readonly interactive?: boolean; readonly size?: ComponentProps<typeof Avatar>['size']; readonly compact?: boolean; readonly indicator?: 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 avatarSrc = safeAvatarUrl(member.avatarUrl);\n const kind = agentKind(member);\n // Agents are a rounded square with a glyph, in the agents' colour (info); people are a circle with their initial.\n const avatar = <Avatar size={compact ? 'sm' : size} className={`${kind ? 'rounded-md after:rounded-md ' : ''}${className ?? ''}`} data-agent-kind={kind} 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 {avatarSrc && <AvatarImage src={avatarSrc} alt={member.name} />}\n <AvatarFallback className={kind ? 'rounded-md bg-info-soft text-info-soft-foreground' : undefined}>{kind === 'builder' ? <HammerIcon data-lucide=\"hammer\" className={compact ? 'size-3' : 'size-3.5'} aria-hidden=\"true\" />\n : member.kind === 'agent' ? <SparkleIcon data-lucide=\"sparkle\" className={`${compact ? 'size-3' : 'size-3.5'} fill-current`} aria-hidden=\"true\" /> : member.name.replace(/[()]/g, '').split(/\\s+/).slice(0, 2).map(word => word[0]).join('').toUpperCase()}</AvatarFallback>\n {indicator && 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 => namedMember(member, labels.sharedName)).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\">{labels.sharedRole(member) ?? 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 => namedMember(member, labels.sharedName)).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\n/** The chat's time inside a bubble, with the shared chat's own clock and date labels. */\nexport function MessageClock(props: Omit<ComponentProps<typeof ChatMessageClock>, 'text' | 'title'>) {\n const { labels } = useChatText();\n return <ChatMessageClock {...props} className=\"text-[0.6875rem]\" text={labels.sharedClock(props.at)} title={labels.sharedTimestamp(props.at)} />;\n}\n\n/** The member's role line: the host's own `roleLabel`, else the labels' derivation from kind and role. */\nexport function useRoleLine(member: Member): string | undefined {\n const { labels } = useChatText();\n return member.roleLabel?.trim() || labels.sharedRole(member);\n}\n\n/**\n * Who spoke, above the first bubble of a run by someone else in a room of several people: a small avatar (a circle with the\n * initial for a person, a rounded square with a glyph for an agent) and the name. Its role shows only on tap (or keyboard).\n * `via`: the outside agent or app that posted with this person's account.\n */\nexport function AuthorName({ member, via }: { readonly member: Member; readonly via?: string | undefined }) {\n const role = useRoleLine(member);\n const { labels } = useChatText();\n const author = namedMember(member, labels.sharedName);\n const agent = agentKind(author) !== undefined;\n const name = <span data-testid=\"message-author\" data-agent-color={agentTone(author)?.token} className={`min-w-0 truncate text-xs font-medium ${agent ? 'text-info' : 'text-muted-foreground'}`}>{author.name}</span>;\n return <div data-testid=\"message-author-row\" className=\"flex min-h-5 min-w-0 items-center gap-1.5 pb-0.5\">\n <MemberAvatar member={author} compact interactive={false} indicator={false} className=\"size-5 data-[size=sm]:size-5\" />\n {role && !(via && agent)\n ? <Popover><PopoverTrigger nativeButton={false} render={<span role=\"button\" tabIndex={0} data-testid=\"message-author-toggle\" aria-label={`${author.name} · ${role}`} className=\"-my-3.5 inline-flex min-h-11 min-w-11 cursor-pointer items-center\" />}>{name}</PopoverTrigger>\n <PopoverContent data-testid=\"message-role\" side=\"top\" align=\"start\" className=\"w-auto max-w-[calc(100vw-2rem)]\">{author.name} · {role}</PopoverContent></Popover>\n : name}\n {via && <ViaLine via={via} />}\n {!(via && agent) && <MemberBadge member={author} />}\n </div>;\n}\n\n/** « via Claude Code »: the outside agent or app that posted with a person's account. */\nexport function ViaLine({ via, className = '' }: { readonly via: string; readonly className?: string }) {\n const { labels } = useChatText();\n return <span data-testid=\"message-via\" className={`min-w-0 truncate text-xs text-muted-foreground ${className}`}>{labels.sharedVia(via)}</span>;\n}\n\n/**\n * A request inside its bubble: one title line, one muted line with the time, and the choices as text buttons along the bottom\n * (« Try · Later »). The primary choice takes the theme's primary colour; no border box.\n */\nexport function RequestCard({ card, action, at, own = false }: { readonly card: Request; readonly action: (card: Request, actionId?: string) => Promise<void>; readonly at?: string | undefined; readonly own?: boolean }) {\n const { labels } = useChatText();\n const [busy, setBusy] = useState<string>(), [error, setError] = useState<string>();\n const actions = cardActions(card);\n const run = (id: string) => {\n setBusy(id); setError(undefined);\n void action(card, id).catch(error => setError(error instanceof Error ? error.message : labels.actionFailedShort)).finally(() => setBusy(undefined));\n };\n return <section data-testid=\"request-card\" aria-label={card.title} className=\"flex min-w-0 flex-col text-start\">\n <p data-testid=\"request-title\" className=\"m-0 truncate font-medium\">{card.title}</p>\n <div className=\"flex min-w-0 items-baseline gap-3\">\n <p data-testid=\"request-line\" className={`m-0 min-w-0 flex-1 truncate ${own ? 'opacity-80' : 'text-muted-foreground'}`}>{card.line}</p>\n {at && <MessageClock at={at} tone={own ? 'primary' : 'muted'} floating={false} />}\n </div>\n {error && <p role=\"alert\" className=\"m-0 text-destructive\">{error}</p>}\n {actions.length > 0 && <>\n <Separator className=\"mt-2\" />\n <div role=\"group\" aria-label={card.title} className=\"-mx-3 -mb-2 flex min-w-0\">\n {actions.map((choice, index) => {\n const primary = (choice.variant ?? (index === 0 ? 'primary' : 'secondary')) === 'primary';\n return <span key={choice.id} className={`contents ${primary && !own ? 'text-primary' : ''}`}>\n <Button data-testid=\"request-action\" data-action={choice.id} data-action-variant={primary ? 'primary' : 'secondary'} variant=\"ghost\" className=\"min-h-11 min-w-0 flex-1\"\n disabled={busy !== undefined} aria-busy={busy === choice.id || undefined} onClick={() => run(choice.id)}><span className=\"truncate\">{choice.label}</span></Button>\n </span>;\n })}\n </div>\n </>}\n </section>;\n}\n\n/**\n * An agent's question to the room: the prompt, its choices as text buttons along the bottom (one tap answers) and, when free text is\n * allowed, a Reply that opens the composer on this message. A reply to the question answers it; once answered the choices go away.\n */\nexport function QuestionCard({ message, answer, onReply, own = false }: { readonly message: ChannelMessage; readonly answer: (question: ChannelMessage, text: string) => Promise<unknown>; readonly onReply: (id: string) => void; readonly own?: boolean }) {\n const { labels } = useChatText();\n const [busy, setBusy] = useState<string>(), [error, setError] = useState<string>();\n const question = message.question!;\n const run = (option: string) => {\n setBusy(option); setError(undefined);\n void answer(message, option).catch(error => setError(error instanceof Error ? error.message : labels.actionFailedShort)).finally(() => setBusy(undefined));\n };\n const open = !question.answered;\n return <section data-testid=\"question-card\" data-answered={question.answered ? 'true' : undefined} aria-label={question.prompt} className=\"flex min-w-0 flex-col text-start\">\n <div className=\"flex min-w-0 items-baseline gap-3\">\n <p data-testid=\"question-prompt\" className=\"m-0 min-w-0 flex-1 whitespace-pre-wrap\">{question.prompt}</p>\n <MessageClock at={message.at} tone={own ? 'primary' : 'muted'} floating={false} />\n </div>\n {error && <p role=\"alert\" className=\"m-0 text-destructive\">{error}</p>}\n {!open && <p data-testid=\"question-answered\" className=\"m-0 flex items-center gap-1 text-xs text-muted-foreground\"><CheckIcon aria-hidden=\"true\" className=\"size-3\" />{labels.sharedAnswered}</p>}\n {open && (question.options.length > 0 || question.allowFreeText) && <div role=\"group\" aria-label={question.prompt} className=\"mt-2 flex min-w-0 flex-col gap-1.5\">\n {question.options.map((option, index) => <Button key={`${index}:${option}`} data-testid=\"question-option\" variant=\"outline\" className=\"min-h-11 min-w-0 justify-start\" disabled={busy !== undefined} aria-busy={busy === option || undefined} onClick={() => run(option)}><span className=\"truncate\">{option}</span></Button>)}\n {question.allowFreeText && <Button data-testid=\"question-reply\" variant=\"ghost\" className=\"min-h-11 min-w-0 justify-start\" onClick={() => onReply(message.id)}><ReplyIcon data-icon=\"inline-start\" aria-hidden=\"true\" /><span className=\"truncate\">{labels.sharedReply}</span></Button>}\n </div>}\n </section>;\n}\n\n/** A comment pinned on a spot: a compact quoted reply, a pin and where it is, then the comment itself. */\nexport function PinnedComment({ card, own, at }: { readonly card: Request; readonly own: boolean; readonly at?: string | undefined }) {\n const { labels } = useChatText();\n return <section data-testid=\"pinned-comment\" aria-label={`${labels.sharedPinned}: ${card.title}`} className=\"flex min-w-0 flex-col gap-1\">\n <div data-testid=\"pin-quote\" className=\"flex min-w-0 items-stretch gap-2\">\n <span aria-hidden=\"true\" className=\"w-0.5 shrink-0 bg-current opacity-50\" />\n <span className={`flex min-w-0 items-center gap-1 text-xs ${own ? 'opacity-80' : 'text-muted-foreground'}`}><PinIcon aria-hidden=\"true\" className=\"size-3 shrink-0\" /><span className=\"truncate\">{card.line}</span></span>\n </div>\n <p className=\"m-0 whitespace-pre-wrap\">{card.title}{at && <ClockSpacer />}</p>\n {at && <MessageClock at={at} tone={own ? 'primary' : 'muted'} />}\n </section>;\n}\n\n/** Both the composer chip and the in-bubble 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 const reveal = (event: { readonly currentTarget: Element }) => {\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 };\n if (!onRemove) return <div data-testid=\"message-quote\" className=\"-ms-1 flex min-w-0 items-stretch gap-1 pb-1\">\n <span aria-hidden=\"true\" className=\"ms-1 w-0.5 shrink-0 bg-current opacity-50\" />\n <Button variant=\"ghost\" size=\"sm\" className=\"min-w-0 max-w-full justify-start\" onClick={reveal}><span className=\"truncate opacity-80\">{quote ? `${quote.author.name}: ${text}` : text}</span></Button>\n </div>;\n return <div data-testid=\"composer-reply\" 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={reveal}><ReplyIcon aria-hidden=\"true\" /><span className=\"truncate text-muted-foreground\">{quote ? `${quote.author.name}: ${text}` : text}</span></Button>\n <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/** Host notices in a row: the latest line, small and centred; the earlier ones open on demand. */\nexport function SystemLines({ lines }: { readonly lines: readonly ChannelMessage[] }) {\n const { labels } = useChatText();\n const [open, setOpen] = useState(false);\n const shown = open ? lines : lines.slice(-1);\n return <div data-testid=\"system-lines\" data-count={lines.length} role=\"group\" className=\"flex min-w-0 flex-col items-center gap-0.5 text-center text-xs text-muted-foreground\">\n {shown.map(line => <p key={line.id} data-shared-message={line.id} data-testid=\"system-line\" className=\"m-0 max-w-[85%] [overflow-wrap:anywhere]\">{line.removed ? labels.sharedRemoved : line.text}</p>)}\n {lines.length > 1 && <Button variant=\"ghost\" size=\"sm\" data-testid=\"system-lines-toggle\" aria-expanded={open} className=\"max-md:min-h-11 pointer-coarse:min-h-11\" onClick={() => setOpen(value => !value)}>\n {open ? labels.sharedFewerUpdates : labels.sharedMoreUpdates(lines.length - 1)}</Button>}\n </div>;\n}\n\n/** The row around one bubble: the avatar slot in multi-person channels, Reply and Delete on hover, focus or long-press. */\nexport function SharedMessage({ shared, children, remove, canRemove, openFile, onReply }: {\n readonly shared: SharedItem; readonly children: ReactNode;\n /** Multi-person channel (kept for hosts passing it): the avatar now rides in the author row above a group's first bubble (AuthorName). */\n readonly avatars?: boolean;\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 view = useSharedView();\n const [error, setError] = useState<string>();\n const [copied, setCopied] = useState(false);\n const copiedTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);\n useEffect(() => () => clearTimeout(copiedTimer.current), []);\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, own } = shared;\n const replyable = Boolean(onReply && ((author.kind === 'agent' && !message.via) || message.card));\n const removable = Boolean(remove && canRemove?.(message));\n // An answer: the app agent's own message (not a person's, not a card, not a host notice).\n const answer = !own && author.kind === 'agent' && !message.via && !message.card && message.kind !== 'system' && !message.removed;\n const plain = view.answerStyle === 'plain' && answer;\n const copyable = answer && Boolean(message.text) && view.answerActions?.includes('copy') === true;\n const copy = () => { void copyText(message.text ?? '').then(() => { setCopied(true); clearTimeout(copiedTimer.current); copiedTimer.current = setTimeout(() => setCopied(false), 1500); }, () => setError(labels.copyFailed)); };\n const footer = answer ? view.footer?.(message) : undefined;\n // In a plain answer the copy action is a visible text button under it; in a bubble it is an icon on hover with Reply and Delete.\n const copyInline = copyable && plain, copyOnHover = copyable && !plain;\n const actionable = !message.removed && (replyable || removable || copyOnHover);\n return <div ref={element} data-shared-message={message.id} data-own={own ? 'true' : undefined} className=\"group/shared-message relative flex min-w-0 flex-col\"\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 <div className=\"flex min-w-0 items-end gap-2.5\">\n <div data-testid=\"shared-message-content\" className={`flex min-w-0 flex-1 flex-col gap-1${own ? ' [&>button]:self-end' : ''}`}>\n {message.removed ? <p className=\"m-0 text-sm text-muted-foreground italic\">{labels.sharedRemoved}</p> : children}\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 {(copyInline || footer) && <div data-testid=\"message-footer\" className=\"flex min-w-0 flex-wrap items-center gap-x-3 text-xs text-muted-foreground\">\n {copyInline && <Button variant=\"ghost\" size=\"sm\" data-testid=\"message-copy\" onClick={copy}>{copied ? <CheckIcon data-icon=\"inline-start\" aria-hidden=\"true\" /> : <CopyIcon data-icon=\"inline-start\" aria-hidden=\"true\" />}{copied ? labels.copied : labels.copyResponse}</Button>}\n {footer}\n </div>}\n {error && <p role=\"alert\">{error}</p>}\n </div></div>\n {actionable && <div data-testid=\"shared-message-actions\" hidden={phone && !actionsOpen} className={`absolute ${own ? 'left-0' : '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 {copyOnHover && <Tooltip><TooltipTrigger render={<Button variant=\"ghost\" size={phone ? 'icon' : 'icon-xs'} data-testid=\"message-copy\" aria-label={copied ? labels.copied : labels.copyResponse} onClick={copy}>{copied ? <CheckIcon aria-hidden=\"true\" /> : <CopyIcon aria-hidden=\"true\" />}</Button>} /><TooltipContent>{copied ? labels.copied : labels.copyResponse}</TooltipContent></Tooltip>}\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;\n /** The first message of a calendar day: a day pill goes before it. */\n readonly separator: boolean;\n /** A run of consecutive host notices (`kind: 'system'`), shown as one collapsed item; `message` is its latest line. */\n readonly system?: readonly ChannelMessage[] | undefined;\n readonly showSpinner: boolean; readonly latestCard: Request | undefined; readonly quote: ReturnType<typeof sharedQuote>;\n}\nconst dayOf = (at: string) => { const date = new Date(at); return Number.isFinite(date.getTime()) ? date.toDateString() : at; };\n/**\n * WhatsApp's grouping: one author (and `via`) within fifteen minutes on the same day is a group; host notices end a group and\n * collapse together. Each day starts with a day pill.\n */\nexport function sharedItems(shared: SharedChatState, nameOf: NameOf = defaultSharedLabels.sharedName): 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 const sameDay = previous !== undefined && dayOf(previous.at) === dayOf(message.at);\n const system = message.kind === 'system';\n if (group && previous && sameDay && (system ? previous.kind === 'system'\n : previous.kind !== 'system' && previous.author === message.author && previous.via === message.via && elapsed >= 0 && elapsed <= 15 * 60_000)) group.push(message);\n else groups.push([message]);\n }\n // One pass for what every item reads, instead of a search through the whole thread per message.\n const cards = new Map<string, Request>(), byId = new Map<string, ChannelMessage>();\n for (const message of shared.messages) { byId.set(message.id, message); if (message.card) cards.set(message.author, message.card); }\n const quoteOf = (replyTo: string | undefined) => { const message = replyTo === undefined ? undefined : byId.get(replyTo); return message ? { message, author: memberOf(shared, message.author, nameOf) } : undefined; };\n return groups.flatMap((group, groupIndex): SharedItem[] => {\n const before = groups[groupIndex - 1]?.at(-1);\n const separator = !before || dayOf(before.at) !== dayOf(group[0]!.at);\n if (group[0]!.kind === 'system') {\n const message = group.at(-1)!;\n return [{ key: `shared:${group[0]!.id}`, message, author: memberOf(shared, message.author, nameOf), own: false, first: true, lastItem: true, separator, system: group,\n showSpinner: false, latestCard: undefined, quote: undefined }];\n }\n const lastItem = group.at(-1);\n return group.map((message, index): SharedItem => ({ key: `shared:${message.id}`, message, author: memberOf(shared, message.author, nameOf), own: message.author === shared.me.id,\n first: index === 0, lastItem: message === lastItem, separator: index === 0 && separator,\n showSpinner: working.get(message.author) === message.id, latestCard: cards.get(message.author), quote: quoteOf(message.replyTo) }));\n });\n}\n",
|
|
48
34
|
"type": "registry:component",
|
|
49
35
|
"target": "components/shared-chat/messages.tsx"
|
|
50
36
|
},
|
|
51
37
|
{
|
|
52
38
|
"path": "registry/shared-chat/timeline.tsx",
|
|
53
|
-
"content": "'use client';\nimport { Children, memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';\nimport type { ReactNode, RefObject } from 'react';\nimport type { SharedChatController } from '@hachej/boring-ui-kit/shared-chat';\nimport type { MentionsConfig } from '../pi-chat/config';\nimport { MessageText } from '../pi-chat/message';\nimport { Markdown } from '../pi-chat-markdown/markdown';\nimport { PAGE_FIRST, PAGE_MORE, useRowChunks, useTranscript } from '../pi-chat/session';\nimport type { ChatPaging } from '../pi-chat/session';\nimport { TranscriptHistory } from '../pi-chat/history';\nimport { Message, MessageContent } from '@/components/ui/message';\nimport { Bubble, BubbleContent } from '@/components/ui/bubble';\nimport { Badge } from '@/components/ui/badge';\nimport { Button } from '@/components/ui/button';\nimport { MessageScroller, MessageScrollerViewport, MessageScrollerContent, MessageScrollerItem } from '@/components/ui/message-scroller';\nimport { showsAuthors, runHead } from './names';\nimport { AuthorName, BubbleTail, ClockSpacer, MessageClock, PinnedComment, QuestionCard, ReplyQuote, RequestCard, SharedMessage, SystemLines, ViaLine, clockRoom, sharedItems } from './messages';\nimport type { SharedItem } from './messages';\nimport { HostTrace } from './host-trace';\nimport { useChatText, useSharedView } from './text';\n\nexport interface TimelineApi { readonly reset: () => void; readonly reveal: (id: string) => void }\n/**\n * WhatsApp's room: bubbles hug the bottom, a day pill starts each day, host notices collapse into one small line, and a\n * « ↓ 3 new messages » pill appears below the list (never over it) while the reader is scrolled up.\n */\nexport function SharedTimeline({ controller, api, focusComposer, openFile, mentions, pendingVoice, paging }: {\n readonly controller: SharedChatController; readonly api: RefObject<TimelineApi | undefined>;\n readonly focusComposer: () => void; readonly openFile?: ((path: string) => void) | undefined; readonly mentions: MentionsConfig; readonly pendingVoice?: ReactNode; readonly paging?: ChatPaging | undefined;\n}) {\n const state = useSyncExternalStore(controller.subscribe, controller.getSnapshot, controller.getSnapshot);\n const { labels, icons } = useChatText();\n const view = useSharedView();\n const items = useStableItems(useMemo(() => sharedItems(state.shared, labels.sharedName), [state.shared, labels.sharedName]));\n const connected = state.connection.kind === 'connected';\n const transcript = useTranscript(items, paging?.first ?? PAGE_FIRST, paging?.more ?? PAGE_MORE, { state: state.history, connected: !state.disposed && connected, load: controller.loadEarlier });\n const { stick, hidden } = transcript;\n const chunks = useRowChunks(useMemo(() => transcript.visible(items), [items, hidden]));\n const root = useRef<HTMLDivElement>(null);\n const [revealing, setRevealing] = useState<string>();\n // The last message the reader has seen at the bottom; what others posted after it while scrolled up is « new ».\n const latest = state.shared.messages.at(-1)?.id;\n const [seen, setSeen] = useState(latest);\n useEffect(() => { if (stick.isAtBottom && seen !== latest) setSeen(latest); }, [stick.isAtBottom, latest, seen]);\n // The pill's row shows only once the reader scrolls up, so loading a room (which scrolls down) never shifts the list under a tap.\n const lastTop = useRef(0);\n const [scrolledUp, setScrolledUp] = useState(false);\n useEffect(() => { if (stick.isAtBottom) setScrolledUp(false); }, [stick.isAtBottom]);\n const seenAt = state.shared.messages.findIndex(message => message.id === seen);\n const unseen = stick.isAtBottom ? 0 : state.shared.messages.slice(seenAt + 1).filter(message => message.author !== state.shared.me.id).length;\n // The reader's own new message lands in view, wherever they were: on Send it is drawn at once (pending), and Send's own scroll\n // runs before that render. Its confirmation (same clientId) is the same message and does not scroll again.\n const mine = state.shared.messages.findLast(message => message.author === state.shared.me.id);\n const mineKey = mine ? mine.clientId ?? mine.id : undefined;\n const lastMine = useRef(mineKey);\n useLayoutEffect(() => {\n if (mineKey === lastMine.current) return;\n const fresh = lastMine.current !== undefined || mine?.pending;\n lastMine.current = mineKey;\n if (fresh && mine === state.shared.messages.at(-1)) { transcript.reset(); void stick.scrollToBottom('instant'); }\n }, [mineKey]);\n useEffect(() => {\n const value = { reset: () => { transcript.reset(); void stick.scrollToBottom('instant'); }, reveal: setRevealing };\n api.current = value;\n return () => { if (api.current === value) api.current = undefined; };\n });\n useEffect(() => {\n if (!revealing) return;\n const key = items.find(item => item.message.id === revealing || item.system?.some(line => line.id === revealing))?.key;\n if (key) {\n transcript.revealTo(key);\n const frame = requestAnimationFrame(() => {\n const parent = [...(root.current?.querySelectorAll<HTMLElement>('[data-shared-message]') ?? [])].find(element => element.dataset.sharedMessage === revealing);\n if (parent) { parent.scrollIntoView({ block: 'center', behavior: 'instant' }); setRevealing(undefined); }\n });\n return () => cancelAnimationFrame(frame);\n }\n if (state.history.kind === 'loading') return;\n if (state.history.kind === 'ready' && state.history.hasMore) void controller.loadEarlier().catch(() => setRevealing(undefined));\n else setRevealing(undefined);\n }, [revealing, items, hidden, state.history]);\n const hasContent = items.length > 0 || Children.count(pendingVoice) > 0;\n // Names and avatars only where they tell people apart: two or more members besides the reader.\n const multi = showsAuthors(state.shared.members, state.shared.me.id, view.presence);\n const focus = useRef(focusComposer); focus.current = focusComposer;\n const reply = useCallback((id: string) => { controller.setReplyTo(id); focus.current(); }, [controller]);\n const open = useRef(openFile); open.current = openFile;\n const hasOpen = Boolean(openFile);\n const stableOpen = useMemo(() => hasOpen ? (path: string) => open.current?.(path) : undefined, [hasOpen]);\n return <MessageScroller>\n <MessageScrollerViewport ref={node => { root.current = node; stick.scrollRef(node); }} data-testid=\"transcript-scroll\" aria-label={labels.messages} className=\"min-h-0 flex-1 scrollbar-thumb-border scrollbar-track-transparent\"\n onScroll={event => { const top = event.currentTarget.scrollTop; if (top < lastTop.current - 4) setScrolledUp(true); lastTop.current = top; }}>\n {items.length > 0 && (hidden > 0 || state.history.kind === 'loading' || state.history.kind === 'error' || state.history.kind === 'ready' && state.history.hasMore) && <TranscriptHistory history={state.history} connected={!state.disposed && connected} onLoad={transcript.reveal} sentinelRef={transcript.sentinelRef} hidden={hidden} loaded={transcript.loaded} />}\n <div data-testid=\"transcript-bottom\" className=\"mx-auto flex min-h-full w-full max-w-[720px] flex-col justify-end px-4 pt-4 pb-3 sm:px-6\">\n <MessageScrollerContent aria-label={labels.messages} aria-live=\"polite\" data-testid=\"transcript\" className=\"h-auto min-h-0 gap-0 [&>[data-local-voice]]:mt-3\">\n {chunks.map((run, index) => <TimelineChunk key={run[0]!.key} items={run} top={index === 0} multi={multi} controller={controller} mentions={mentions} openFile={stableOpen} onReply={reply} onReveal={setRevealing} />)}\n {pendingVoice}\n </MessageScrollerContent>\n </div>\n </MessageScrollerViewport>\n {hasContent && !stick.isAtBottom && scrolledUp && <div data-testid=\"transcript-navigation\" className=\"flex shrink-0 items-center justify-center py-1.5\">\n <Button variant=\"secondary\" size=\"sm\" data-testid=\"jump-latest\" data-unseen={unseen || undefined} aria-label={unseen ? labels.sharedNewMessages(unseen) : labels.jumpToLatest} title={labels.jumpToLatest} className=\"max-sm:min-h-11 pointer-coarse:min-h-11\" onClick={() => api.current?.reset()}>\n <icons.jumpToLatest data-icon=\"inline-start\" aria-hidden=\"true\" />{unseen ? labels.sharedNewMessages(unseen) : labels.jumpToLatest}</Button>\n </div>}\n </MessageScroller>;\n}\n\n/** Same item, field by field (the quote is rebuilt each pass, so it compares by what it points at). */\nconst sameItem = (a: SharedItem, b: SharedItem) => a.key === b.key && a.message === b.message && a.author === b.author && a.own === b.own && a.first === b.first\n && a.lastItem === b.lastItem && a.separator === b.separator && a.showSpinner === b.showSpinner && a.latestCard === b.latestCard\n && a.quote?.message === b.quote?.message && a.quote?.author === b.quote?.author\n && (a.system === b.system || (a.system?.length === b.system?.length && a.system!.every((line, index) => line === b.system![index])));\n/**\n * Keeps the previous object of every item that did not change, so a page loaded above, a delta or a new message renders only the\n * rows it touches (rows and their runs are memoized by identity).\n */\nfunction useStableItems(next: readonly SharedItem[]): readonly SharedItem[] {\n const previous = useRef(new Map<string, SharedItem>());\n return useMemo(() => {\n const items = next.map(item => { const before = previous.current.get(item.key); return before && sameItem(before, item) ? before : item; });\n previous.current = new Map(items.map(item => [item.key, item]));\n return items;\n }, [next]);\n}\n\ntype RowProps = {\n readonly multi: boolean; readonly controller: SharedChatController; readonly mentions: MentionsConfig;\n readonly openFile: ((path: string) => void) | undefined; readonly onReply: (id: string) => void; readonly onReveal: (id: string) => void;\n};\n/** A run of timeline rows as one scroller item (`useRowChunks`). Memoized, like each row: it renders again only when its own items change. */\nconst TimelineChunk = memo(function TimelineChunk({ items, top, ...props }: RowProps & { readonly items: readonly SharedItem[]; readonly top: boolean }) {\n return <MessageScrollerItem messageId={items[0]!.key} data-transcript-chunk=\"\" className=\"flex flex-col [content-visibility:visible] [gap:inherit]\">\n {items.map((item, index) => <TimelineRow key={item.key} item={item} top={top && index === 0} {...props} />)}\n </MessageScrollerItem>;\n});\nconst TimelineRow = memo(function TimelineRow({ item, top, multi, controller, mentions, openFile, onReply, onReveal }: RowProps & { readonly item: SharedItem; readonly top: boolean }) {\n const { labels } = useChatText();\n const view = useSharedView();\n const context = useMemo(() => ({ pieces: { mentions: true, skills: [], renderMention: mentions.renderSent }, openFile }), [mentions.renderSent, openFile]);\n const renderMention = (path: string, text: string) => mentions.renderSent?.(path) ?? text;\n const bubble = () => {\n const { message, author, own, first } = item;\n const person = own || author.kind !== 'agent' || message.via !== undefined;\n const headKind = runHead({ first, own, authors: multi, via: message.via });\n const head = headKind === 'author' ? <AuthorName member={author} via={message.via} /> : undefined;\n const traced = view.steps !== 'hidden' && !message.via && author.kind === 'agent' && Boolean(message.streaming || message.steps?.length);\n // Who spoke (and via what) sits above the group's first bubble, outside it: the bubble keeps its width. My own messages carry no name;\n // one posted through an outside agent says so on my side.\n const via = headKind === 'via' && message.via ? <ViaLine via={message.via} className=\"ms-auto block w-fit max-w-full pb-0.5 pe-1\" /> : undefined;\n const live = traced && Boolean(message.streaming);\n const trace = traced ? <HostTrace participant={author.name} steps={message.steps ?? []} streaming={Boolean(message.streaming)} active={item.showSpinner} /> : undefined;\n const quote = message.replyTo ? <ReplyQuote targetId={message.replyTo} quote={item.quote} onReveal={onReveal} /> : undefined;\n if (message.question) return <article data-row-id={item.key} data-role=\"event\" className=\"flex w-full min-w-0 flex-col\">\n {head}{via}\n <Bubble variant=\"muted\" align=\"start\" data-testid=\"question-bubble\">\n {first && <BubbleTail side=\"start\" />}\n <BubbleContent className=\"relative min-w-52\">{quote}\n <QuestionCard message={message} answer={controller.answer} onReply={onReply} />\n </BubbleContent>\n </Bubble></article>;\n if (message.card) return <article data-row-id={item.key} data-role=\"event\" className=\"flex w-full min-w-0 flex-col\">\n {head}{via}\n <Bubble variant={own ? 'default' : 'muted'} align={own ? 'end' : 'start'} data-testid=\"card-bubble\">\n {first && <BubbleTail side={own ? 'end' : 'start'} />}\n <BubbleContent className=\"relative min-w-52\">{quote}\n {message.card.kind === 'pin' ? <PinnedComment card={message.card} own={own} at={message.at} /> : <RequestCard card={message.card} action={controller.action} at={message.at} own={own} />}\n </BubbleContent>\n </Bubble></article>;\n return <article data-row-id={item.key} data-role={person ? 'user' : 'assistant'} data-streaming={message.streaming ? 'true' : undefined} data-pending={message.pending ? 'true' : undefined} className=\"group/message\">{head}{via}<Message align={own ? 'end' : 'start'}><MessageContent>\n {own ? <div data-testid=\"own-message\" className=\"contents [&_[data-testid=message-mention]]:text-inherit\">\n <MessageText text={message.text ?? ''} context={context} variant=\"default\" decoration={first ? <BubbleTail side=\"end\" /> : undefined} header={quote} footer={<><ClockSpacer /><MessageClock at={message.at} tone=\"primary\" pending={message.pending} /></>} /></div>\n : view.answerStyle === 'plain' && !person ? <div data-testid=\"plain-answer\" className=\"min-w-0\">\n {quote}\n {message.text && <Markdown text={message.text} streaming={Boolean(message.streaming)} renderMention={renderMention} />}\n {traced && <HostTrace participant={author.name} steps={message.steps ?? []} streaming={Boolean(message.streaming)} active={item.showSpinner} />}\n </div>\n : <Bubble variant=\"muted\">{first && <BubbleTail side=\"start\" />}<BubbleContent className=\"relative\">\n {quote}\n {/* While the agent writes, the working line sits above the growing text and the time waits for the answer: nothing under the text moves as it grows (B5). */}\n {live && trace}\n {message.text && <div className={traced ? undefined : clockRoom}><Markdown text={message.text} streaming={Boolean(message.streaming)} renderMention={renderMention} /></div>}\n {traced ? <>\n {!live && trace}\n {!live && <div className=\"flex justify-end\"><MessageClock at={message.at} tone=\"muted\" floating={false} /></div>}\n </> : <MessageClock at={message.at} tone=\"muted\" />}\n </BubbleContent></Bubble>}\n </MessageContent></Message></article>;\n };\n const gap = top || item.separator ? '' : item.system || item.first ? ' mt-3' : ' mt-0.5';\n return <div data-row-key={item.key} data-group-start={item.first ? 'true' : undefined} className={`min-w-0 shrink-0${gap}`}>\n {item.separator && <div data-testid=\"day-separator\" className={`flex justify-center pb-3${top ? '' : ' pt-4'}`}><Badge variant=\"secondary\"><time dateTime={item.message.at} title={labels.sharedTimestamp(item.message.at)}>{labels.sharedDay(item.message.at)}</time></Badge></div>}\n {item.system ? <SystemLines lines={item.system} />\n : <SharedMessage shared={item} remove={controller.remove} canRemove={controller.canRemove} openFile={openFile} onReply={onReply} onReveal={onReveal}>{bubble()}</SharedMessage>}\n </div>;\n});\n",
|
|
39
|
+
"content": "'use client';\nimport { Children, memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';\nimport type { ReactNode, RefObject } from 'react';\nimport type { SharedChatController } from '@hachej/boring-ui-kit/shared-chat';\nimport type { MentionsConfig } from '../pi-chat/config';\nimport { MessageText } from '../pi-chat/message';\nimport { Markdown } from '../pi-chat-markdown/markdown';\nimport { PAGE_FIRST, PAGE_MORE, useRowChunks, useTranscript } from '../pi-chat/session';\nimport type { ChatPaging } from '../pi-chat/session';\nimport { TranscriptHistory } from '../pi-chat/history';\nimport { Message, MessageContent } from '@/components/ui/message';\nimport { Bubble, BubbleContent } from '@/components/ui/bubble';\nimport { Badge } from '@/components/ui/badge';\nimport { Button } from '@/components/ui/button';\nimport { MessageScroller, MessageScrollerViewport, MessageScrollerContent, MessageScrollerItem } from '@/components/ui/message-scroller';\nimport { showsAuthors, runHead } from './names';\nimport { AuthorName, BubbleTail, ClockSpacer, MessageClock, PinnedComment, QuestionCard, ReplyQuote, RequestCard, SharedMessage, SystemLines, ViaLine, clockRoom, sharedItems } from './messages';\nimport type { SharedItem } from './messages';\nimport { HostTrace } from './host-trace';\nimport { useChatText, useSharedView } from './text';\n\nexport interface TimelineApi { readonly reset: () => void; readonly reveal: (id: string) => void }\n/**\n * WhatsApp's room: bubbles hug the bottom, a day pill starts each day, host notices collapse into one small line, and a\n * « ↓ 3 new messages » pill appears below the list (never over it) while the reader is scrolled up.\n */\nexport function SharedTimeline({ controller, api, focusComposer, openFile, mentions, pendingVoice, paging }: {\n readonly controller: SharedChatController; readonly api: RefObject<TimelineApi | undefined>;\n readonly focusComposer: () => void; readonly openFile?: ((path: string) => void) | undefined; readonly mentions: MentionsConfig; readonly pendingVoice?: ReactNode; readonly paging?: ChatPaging | undefined;\n}) {\n const state = useSyncExternalStore(controller.subscribe, controller.getSnapshot, controller.getSnapshot);\n const { labels, icons } = useChatText();\n const view = useSharedView();\n const items = useStableItems(useMemo(() => sharedItems(state.shared, labels.sharedName), [state.shared, labels.sharedName]));\n const connected = state.connection.kind === 'connected';\n const transcript = useTranscript(items, paging?.first ?? PAGE_FIRST, paging?.more ?? PAGE_MORE, { state: state.history, connected: !state.disposed && connected, load: controller.loadEarlier });\n const { stick, hidden } = transcript;\n const chunks = useRowChunks(useMemo(() => hidden ? items.slice(hidden) : items, [items, hidden]));\n const root = useRef<HTMLDivElement>(null);\n const [revealing, setRevealing] = useState<string>();\n // The last message the reader has seen at the bottom; what others posted after it while scrolled up is « new ».\n const latest = state.shared.messages.at(-1)?.id;\n const [seen, setSeen] = useState(latest);\n // eslint-disable-next-line react-hooks/set-state-in-effect -- marks the latest message seen once the reader is at the bottom, after commit; settles when equal\n useEffect(() => { if (stick.isAtBottom && seen !== latest) setSeen(latest); }, [stick.isAtBottom, latest, seen]);\n // The pill's row shows only once the reader scrolls up, so loading a room (which scrolls down) never shifts the list under a tap.\n const lastTop = useRef(0);\n const [scrolledUp, setScrolledUp] = useState(false);\n // eslint-disable-next-line react-hooks/set-state-in-effect -- reaching the bottom hides the pill's row once, after commit\n useEffect(() => { if (stick.isAtBottom) setScrolledUp(false); }, [stick.isAtBottom]);\n const seenAt = state.shared.messages.findIndex(message => message.id === seen);\n const unseen = stick.isAtBottom ? 0 : state.shared.messages.slice(seenAt + 1).filter(message => message.author !== state.shared.me.id).length;\n // The reader's own new message lands in view, wherever they were: on Send it is drawn at once (pending), and Send's own scroll\n // runs before that render. Its confirmation (same clientId) is the same message and does not scroll again.\n const mine = state.shared.messages.findLast(message => message.author === state.shared.me.id);\n const mineKey = mine ? mine.clientId ?? mine.id : undefined;\n const lastMine = useRef(mineKey);\n useLayoutEffect(() => {\n if (mineKey === lastMine.current) return;\n const fresh = lastMine.current !== undefined || mine?.pending;\n lastMine.current = mineKey;\n if (fresh && mine === state.shared.messages.at(-1)) { transcript.reset(); void stick.scrollToBottom('instant'); }\n // eslint-disable-next-line react-hooks/exhaustive-deps -- scrolls only when the reader's own latest message changes, not on every message or transcript object\n }, [mineKey]);\n useEffect(() => bindApi(api, { reset: () => { transcript.reset(); void stick.scrollToBottom('instant'); }, reveal: setRevealing }), [api, transcript, stick]);\n useEffect(() => {\n if (!revealing) return;\n const key = items.find(item => item.message.id === revealing || item.system?.some(line => line.id === revealing))?.key;\n if (key) {\n transcript.revealTo(key);\n const frame = requestAnimationFrame(() => {\n const parent = [...(root.current?.querySelectorAll<HTMLElement>('[data-shared-message]') ?? [])].find(element => element.dataset.sharedMessage === revealing);\n if (parent) { parent.scrollIntoView({ block: 'center', behavior: 'instant' }); setRevealing(undefined); }\n });\n return () => cancelAnimationFrame(frame);\n }\n if (state.history.kind === 'loading') return;\n if (state.history.kind === 'ready' && state.history.hasMore) void controller.loadEarlier().catch(() => setRevealing(undefined));\n // A message that is neither loaded nor further back in history cannot be revealed.\n // eslint-disable-next-line react-hooks/set-state-in-effect -- an unreachable target is dropped once, after commit\n else setRevealing(undefined);\n // eslint-disable-next-line react-hooks/exhaustive-deps -- runs per target, rows and history page; the transcript object and controller are rebuilt or stable and must not re-trigger a reveal\n }, [revealing, items, hidden, state.history]);\n const hasContent = items.length > 0 || Children.count(pendingVoice) > 0;\n // Names and avatars only where they tell people apart: two or more members besides the reader.\n const multi = showsAuthors(state.shared.members, state.shared.me.id, view.presence);\n const focus = useRef(focusComposer);\n useLayoutEffect(() => { focus.current = focusComposer; });\n const reply = useCallback((id: string) => { controller.setReplyTo(id); focus.current(); }, [controller]);\n const open = useRef(openFile);\n useLayoutEffect(() => { open.current = openFile; });\n const hasOpen = Boolean(openFile);\n const stableOpen = useMemo(() => hasOpen ? (path: string) => open.current?.(path) : undefined, [hasOpen]);\n return <MessageScroller>\n <MessageScrollerViewport ref={node => { root.current = node; stick.scrollRef(node); }} data-testid=\"transcript-scroll\" aria-label={labels.messages} className=\"min-h-0 flex-1 scrollbar-thumb-border scrollbar-track-transparent\"\n onScroll={event => { const top = event.currentTarget.scrollTop; if (top < lastTop.current - 4) setScrolledUp(true); lastTop.current = top; }}>\n {items.length > 0 && (hidden > 0 || state.history.kind === 'loading' || state.history.kind === 'error' || state.history.kind === 'ready' && state.history.hasMore) && <TranscriptHistory history={state.history} connected={!state.disposed && connected} onLoad={transcript.reveal} sentinelRef={transcript.sentinelRef} hidden={hidden} loaded={transcript.loaded} />}\n <div data-testid=\"transcript-bottom\" className=\"mx-auto flex min-h-full w-full max-w-[720px] flex-col justify-end px-4 pt-4 pb-3 md:px-6\">\n <MessageScrollerContent aria-label={labels.messages} aria-live=\"polite\" data-testid=\"transcript\" className=\"h-auto min-h-0 gap-0 [&>[data-local-voice]]:mt-3\">\n {chunks.map((run, index) => <TimelineChunk key={run[0]!.key} items={run} top={index === 0} multi={multi} controller={controller} mentions={mentions} openFile={stableOpen} onReply={reply} onReveal={setRevealing} />)}\n {pendingVoice}\n </MessageScrollerContent>\n </div>\n </MessageScrollerViewport>\n {hasContent && !stick.isAtBottom && scrolledUp && <div data-testid=\"transcript-navigation\" className=\"flex shrink-0 items-center justify-center py-1.5\">\n <Button variant=\"secondary\" size=\"sm\" data-testid=\"jump-latest\" data-unseen={unseen || undefined} aria-label={unseen ? labels.sharedNewMessages(unseen) : labels.jumpToLatest} title={labels.jumpToLatest} className=\"max-md:min-h-11 pointer-coarse:min-h-11\" onClick={() => api.current?.reset()}>\n <icons.jumpToLatest data-icon=\"inline-start\" aria-hidden=\"true\" />{unseen ? labels.sharedNewMessages(unseen) : labels.jumpToLatest}</Button>\n </div>}\n </MessageScroller>;\n}\n\n/** Same item, field by field (the quote is rebuilt each pass, so it compares by what it points at). */\nconst sameItem = (a: SharedItem, b: SharedItem) => a.key === b.key && a.message === b.message && a.author === b.author && a.own === b.own && a.first === b.first\n && a.lastItem === b.lastItem && a.separator === b.separator && a.showSpinner === b.showSpinner && a.latestCard === b.latestCard\n && a.quote?.message === b.quote?.message && a.quote?.author === b.quote?.author\n && (a.system === b.system || (a.system?.length === b.system?.length && a.system!.every((line, index) => line === b.system![index])));\n/**\n * Keeps the previous object of every item that did not change, so a page loaded above, a delta or a new message renders only the\n * rows it touches (rows and their runs are memoized by identity).\n */\nfunction useStableItems(next: readonly SharedItem[]): readonly SharedItem[] {\n // A derived identity cache, read and replaced inside the memo only: the same items always give the same objects.\n const previous = useRef(new Map<string, SharedItem>());\n return useMemo(() => {\n // eslint-disable-next-line react-hooks/refs -- reuses the cached object of an unchanged item; no setState, no render loop\n const items = next.map(item => { const before = previous.current.get(item.key); return before && sameItem(before, item) ? before : item; });\n // eslint-disable-next-line react-hooks/refs -- stores the items for the next pass; a cache write, not state, so it triggers no render\n previous.current = new Map(items.map(item => [item.key, item]));\n return items;\n }, [next]);\n}\n\n/** Hands the timeline's controls to the host's ref until this timeline unmounts or rebinds. */\nfunction bindApi(api: RefObject<TimelineApi | undefined>, value: TimelineApi) {\n api.current = value;\n return () => { if (api.current === value) api.current = undefined; };\n}\n\ntype RowProps = {\n readonly multi: boolean; readonly controller: SharedChatController; readonly mentions: MentionsConfig;\n readonly openFile: ((path: string) => void) | undefined; readonly onReply: (id: string) => void; readonly onReveal: (id: string) => void;\n};\n/** A run of timeline rows as one scroller item (`useRowChunks`). Memoized, like each row: it renders again only when its own items change. */\nconst TimelineChunk = memo(function TimelineChunk({ items, top, ...props }: RowProps & { readonly items: readonly SharedItem[]; readonly top: boolean }) {\n return <MessageScrollerItem messageId={items[0]!.key} data-transcript-chunk=\"\" className=\"flex flex-col [content-visibility:visible] [gap:inherit]\">\n {items.map((item, index) => <TimelineRow key={item.key} item={item} top={top && index === 0} {...props} />)}\n </MessageScrollerItem>;\n});\nconst TimelineRow = memo(function TimelineRow({ item, top, multi, controller, mentions, openFile, onReply, onReveal }: RowProps & { readonly item: SharedItem; readonly top: boolean }) {\n const { labels } = useChatText();\n const view = useSharedView();\n const context = useMemo(() => ({ pieces: { mentions: true, skills: [], renderMention: mentions.renderSent }, openFile }), [mentions.renderSent, openFile]);\n const renderMention = (path: string, text: string) => mentions.renderSent?.(path) ?? text;\n const bubble = () => {\n const { message, author, own, first } = item;\n const person = own || author.kind !== 'agent' || message.via !== undefined;\n const headKind = runHead({ first, own, authors: multi, via: message.via });\n const head = headKind === 'author' ? <AuthorName member={author} via={message.via} /> : undefined;\n const traced = view.steps !== 'hidden' && !message.via && author.kind === 'agent' && Boolean(message.streaming || message.steps?.length);\n // Who spoke (and via what) sits above the group's first bubble, outside it: the bubble keeps its width. My own messages carry no name;\n // one posted through an outside agent says so on my side.\n const via = headKind === 'via' && message.via ? <ViaLine via={message.via} className=\"ms-auto block w-fit max-w-full pb-0.5 pe-1\" /> : undefined;\n const live = traced && Boolean(message.streaming);\n const trace = traced ? <HostTrace participant={author.name} steps={message.steps ?? []} streaming={Boolean(message.streaming)} active={item.showSpinner} /> : undefined;\n const quote = message.replyTo ? <ReplyQuote targetId={message.replyTo} quote={item.quote} onReveal={onReveal} /> : undefined;\n if (message.question) return <article data-row-id={item.key} data-role=\"event\" className=\"flex w-full min-w-0 flex-col\">\n {head}{via}\n <Bubble variant=\"muted\" align=\"start\" data-testid=\"question-bubble\">\n {first && <BubbleTail side=\"start\" />}\n <BubbleContent className=\"relative min-w-52\">{quote}\n <QuestionCard message={message} answer={controller.answer} onReply={onReply} />\n </BubbleContent>\n </Bubble></article>;\n if (message.card) return <article data-row-id={item.key} data-role=\"event\" className=\"flex w-full min-w-0 flex-col\">\n {head}{via}\n <Bubble variant={own ? 'default' : 'muted'} align={own ? 'end' : 'start'} data-testid=\"card-bubble\">\n {first && <BubbleTail side={own ? 'end' : 'start'} />}\n <BubbleContent className=\"relative min-w-52\">{quote}\n {message.card.kind === 'pin' ? <PinnedComment card={message.card} own={own} at={message.at} /> : <RequestCard card={message.card} action={controller.action} at={message.at} own={own} />}\n </BubbleContent>\n </Bubble></article>;\n return <article data-row-id={item.key} data-role={person ? 'user' : 'assistant'} data-streaming={message.streaming ? 'true' : undefined} data-pending={message.pending ? 'true' : undefined} className=\"group/message\">{head}{via}<Message align={own ? 'end' : 'start'}><MessageContent>\n {own ? <div data-testid=\"own-message\" className=\"contents [&_[data-testid=message-mention]]:text-inherit\">\n <MessageText text={message.text ?? ''} context={context} variant=\"default\" decoration={first ? <BubbleTail side=\"end\" /> : undefined} header={quote} footer={<><ClockSpacer /><MessageClock at={message.at} tone=\"primary\" pending={message.pending} /></>} /></div>\n : view.answerStyle === 'plain' && !person ? <div data-testid=\"plain-answer\" className=\"min-w-0\">\n {quote}\n {message.text && <Markdown text={message.text} streaming={Boolean(message.streaming)} renderMention={renderMention} />}\n {traced && <HostTrace participant={author.name} steps={message.steps ?? []} streaming={Boolean(message.streaming)} active={item.showSpinner} />}\n </div>\n : <Bubble variant=\"muted\">{first && <BubbleTail side=\"start\" />}<BubbleContent className=\"relative\">\n {quote}\n {/* While the agent writes, the working line sits above the growing text and the time waits for the answer: nothing under the text moves as it grows (B5). */}\n {live && trace}\n {message.text && <div className={traced ? undefined : clockRoom}><Markdown text={message.text} streaming={Boolean(message.streaming)} renderMention={renderMention} /></div>}\n {traced ? <>\n {!live && trace}\n {!live && <div className=\"flex justify-end\"><MessageClock at={message.at} tone=\"muted\" floating={false} /></div>}\n </> : <MessageClock at={message.at} tone=\"muted\" />}\n </BubbleContent></Bubble>}\n </MessageContent></Message></article>;\n };\n const gap = top || item.separator ? '' : item.system || item.first ? ' mt-3' : ' mt-0.5';\n return <div data-row-key={item.key} data-group-start={item.first ? 'true' : undefined} className={`min-w-0 shrink-0${gap}`}>\n {item.separator && <div data-testid=\"day-separator\" className={`flex justify-center pb-3${top ? '' : ' pt-4'}`}><Badge variant=\"secondary\"><time dateTime={item.message.at} title={labels.sharedTimestamp(item.message.at)}>{labels.sharedDay(item.message.at)}</time></Badge></div>}\n {item.system ? <SystemLines lines={item.system} />\n : <SharedMessage shared={item} remove={controller.remove} canRemove={controller.canRemove} openFile={openFile} onReply={onReply} onReveal={onReveal}>{bubble()}</SharedMessage>}\n </div>;\n});\n",
|
|
54
40
|
"type": "registry:component",
|
|
55
41
|
"target": "components/shared-chat/timeline.tsx"
|
|
56
42
|
},
|
|
57
43
|
{
|
|
58
44
|
"path": "registry/shared-chat/host-trace.tsx",
|
|
59
|
-
"content": "'use client';\nimport { useEffect, useState } from 'react';\nimport { AlertCircleIcon, CheckIcon, WrenchIcon } from 'lucide-react';\nimport type { Step } from '@hachej/boring-ui-kit/shared-chat';\nimport { Spinner } from '@/components/ui/spinner';\nimport { Trace, TraceCollapsible, TraceCollapsibleContent, TraceCollapsibleTrigger, TraceIcon, TraceLine, TraceTitle } from '@/components/ui/trace';\nimport { useChatText } from './text';\n\n/** Host observations only. This trace neither creates Pi tool calls nor runs work. */\nexport function HostTrace({ steps, participant, streaming, active }: { readonly steps: readonly Step[]; readonly participant: string; readonly streaming: boolean; readonly active: boolean }) {\n const { labels } = useChatText();\n const [chosen, setChosen] = useState<boolean | undefined>();\n useEffect(() => setChosen(undefined), [participant, streaming]);\n const open = chosen ?? false;\n const state = streaming ? 'running' : steps.some(step => step.status === 'failed') ? 'failed' : 'done';\n const running = steps.reduce((found, step, index) => found === -1 && step.status === 'running' ? index : found, -1);\n const current = steps[running];\n const progress = current ? ` · ${current.label} (${running + 1}/${steps.length})` : '';\n const Icon = streaming ? Spinner : state === 'failed' ? AlertCircleIcon : CheckIcon;\n return <Trace data-testid=\"activity\" data-boring=\"activity\" data-state={state} data-open={open ? 'true' : 'false'} className=\"my-1.5\">\n <TraceCollapsible open={open} onOpenChange={setChosen}>\n <TraceCollapsibleTrigger className=\"min-h-8 max-
|
|
45
|
+
"content": "'use client';\nimport { useEffect, useState } from 'react';\nimport { AlertCircleIcon, CheckIcon, WrenchIcon } from 'lucide-react';\nimport type { Step } from '@hachej/boring-ui-kit/shared-chat';\nimport { Spinner } from '@/components/ui/spinner';\nimport { Trace, TraceCollapsible, TraceCollapsibleContent, TraceCollapsibleTrigger, TraceIcon, TraceLine, TraceTitle } from '@/components/ui/trace';\nimport { useChatText } from './text';\n\n/** Host observations only. This trace neither creates Pi tool calls nor runs work. */\nexport function HostTrace({ steps, participant, streaming, active }: { readonly steps: readonly Step[]; readonly participant: string; readonly streaming: boolean; readonly active: boolean }) {\n const { labels } = useChatText();\n const [chosen, setChosen] = useState<boolean | undefined>();\n // Another participant or a run that starts or ends drops the reader's choice.\n // eslint-disable-next-line react-hooks/set-state-in-effect -- resets the choice once per participant or run change, after commit\n useEffect(() => setChosen(undefined), [participant, streaming]);\n const open = chosen ?? false;\n const state = streaming ? 'running' : steps.some(step => step.status === 'failed') ? 'failed' : 'done';\n const running = steps.reduce((found, step, index) => found === -1 && step.status === 'running' ? index : found, -1);\n const current = steps[running];\n const progress = current ? ` · ${current.label} (${running + 1}/${steps.length})` : '';\n const Icon = streaming ? Spinner : state === 'failed' ? AlertCircleIcon : CheckIcon;\n return <Trace data-testid=\"activity\" data-boring=\"activity\" data-state={state} data-open={open ? 'true' : 'false'} className=\"my-1.5\">\n <TraceCollapsible open={open} onOpenChange={setChosen}>\n <TraceCollapsibleTrigger className=\"min-h-8 max-md:min-h-11 pointer-coarse:min-h-11\">\n <TraceLine {...(streaming ? { status: 'running' } : state === 'failed' ? { status: 'error' } : {})}>\n {!(open || streaming && !active) && <TraceIcon><Icon aria-hidden=\"true\" /></TraceIcon>}\n <TraceTitle data-testid=\"activity-label\">{streaming ? `${labels.sharedWorking(participant)}${progress}` : labels.steps(steps.length)}</TraceTitle>\n </TraceLine>\n </TraceCollapsibleTrigger>\n <TraceCollapsibleContent><Trace data-testid=\"activity-steps\">\n {steps.map((step, index) => {\n const StepIcon = step.status === 'running' ? open && active && streaming && index === running ? Spinner : WrenchIcon : step.status === 'done' ? CheckIcon : AlertCircleIcon;\n return <div key={index} data-testid=\"activity-step\" data-kind=\"host\" data-status={step.status === 'done' ? 'completed' : step.status}>\n <TraceLine data-testid=\"host-step\" {...(step.status === 'running' ? { status: 'running' } : step.status === 'failed' ? { status: 'error' } : {})}>\n <TraceIcon><StepIcon aria-hidden=\"true\" /></TraceIcon><TraceTitle><div className=\"flex min-w-0 flex-col\"><span>{step.label}</span>{step.detail && <pre data-testid=\"step-detail\" className=\"m-0 text-xs font-mono whitespace-pre-wrap text-muted-foreground [overflow-wrap:anywhere]\">{step.detail}</pre>}</div></TraceTitle>\n </TraceLine>\n </div>;\n })}\n </Trace></TraceCollapsibleContent>\n </TraceCollapsible>\n </Trace>;\n}\n",
|
|
60
46
|
"type": "registry:component",
|
|
61
47
|
"target": "components/shared-chat/host-trace.tsx"
|
|
62
48
|
},
|
package/r/task-list-viewer.json
CHANGED
|
@@ -4,18 +4,12 @@
|
|
|
4
4
|
"title": "Consumer task-list viewer",
|
|
5
5
|
"description": "A consumer-defined task-list schema and controller over the shared text buffer, with a restylable borrowed React renderer.",
|
|
6
6
|
"dependencies": [
|
|
7
|
-
"@hachej/boring-ui-kit@^0.4.
|
|
8
|
-
"@hachej/boring-files@^0.4.
|
|
7
|
+
"@hachej/boring-ui-kit@^0.4.27",
|
|
8
|
+
"@hachej/boring-files@^0.4.27",
|
|
9
9
|
"react@^19.0.0",
|
|
10
10
|
"react-dom@^19.0.0",
|
|
11
11
|
"zod@^4.6.5"
|
|
12
12
|
],
|
|
13
|
-
"registryDependencies": [
|
|
14
|
-
"button",
|
|
15
|
-
"card",
|
|
16
|
-
"input",
|
|
17
|
-
"checkbox"
|
|
18
|
-
],
|
|
19
13
|
"files": [
|
|
20
14
|
{
|
|
21
15
|
"path": "registry/task-list-viewer/task-list-document.ts",
|
|
@@ -31,7 +25,7 @@
|
|
|
31
25
|
},
|
|
32
26
|
{
|
|
33
27
|
"path": "registry/task-list-viewer/task-list-viewer.tsx",
|
|
34
|
-
"content": "'use client';\n\nimport { randomUUID } from '@hachej/boring-files/platform';\nimport React, { useId, useRef, useState, useSyncExternalStore } from 'react';\nimport { Button } from '@/components/ui/button';\nimport { Checkbox } from '@/components/ui/checkbox';\nimport { Card } from '@/components/ui/card';\nimport { Input } from '@/components/ui/input';\nimport type { ReadResult } from '@hachej/boring-files';\nimport type { SaveResult } from '@hachej/boring-ui-kit/resources';\nimport type { TaskListController } from './task-list-controller';\nimport type { TaskListOperation } from './task-list-document';\n\nexport interface TaskListViewerProps {\n readonly controller: TaskListController;\n readonly title?: string;\n readonly className?: string;\n}\n\nexport function TaskListViewer({ controller, title = 'Tasks', className }: TaskListViewerProps) {\n const state = useSyncExternalStore(controller.subscribe, controller.getSnapshot, controller.getSnapshot);\n const current = useRef({ controller });\n if (current.current.controller !== controller) current.current = { controller };\n const owner = current.current;\n const [form, setForm] = useState({ owner, draftTitle: '', notice: '' });\n const draftTitle = form.owner === owner ? form.draftTitle : '';\n const notice = form.owner === owner ? form.notice : '';\n const updateForm = (patch: Partial<{ draftTitle: string; notice: string }>) => {\n if (current.current !== owner) return;\n setForm(previous => ({ ...(previous.owner === owner ? previous : { owner, draftTitle: '', notice: '' }), ...patch }));\n };\n const setDraftTitle = (value: string) => updateForm({ draftTitle: value });\n const setNotice = (value: string) => updateForm({ notice: value });\n const inputId = useId();\n const disabled = state.readOnly || state.lifecycle === 'disposed';\n const edit = (operations: readonly TaskListOperation[]) => {\n const result = controller.actions.edit(controller.actions.selection(), operations);\n setNotice(result.kind === 'applied' ? '' : `${result.kind}: ${result.reason}`);\n return result;\n };\n const run = async (action: () => Promise<ReadResult | SaveResult>) => {\n try {\n const result = await action();\n setNotice(result.kind === 'available' || result.kind === 'saved' ? ''\n : result.kind === 'missing' ? 'missing: Resource is missing'\n : `${result.kind}${'reason' in result ? `: ${result.reason}` : ''}`);\n }\n catch (error) { setNotice(String(error)); }\n };\n const save = state.save.kind === 'settled' ? state.save.result : undefined;\n return <Card className={['boring-task-list-recipe', className].filter(Boolean).join(' ')} data-boring=\"task-list-viewer\">\n <h2>{title}</h2>\n <p role=\"status\" aria-live=\"polite\">{state.lifecycle === 'disposed' ? 'Closed' : state.readOnly ? 'Read only' : state.dirty ? 'Unsaved changes' : 'Saved'}\n {state.save.kind === 'pending' ? ' · Saving' : save ? ` · ${save.kind}${'reason' in save ? `: ${save.reason}` : ''}` : ''}\n {state.remote ? ' · Remote changes available' : ''}</p>\n <ul>{state.document.items.map(item => <li key={item.id} data-task-id={item.id}>\n <label><Checkbox aria-label={`Complete ${item.title}`} checked={item.completed} disabled={disabled}\n onCheckedChange={completed => edit([{ kind: 'set-completed', id: item.id, completed }])} />{item.title}</label>\n <Button variant=\"ghost\" aria-label={`Remove ${item.title}`} disabled={disabled} onClick={() => edit([{ kind: 'remove', id: item.id }])}>Remove</Button>\n </li>)}</ul>\n <form onSubmit={event => { event.preventDefault(); if (edit([{ kind: 'add', id: randomUUID(), title: draftTitle }]).kind === 'applied') setDraftTitle(''); }}>\n <label htmlFor={inputId}>New task</label><Input id={inputId} value={draftTitle} disabled={disabled} onChange={event => setDraftTitle(event.target.value)} />\n <Button variant=\"default\" type=\"submit\" disabled={disabled || !draftTitle.trim()}>Add task</Button>\n </form>\n <div className=\"boring-task-list-actions\">\n <Button variant=\"default\" disabled={disabled || !state.dirty || state.save.kind === 'pending'} onClick={() => void run(() => controller.flush(controller.actions.selection()))}>Save</Button>\n <Button variant=\"ghost\" disabled={state.lifecycle === 'disposed'} onClick={() => void run(controller.actions.refresh)}>Refresh</Button>\n <Button variant=\"ghost\" disabled={disabled || (!state.dirty && !state.remote)} onClick={() => void run(controller.actions.discardToRemote)}>Discard</Button>\n <Button variant=\"ghost\" disabled={state.lifecycle === 'disposed' || save?.kind !== 'unknown'} onClick={() => void run(controller.actions.reconcile)}>Reconcile</Button>\n <Button variant=\"ghost\" disabled={state.lifecycle === 'disposed' || save?.kind !== 'unknown'} onClick={() => void run(controller.actions.abandon)}>Abandon save</Button>\n </div>\n {notice && <p role=\"alert\">{notice}</p>}\n </Card>;\n}\n",
|
|
28
|
+
"content": "'use client';\n\nimport { randomUUID } from '@hachej/boring-files/platform';\nimport React, { useId, useRef, useState, useSyncExternalStore } from 'react';\nimport { Button } from '@/components/ui/button';\nimport { Checkbox } from '@/components/ui/checkbox';\nimport { Card } from '@/components/ui/card';\nimport { Input } from '@/components/ui/input';\nimport type { ReadResult } from '@hachej/boring-files';\nimport type { SaveResult } from '@hachej/boring-ui-kit/resources';\nimport type { TaskListController } from './task-list-controller';\nimport type { TaskListOperation } from './task-list-document';\n\nexport interface TaskListViewerProps {\n readonly controller: TaskListController;\n readonly title?: string;\n readonly className?: string;\n}\n\nexport function TaskListViewer({ controller, title = 'Tasks', className }: TaskListViewerProps) {\n const state = useSyncExternalStore(controller.subscribe, controller.getSnapshot, controller.getSnapshot);\n // A fresh owner per controller: drafts and notices of another controller are dropped, and a late result of one is ignored.\n const current = useRef({ controller });\n // eslint-disable-next-line react-hooks/refs -- an identity cache keyed by controller: a new controller swaps in a new owner object, never setState in render\n if (current.current.controller !== controller) current.current = { controller };\n // eslint-disable-next-line react-hooks/refs -- the owner token for this controller (see above); late results compare against it\n const owner = current.current;\n const [form, setForm] = useState({ owner, draftTitle: '', notice: '' });\n const draftTitle = form.owner === owner ? form.draftTitle : '';\n const notice = form.owner === owner ? form.notice : '';\n const updateForm = (patch: Partial<{ draftTitle: string; notice: string }>) => {\n if (current.current !== owner) return;\n setForm(previous => ({ ...(previous.owner === owner ? previous : { owner, draftTitle: '', notice: '' }), ...patch }));\n };\n const setDraftTitle = (value: string) => updateForm({ draftTitle: value });\n const setNotice = (value: string) => updateForm({ notice: value });\n const inputId = useId();\n const disabled = state.readOnly || state.lifecycle === 'disposed';\n const edit = (operations: readonly TaskListOperation[]) => {\n const result = controller.actions.edit(controller.actions.selection(), operations);\n setNotice(result.kind === 'applied' ? '' : `${result.kind}: ${result.reason}`);\n return result;\n };\n const run = async (action: () => Promise<ReadResult | SaveResult>) => {\n try {\n const result = await action();\n setNotice(result.kind === 'available' || result.kind === 'saved' ? ''\n : result.kind === 'missing' ? 'missing: Resource is missing'\n : `${result.kind}${'reason' in result ? `: ${result.reason}` : ''}`);\n }\n catch (error) { setNotice(String(error)); }\n };\n const save = state.save.kind === 'settled' ? state.save.result : undefined;\n return <Card className={['boring-task-list-recipe', className].filter(Boolean).join(' ')} data-boring=\"task-list-viewer\">\n <h2>{title}</h2>\n <p role=\"status\" aria-live=\"polite\">{state.lifecycle === 'disposed' ? 'Closed' : state.readOnly ? 'Read only' : state.dirty ? 'Unsaved changes' : 'Saved'}\n {state.save.kind === 'pending' ? ' · Saving' : save ? ` · ${save.kind}${'reason' in save ? `: ${save.reason}` : ''}` : ''}\n {state.remote ? ' · Remote changes available' : ''}</p>\n <ul>{state.document.items.map(item => <li key={item.id} data-task-id={item.id}>\n <label><Checkbox aria-label={`Complete ${item.title}`} checked={item.completed} disabled={disabled}\n onCheckedChange={completed => edit([{ kind: 'set-completed', id: item.id, completed }])} />{item.title}</label>\n <Button variant=\"ghost\" aria-label={`Remove ${item.title}`} disabled={disabled} onClick={() => edit([{ kind: 'remove', id: item.id }])}>Remove</Button>\n </li>)}</ul>\n <form onSubmit={event => { event.preventDefault(); if (edit([{ kind: 'add', id: randomUUID(), title: draftTitle }]).kind === 'applied') setDraftTitle(''); }}>\n <label htmlFor={inputId}>New task</label><Input id={inputId} value={draftTitle} disabled={disabled} onChange={event => setDraftTitle(event.target.value)} />\n <Button variant=\"default\" type=\"submit\" disabled={disabled || !draftTitle.trim()}>Add task</Button>\n </form>\n <div className=\"boring-task-list-actions\">\n <Button variant=\"default\" disabled={disabled || !state.dirty || state.save.kind === 'pending'} onClick={() => void run(() => controller.flush(controller.actions.selection()))}>Save</Button>\n <Button variant=\"ghost\" disabled={state.lifecycle === 'disposed'} onClick={() => void run(controller.actions.refresh)}>Refresh</Button>\n <Button variant=\"ghost\" disabled={disabled || (!state.dirty && !state.remote)} onClick={() => void run(controller.actions.discardToRemote)}>Discard</Button>\n <Button variant=\"ghost\" disabled={state.lifecycle === 'disposed' || save?.kind !== 'unknown'} onClick={() => void run(controller.actions.reconcile)}>Reconcile</Button>\n <Button variant=\"ghost\" disabled={state.lifecycle === 'disposed' || save?.kind !== 'unknown'} onClick={() => void run(controller.actions.abandon)}>Abandon save</Button>\n </div>\n {notice && <p role=\"alert\">{notice}</p>}\n </Card>;\n}\n",
|
|
35
29
|
"type": "registry:component",
|
|
36
30
|
"target": "components/task-list-viewer/task-list-viewer.tsx"
|
|
37
31
|
}
|
package/r/utils.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
3
|
"name": "utils",
|
|
4
4
|
"title": "Shared helpers",
|
|
5
|
-
"description": "The class merge helper `cn` and `copyText` (host callback, Clipboard API, then a selection copy that works over plain HTTP) shared by every Tailwind item, and the app shell's header slot for an embedded page's controls (`ScreenHeaderSlotContext`), installed once in components/utils/.",
|
|
5
|
+
"description": "The class merge helper `cn` and `copyText` (host callback, Clipboard API, then a selection copy that works over plain HTTP) shared by every Tailwind item, and the app shell's header slot for an embedded page's controls (`ScreenHeaderSlotContext`), installed once in components/utils/. Shared phone/media/visual-viewport hooks and nonfatal viewer storage helpers live here too.",
|
|
6
6
|
"dependencies": [
|
|
7
7
|
"clsx@^2.1.1",
|
|
8
8
|
"tailwind-merge@^3.7.0",
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
},
|
|
18
18
|
{
|
|
19
19
|
"path": "registry/utils/utils.ts",
|
|
20
|
-
"content": "import { clsx } from 'clsx';\nimport type { ClassValue } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\
|
|
20
|
+
"content": "import { clsx } from 'clsx';\nimport type { ClassValue } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\nexport { PHONE_BELOW } from './use-media';\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",
|
|
21
21
|
"type": "registry:lib",
|
|
22
22
|
"target": "components/utils/utils.ts"
|
|
23
23
|
},
|
|
@@ -26,6 +26,18 @@
|
|
|
26
26
|
"content": "// Where an embedded page puts its own header controls when a host header already exists. The app shell (`app-shell`) provides the\n// element of its screen header to the screen on show; `AgentWorkspace layout=\"embedded\"` (`pi-app`) renders its bar's controls there\n// (a React portal), so the page has one header row. Null outside a shell screen on show: the page keeps its own bar. Host concern: Pi has\n// no layout. It lives here because both items depend on `utils` and neither imports the other.\nimport { createContext, useContext } from 'react';\n\nexport const ScreenHeaderSlotContext = createContext<HTMLElement | null>(null);\n\n/** The host header's slot for the calling screen's controls, or null when there is none. */\nexport const useScreenHeaderSlot = (): HTMLElement | null => useContext(ScreenHeaderSlotContext);\n",
|
|
27
27
|
"type": "registry:lib",
|
|
28
28
|
"target": "components/utils/screen-header-slot.ts"
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"path": "registry/utils/use-media.ts",
|
|
32
|
+
"content": "'use client';\n\nimport { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from 'react';\nimport type { RefObject } from 'react';\n\n/** Phone layout is strictly below 768 CSS pixels, independently of pointer type. */\nexport const PHONE_BELOW = 768;\nexport const PHONE_QUERY = `(width < ${PHONE_BELOW}px)`;\nconst serverMatches = () => false;\n\nexport function useMedia(query: string): boolean {\n const media = useMemo(() => globalThis.matchMedia?.(query) ?? globalThis.window?.matchMedia?.(query), [query]);\n const subscribe = useCallback((notify: () => void) => {\n media?.addEventListener('change', notify);\n return () => media?.removeEventListener('change', notify);\n }, [media]);\n const snapshot = useCallback(() => media?.matches === true, [media]);\n return useSyncExternalStore(subscribe, snapshot, serverMatches);\n}\n\nexport const usePhone = (): boolean => useMedia(PHONE_QUERY);\n\nexport interface VisualViewportBox { readonly top: number; readonly height: number }\n\nfunction useVisualViewportBox(enabled: boolean): VisualViewportBox | null {\n const [box, setBox] = useState<VisualViewportBox | null>(null);\n useEffect(() => {\n const viewport = globalThis.window?.visualViewport ?? globalThis.visualViewport;\n if (!enabled || !viewport) return;\n const read = () => {\n if (viewport.scale > 1.01) return;\n const next = { top: viewport.offsetTop, height: viewport.height };\n setBox(previous => previous && previous.top === next.top && previous.height === next.height ? previous : next);\n };\n read();\n viewport.addEventListener('resize', read);\n viewport.addEventListener('scroll', read);\n return () => { viewport.removeEventListener('resize', read); viewport.removeEventListener('scroll', read); };\n }, [enabled]);\n return enabled ? box : null;\n}\n\n/** Null without a visual viewport; pinch zoom keeps the last unzoomed box. */\nexport const useVisualViewport = (): VisualViewportBox | null => useVisualViewportBox(true);\n\n/** The chat's inset is relative to its own bottom, not necessarily the layout viewport's bottom. */\nexport function useKeyboardInset(ref: RefObject<HTMLElement | null>, enabled: boolean): void {\n const viewport = useVisualViewportBox(enabled);\n useEffect(() => {\n const element = ref.current;\n if (!enabled || !viewport || !element) return;\n const measure = () => element.style.setProperty('--composer-keyboard-inset', `${Math.max(0, element.getBoundingClientRect().bottom - viewport.height - viewport.top)}px`);\n const observer = typeof ResizeObserver === 'undefined' ? undefined : new ResizeObserver(measure);\n observer?.observe(element);\n measure();\n return () => { observer?.disconnect(); element.style.removeProperty('--composer-keyboard-inset'); };\n }, [ref, enabled, viewport]);\n}\n",
|
|
33
|
+
"type": "registry:hook",
|
|
34
|
+
"target": "components/utils/use-media.ts"
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
"path": "registry/utils/persisted.ts",
|
|
38
|
+
"content": "export type Persistence = 'session' | 'local';\n\n/** Viewer conveniences only: browser policy can deny the storage object or an individual operation. */\nexport function storageFor(persist: Persistence = 'session'): Storage | undefined {\n try { return persist === 'local' ? globalThis.localStorage ?? globalThis.window?.localStorage : globalThis.sessionStorage ?? globalThis.window?.sessionStorage; }\n catch { return undefined; }\n}\n\nexport function readPersisted(key: string, persist: Persistence = 'session'): string | null {\n try { return storageFor(persist)?.getItem(key) ?? null; }\n catch { return null; }\n}\n\nexport function writePersisted(key: string, value: string | null, persist: Persistence = 'session'): void {\n try {\n const storage = storageFor(persist);\n if (value === null) storage?.removeItem(key);\n else storage?.setItem(key, value);\n } catch { /* persistence is a viewer convenience */ }\n}\n\nexport function readPersistedJson(key: string, persist: Persistence = 'session'): unknown {\n try { return JSON.parse(readPersisted(key, persist) ?? 'null'); }\n catch { return null; }\n}\n\nexport function writePersistedJson(key: string, value: unknown, persist: Persistence = 'session'): void {\n try { const text = JSON.stringify(value); if (text !== undefined) writePersisted(key, text, persist); }\n catch { /* malformed convenience data cannot interrupt the viewer */ }\n}\n\nexport const readFlag = (key: string): boolean => readPersisted(key) === '1';\nexport const writeFlag = (key: string, on: boolean): void => writePersisted(key, on ? '1' : null);\n",
|
|
39
|
+
"type": "registry:lib",
|
|
40
|
+
"target": "components/utils/persisted.ts"
|
|
29
41
|
}
|
|
30
42
|
],
|
|
31
43
|
"type": "registry:lib"
|