@hachej/boring-ui-kit 0.4.26 → 0.4.28
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 +15 -2
- package/bin/boring-ui-blocks.mjs +1 -1
- package/dist/chat-dictation.d.ts +27 -0
- package/dist/chat-dictation.js +71 -0
- package/dist/chat-react-session.d.ts +170 -0
- package/dist/chat-react-session.js +367 -0
- package/dist/chat-react.d.ts +2 -0
- package/dist/chat-react.js +1 -0
- package/dist/chat-text.d.ts +9 -0
- package/dist/chat-text.js +4 -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/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/package.json +9 -5
- package/r/agent-target.json +5 -5
- package/r/app-shell.json +14 -14
- package/r/board-viewer.json +7 -6
- package/r/button.json +1 -1
- package/r/dock.json +2 -2
- package/r/documents.json +12 -12
- package/r/feedback-preview.json +2 -2
- package/r/feedback.json +7 -7
- package/r/file-tree.json +4 -4
- package/r/highlights.json +3 -3
- package/r/html-viewer.json +3 -3
- package/r/looks-viewer.json +3 -2
- package/r/maquette-viewer.json +1 -1
- package/r/markdown-editor.json +3 -3
- package/r/mockup-viewer.json +1 -1
- package/r/pi-ambient.json +11 -11
- package/r/pi-app-kinds.json +10 -10
- package/r/pi-app.json +13 -13
- package/r/pi-artifacts.json +4 -4
- package/r/pi-chat-approval.json +3 -3
- package/r/pi-chat-attachments.json +1 -1
- package/r/pi-chat-markdown.json +2 -2
- package/r/pi-chat-mentions.json +1 -1
- package/r/pi-chat-pickers.json +1 -1
- package/r/pi-chat-plugins.json +11 -11
- package/r/pi-chat-question.json +2 -2
- package/r/pi-chat-slash.json +2 -2
- package/r/pi-chat-subagent.json +2 -2
- package/r/pi-chat-voice.json +5 -5
- package/r/pi-chat.json +22 -22
- package/r/pi-workspace.json +4 -4
- package/r/placed-agent.json +4 -4
- package/r/provider-setup.json +1 -1
- package/r/registry.json +193 -181
- package/r/requests-board.json +1 -1
- package/r/shared-chat.json +9 -9
- package/r/task-list-viewer.json +2 -2
- package/r/utils.json +14 -2
- package/r/version-history.json +2 -2
- package/r/viewer-code.json +2 -2
- package/r/viewer-frame.json +7 -7
- package/r/viewer-html.json +4 -4
- package/r/viewer-image.json +2 -2
- package/r/viewer-markdown.json +5 -5
- package/r/viewer-pdf.json +4 -4
- package/r/viewers.json +6 -6
- package/r/windows.json +3 -3
package/r/requests-board.json
CHANGED
package/r/shared-chat.json
CHANGED
|
@@ -4,16 +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.28",
|
|
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.
|
|
12
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.28/r/pi-chat.json",
|
|
13
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.28/r/pi-chat-plugins.json",
|
|
14
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.28/r/pi-chat-markdown.json",
|
|
15
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.28/r/pi-chat-mentions.json",
|
|
16
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.28/r/utils.json"
|
|
17
17
|
],
|
|
18
18
|
"files": [
|
|
19
19
|
{
|
|
@@ -30,19 +30,19 @@
|
|
|
30
30
|
},
|
|
31
31
|
{
|
|
32
32
|
"path": "registry/shared-chat/messages.tsx",
|
|
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 { 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",
|
|
34
34
|
"type": "registry:component",
|
|
35
35
|
"target": "components/shared-chat/messages.tsx"
|
|
36
36
|
},
|
|
37
37
|
{
|
|
38
38
|
"path": "registry/shared-chat/timeline.tsx",
|
|
39
|
-
"content": "'use client';\nimport { Children, memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';\nimport type { ReactNode, RefObject } from 'react';\nimport type { SharedChatController } from '@hachej/boring-ui-kit/shared-chat';\nimport type { MentionsConfig } from '../pi-chat/config';\nimport { MessageText } from '../pi-chat/message';\nimport { Markdown } from '../pi-chat-markdown/markdown';\nimport { PAGE_FIRST, PAGE_MORE, useRowChunks, useTranscript } from '../pi-chat/session';\nimport type { ChatPaging } from '../pi-chat/session';\nimport { TranscriptHistory } from '../pi-chat/history';\nimport { Message, MessageContent } from '@/components/ui/message';\nimport { Bubble, BubbleContent } from '@/components/ui/bubble';\nimport { Badge } from '@/components/ui/badge';\nimport { Button } from '@/components/ui/button';\nimport { MessageScroller, MessageScrollerViewport, MessageScrollerContent, MessageScrollerItem } from '@/components/ui/message-scroller';\nimport { showsAuthors, runHead } from './names';\nimport { AuthorName, BubbleTail, ClockSpacer, MessageClock, PinnedComment, QuestionCard, ReplyQuote, RequestCard, SharedMessage, SystemLines, ViaLine, clockRoom, sharedItems } from './messages';\nimport type { SharedItem } from './messages';\nimport { HostTrace } from './host-trace';\nimport { useChatText, useSharedView } from './text';\n\nexport interface TimelineApi { readonly reset: () => void; readonly reveal: (id: string) => void }\n/**\n * WhatsApp's room: bubbles hug the bottom, a day pill starts each day, host notices collapse into one small line, and a\n * « ↓ 3 new messages » pill appears below the list (never over it) while the reader is scrolled up.\n */\nexport function SharedTimeline({ controller, api, focusComposer, openFile, mentions, pendingVoice, paging }: {\n readonly controller: SharedChatController; readonly api: RefObject<TimelineApi | undefined>;\n readonly focusComposer: () => void; readonly openFile?: ((path: string) => void) | undefined; readonly mentions: MentionsConfig; readonly pendingVoice?: ReactNode; readonly paging?: ChatPaging | undefined;\n}) {\n const state = useSyncExternalStore(controller.subscribe, controller.getSnapshot, controller.getSnapshot);\n const { labels, icons } = useChatText();\n const view = useSharedView();\n const items = useStableItems(useMemo(() => sharedItems(state.shared, labels.sharedName), [state.shared, labels.sharedName]));\n const connected = state.connection.kind === 'connected';\n const transcript = useTranscript(items, paging?.first ?? PAGE_FIRST, paging?.more ?? PAGE_MORE, { state: state.history, connected: !state.disposed && connected, load: controller.loadEarlier });\n const { stick, hidden } = transcript;\n const chunks = useRowChunks(useMemo(() => hidden ? items.slice(hidden) : items, [items, hidden]));\n const root = useRef<HTMLDivElement>(null);\n const [revealing, setRevealing] = useState<string>();\n // The last message the reader has seen at the bottom; what others posted after it while scrolled up is « new ».\n const latest = state.shared.messages.at(-1)?.id;\n const [seen, setSeen] = useState(latest);\n // eslint-disable-next-line react-hooks/set-state-in-effect -- marks the latest message seen once the reader is at the bottom, after commit; settles when equal\n useEffect(() => { if (stick.isAtBottom && seen !== latest) setSeen(latest); }, [stick.isAtBottom, latest, seen]);\n // The pill's row shows only once the reader scrolls up, so loading a room (which scrolls down) never shifts the list under a tap.\n const lastTop = useRef(0);\n const [scrolledUp, setScrolledUp] = useState(false);\n // eslint-disable-next-line react-hooks/set-state-in-effect -- reaching the bottom hides the pill's row once, after commit\n useEffect(() => { if (stick.isAtBottom) setScrolledUp(false); }, [stick.isAtBottom]);\n const seenAt = state.shared.messages.findIndex(message => message.id === seen);\n const unseen = stick.isAtBottom ? 0 : state.shared.messages.slice(seenAt + 1).filter(message => message.author !== state.shared.me.id).length;\n // The reader's own new message lands in view, wherever they were: on Send it is drawn at once (pending), and Send's own scroll\n // runs before that render. Its confirmation (same clientId) is the same message and does not scroll again.\n const mine = state.shared.messages.findLast(message => message.author === state.shared.me.id);\n const mineKey = mine ? mine.clientId ?? mine.id : undefined;\n const lastMine = useRef(mineKey);\n useLayoutEffect(() => {\n if (mineKey === lastMine.current) return;\n const fresh = lastMine.current !== undefined || mine?.pending;\n lastMine.current = mineKey;\n if (fresh && mine === state.shared.messages.at(-1)) { transcript.reset(); void stick.scrollToBottom('instant'); }\n // eslint-disable-next-line react-hooks/exhaustive-deps -- scrolls only when the reader's own latest message changes, not on every message or transcript object\n }, [mineKey]);\n useEffect(() => bindApi(api, { reset: () => { transcript.reset(); void stick.scrollToBottom('instant'); }, reveal: setRevealing }), [api, transcript, stick]);\n useEffect(() => {\n if (!revealing) return;\n const key = items.find(item => item.message.id === revealing || item.system?.some(line => line.id === revealing))?.key;\n if (key) {\n transcript.revealTo(key);\n const frame = requestAnimationFrame(() => {\n const parent = [...(root.current?.querySelectorAll<HTMLElement>('[data-shared-message]') ?? [])].find(element => element.dataset.sharedMessage === revealing);\n if (parent) { parent.scrollIntoView({ block: 'center', behavior: 'instant' }); setRevealing(undefined); }\n });\n return () => cancelAnimationFrame(frame);\n }\n if (state.history.kind === 'loading') return;\n if (state.history.kind === 'ready' && state.history.hasMore) void controller.loadEarlier().catch(() => setRevealing(undefined));\n // A message that is neither loaded nor further back in history cannot be revealed.\n // eslint-disable-next-line react-hooks/set-state-in-effect -- an unreachable target is dropped once, after commit\n else setRevealing(undefined);\n // eslint-disable-next-line react-hooks/exhaustive-deps -- runs per target, rows and history page; the transcript object and controller are rebuilt or stable and must not re-trigger a reveal\n }, [revealing, items, hidden, state.history]);\n const hasContent = items.length > 0 || Children.count(pendingVoice) > 0;\n // Names and avatars only where they tell people apart: two or more members besides the reader.\n const multi = showsAuthors(state.shared.members, state.shared.me.id, view.presence);\n const focus = useRef(focusComposer);\n useLayoutEffect(() => { focus.current = focusComposer; });\n const reply = useCallback((id: string) => { controller.setReplyTo(id); focus.current(); }, [controller]);\n const open = useRef(openFile);\n useLayoutEffect(() => { open.current = openFile; });\n const hasOpen = Boolean(openFile);\n const stableOpen = useMemo(() => hasOpen ? (path: string) => open.current?.(path) : undefined, [hasOpen]);\n return <MessageScroller>\n <MessageScrollerViewport ref={node => { root.current = node; stick.scrollRef(node); }} data-testid=\"transcript-scroll\" aria-label={labels.messages} className=\"min-h-0 flex-1 scrollbar-thumb-border scrollbar-track-transparent\"\n onScroll={event => { const top = event.currentTarget.scrollTop; if (top < lastTop.current - 4) setScrolledUp(true); lastTop.current = top; }}>\n {items.length > 0 && (hidden > 0 || state.history.kind === 'loading' || state.history.kind === 'error' || state.history.kind === 'ready' && state.history.hasMore) && <TranscriptHistory history={state.history} connected={!state.disposed && connected} onLoad={transcript.reveal} sentinelRef={transcript.sentinelRef} hidden={hidden} loaded={transcript.loaded} />}\n <div data-testid=\"transcript-bottom\" className=\"mx-auto flex min-h-full w-full max-w-[720px] flex-col justify-end px-4 pt-4 pb-3 sm:px-6\">\n <MessageScrollerContent aria-label={labels.messages} aria-live=\"polite\" data-testid=\"transcript\" className=\"h-auto min-h-0 gap-0 [&>[data-local-voice]]:mt-3\">\n {chunks.map((run, index) => <TimelineChunk key={run[0]!.key} items={run} top={index === 0} multi={multi} controller={controller} mentions={mentions} openFile={stableOpen} onReply={reply} onReveal={setRevealing} />)}\n {pendingVoice}\n </MessageScrollerContent>\n </div>\n </MessageScrollerViewport>\n {hasContent && !stick.isAtBottom && scrolledUp && <div data-testid=\"transcript-navigation\" className=\"flex shrink-0 items-center justify-center py-1.5\">\n <Button variant=\"secondary\" size=\"sm\" data-testid=\"jump-latest\" data-unseen={unseen || undefined} aria-label={unseen ? labels.sharedNewMessages(unseen) : labels.jumpToLatest} title={labels.jumpToLatest} className=\"max-sm:min-h-11 pointer-coarse:min-h-11\" onClick={() => api.current?.reset()}>\n <icons.jumpToLatest data-icon=\"inline-start\" aria-hidden=\"true\" />{unseen ? labels.sharedNewMessages(unseen) : labels.jumpToLatest}</Button>\n </div>}\n </MessageScroller>;\n}\n\n/** Same item, field by field (the quote is rebuilt each pass, so it compares by what it points at). */\nconst sameItem = (a: SharedItem, b: SharedItem) => a.key === b.key && a.message === b.message && a.author === b.author && a.own === b.own && a.first === b.first\n && a.lastItem === b.lastItem && a.separator === b.separator && a.showSpinner === b.showSpinner && a.latestCard === b.latestCard\n && a.quote?.message === b.quote?.message && a.quote?.author === b.quote?.author\n && (a.system === b.system || (a.system?.length === b.system?.length && a.system!.every((line, index) => line === b.system![index])));\n/**\n * Keeps the previous object of every item that did not change, so a page loaded above, a delta or a new message renders only the\n * rows it touches (rows and their runs are memoized by identity).\n */\nfunction useStableItems(next: readonly SharedItem[]): readonly SharedItem[] {\n // A derived identity cache, read and replaced inside the memo only: the same items always give the same objects.\n const previous = useRef(new Map<string, SharedItem>());\n return useMemo(() => {\n // eslint-disable-next-line react-hooks/refs -- reuses the cached object of an unchanged item; no setState, no render loop\n const items = next.map(item => { const before = previous.current.get(item.key); return before && sameItem(before, item) ? before : item; });\n // eslint-disable-next-line react-hooks/refs -- stores the items for the next pass; a cache write, not state, so it triggers no render\n previous.current = new Map(items.map(item => [item.key, item]));\n return items;\n }, [next]);\n}\n\n/** Hands the timeline's controls to the host's ref until this timeline unmounts or rebinds. */\nfunction bindApi(api: RefObject<TimelineApi | undefined>, value: TimelineApi) {\n api.current = value;\n return () => { if (api.current === value) api.current = undefined; };\n}\n\ntype RowProps = {\n readonly multi: boolean; readonly controller: SharedChatController; readonly mentions: MentionsConfig;\n readonly openFile: ((path: string) => void) | undefined; readonly onReply: (id: string) => void; readonly onReveal: (id: string) => void;\n};\n/** A run of timeline rows as one scroller item (`useRowChunks`). Memoized, like each row: it renders again only when its own items change. */\nconst TimelineChunk = memo(function TimelineChunk({ items, top, ...props }: RowProps & { readonly items: readonly SharedItem[]; readonly top: boolean }) {\n return <MessageScrollerItem messageId={items[0]!.key} data-transcript-chunk=\"\" className=\"flex flex-col [content-visibility:visible] [gap:inherit]\">\n {items.map((item, index) => <TimelineRow key={item.key} item={item} top={top && index === 0} {...props} />)}\n </MessageScrollerItem>;\n});\nconst TimelineRow = memo(function TimelineRow({ item, top, multi, controller, mentions, openFile, onReply, onReveal }: RowProps & { readonly item: SharedItem; readonly top: boolean }) {\n const { labels } = useChatText();\n const view = useSharedView();\n const context = useMemo(() => ({ pieces: { mentions: true, skills: [], renderMention: mentions.renderSent }, openFile }), [mentions.renderSent, openFile]);\n const renderMention = (path: string, text: string) => mentions.renderSent?.(path) ?? text;\n const bubble = () => {\n const { message, author, own, first } = item;\n const person = own || author.kind !== 'agent' || message.via !== undefined;\n const headKind = runHead({ first, own, authors: multi, via: message.via });\n const head = headKind === 'author' ? <AuthorName member={author} via={message.via} /> : undefined;\n const traced = view.steps !== 'hidden' && !message.via && author.kind === 'agent' && Boolean(message.streaming || message.steps?.length);\n // Who spoke (and via what) sits above the group's first bubble, outside it: the bubble keeps its width. My own messages carry no name;\n // one posted through an outside agent says so on my side.\n const via = headKind === 'via' && message.via ? <ViaLine via={message.via} className=\"ms-auto block w-fit max-w-full pb-0.5 pe-1\" /> : undefined;\n const live = traced && Boolean(message.streaming);\n const trace = traced ? <HostTrace participant={author.name} steps={message.steps ?? []} streaming={Boolean(message.streaming)} active={item.showSpinner} /> : undefined;\n const quote = message.replyTo ? <ReplyQuote targetId={message.replyTo} quote={item.quote} onReveal={onReveal} /> : undefined;\n if (message.question) return <article data-row-id={item.key} data-role=\"event\" className=\"flex w-full min-w-0 flex-col\">\n {head}{via}\n <Bubble variant=\"muted\" align=\"start\" data-testid=\"question-bubble\">\n {first && <BubbleTail side=\"start\" />}\n <BubbleContent className=\"relative min-w-52\">{quote}\n <QuestionCard message={message} answer={controller.answer} onReply={onReply} />\n </BubbleContent>\n </Bubble></article>;\n if (message.card) return <article data-row-id={item.key} data-role=\"event\" className=\"flex w-full min-w-0 flex-col\">\n {head}{via}\n <Bubble variant={own ? 'default' : 'muted'} align={own ? 'end' : 'start'} data-testid=\"card-bubble\">\n {first && <BubbleTail side={own ? 'end' : 'start'} />}\n <BubbleContent className=\"relative min-w-52\">{quote}\n {message.card.kind === 'pin' ? <PinnedComment card={message.card} own={own} at={message.at} /> : <RequestCard card={message.card} action={controller.action} at={message.at} own={own} />}\n </BubbleContent>\n </Bubble></article>;\n return <article data-row-id={item.key} data-role={person ? 'user' : 'assistant'} data-streaming={message.streaming ? 'true' : undefined} data-pending={message.pending ? 'true' : undefined} className=\"group/message\">{head}{via}<Message align={own ? 'end' : 'start'}><MessageContent>\n {own ? <div data-testid=\"own-message\" className=\"contents [&_[data-testid=message-mention]]:text-inherit\">\n <MessageText text={message.text ?? ''} context={context} variant=\"default\" decoration={first ? <BubbleTail side=\"end\" /> : undefined} header={quote} footer={<><ClockSpacer /><MessageClock at={message.at} tone=\"primary\" pending={message.pending} /></>} /></div>\n : view.answerStyle === 'plain' && !person ? <div data-testid=\"plain-answer\" className=\"min-w-0\">\n {quote}\n {message.text && <Markdown text={message.text} streaming={Boolean(message.streaming)} renderMention={renderMention} />}\n {traced && <HostTrace participant={author.name} steps={message.steps ?? []} streaming={Boolean(message.streaming)} active={item.showSpinner} />}\n </div>\n : <Bubble variant=\"muted\">{first && <BubbleTail side=\"start\" />}<BubbleContent className=\"relative\">\n {quote}\n {/* While the agent writes, the working line sits above the growing text and the time waits for the answer: nothing under the text moves as it grows (B5). */}\n {live && trace}\n {message.text && <div className={traced ? undefined : clockRoom}><Markdown text={message.text} streaming={Boolean(message.streaming)} renderMention={renderMention} /></div>}\n {traced ? <>\n {!live && trace}\n {!live && <div className=\"flex justify-end\"><MessageClock at={message.at} tone=\"muted\" floating={false} /></div>}\n </> : <MessageClock at={message.at} tone=\"muted\" />}\n </BubbleContent></Bubble>}\n </MessageContent></Message></article>;\n };\n const gap = top || item.separator ? '' : item.system || item.first ? ' mt-3' : ' mt-0.5';\n return <div data-row-key={item.key} data-group-start={item.first ? 'true' : undefined} className={`min-w-0 shrink-0${gap}`}>\n {item.separator && <div data-testid=\"day-separator\" className={`flex justify-center pb-3${top ? '' : ' pt-4'}`}><Badge variant=\"secondary\"><time dateTime={item.message.at} title={labels.sharedTimestamp(item.message.at)}>{labels.sharedDay(item.message.at)}</time></Badge></div>}\n {item.system ? <SystemLines lines={item.system} />\n : <SharedMessage shared={item} remove={controller.remove} canRemove={controller.canRemove} openFile={openFile} onReply={onReply} onReveal={onReveal}>{bubble()}</SharedMessage>}\n </div>;\n});\n",
|
|
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",
|
|
40
40
|
"type": "registry:component",
|
|
41
41
|
"target": "components/shared-chat/timeline.tsx"
|
|
42
42
|
},
|
|
43
43
|
{
|
|
44
44
|
"path": "registry/shared-chat/host-trace.tsx",
|
|
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-
|
|
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",
|
|
46
46
|
"type": "registry:component",
|
|
47
47
|
"target": "components/shared-chat/host-trace.tsx"
|
|
48
48
|
},
|
package/r/task-list-viewer.json
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
"title": "Consumer task-list viewer",
|
|
5
5
|
"description": "A consumer-defined task-list schema and controller over the shared text buffer, with a restylable borrowed React renderer.",
|
|
6
6
|
"dependencies": [
|
|
7
|
-
"@hachej/boring-ui-kit@^0.4.
|
|
8
|
-
"@hachej/boring-files@^0.4.
|
|
7
|
+
"@hachej/boring-ui-kit@^0.4.28",
|
|
8
|
+
"@hachej/boring-files@^0.4.28",
|
|
9
9
|
"react@^19.0.0",
|
|
10
10
|
"react-dom@^19.0.0",
|
|
11
11
|
"zod@^4.6.5"
|
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"
|
package/r/version-history.json
CHANGED
|
@@ -4,11 +4,11 @@
|
|
|
4
4
|
"title": "Version history rows",
|
|
5
5
|
"description": "Shared view-only version rows and menu metadata with relative time, provenance and Current. No file reads, publication or restoration.",
|
|
6
6
|
"dependencies": [
|
|
7
|
-
"@hachej/boring-files@^0.4.
|
|
7
|
+
"@hachej/boring-files@^0.4.28",
|
|
8
8
|
"react@^19.0.0"
|
|
9
9
|
],
|
|
10
10
|
"registryDependencies": [
|
|
11
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
11
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.28/r/button.json"
|
|
12
12
|
],
|
|
13
13
|
"files": [
|
|
14
14
|
{
|
package/r/viewer-code.json
CHANGED
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
"lucide-react@^1.51.0"
|
|
9
9
|
],
|
|
10
10
|
"registryDependencies": [
|
|
11
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
12
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
11
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.28/r/viewer-frame.json",
|
|
12
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.28/r/pi-chat.json"
|
|
13
13
|
],
|
|
14
14
|
"files": [
|
|
15
15
|
{
|
package/r/viewer-frame.json
CHANGED
|
@@ -4,15 +4,15 @@
|
|
|
4
4
|
"title": "Viewer frame",
|
|
5
5
|
"description": "The standard viewer top bar every viewer draws in (title, status, viewer controls, Refresh, Share, Copy, Download, Open in new tab, Close, overflow menu), its window labels, download, share and media helpers and the sandboxed interactive HTML frame. The viewers' styles live here. Each viewer is its own item (viewer-markdown, viewer-html, viewer-image, viewer-pdf, viewer-code) with a cheap kind module and a view loaded on first open.",
|
|
6
6
|
"dependencies": [
|
|
7
|
-
"@hachej/boring-ui-kit@^0.4.
|
|
7
|
+
"@hachej/boring-ui-kit@^0.4.28",
|
|
8
8
|
"react@^19.0.0",
|
|
9
9
|
"react-dom@^19.0.0",
|
|
10
10
|
"lucide-react@^1.51.0"
|
|
11
11
|
],
|
|
12
12
|
"registryDependencies": [
|
|
13
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
14
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
15
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
13
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.28/r/utils.json",
|
|
14
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.28/r/button.json",
|
|
15
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.28/r/version-history.json"
|
|
16
16
|
],
|
|
17
17
|
"files": [
|
|
18
18
|
{
|
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
},
|
|
48
48
|
{
|
|
49
49
|
"path": "registry/viewer-frame/viewer-frame.tsx",
|
|
50
|
-
"content": "'use client';\n\nimport { createContext, useCallback, useContext, useEffect, useLayoutEffect, useRef, useState } from 'react';\nimport type { ReactNode } from 'react';\nimport { ArrowLeftIcon, CheckIcon, CircleIcon, CopyIcon, DownloadIcon, ExternalLinkIcon, RefreshCwIcon } from 'lucide-react';\nimport { Button } from '@/components/ui/button';\nimport { Toggle } from '@/components/ui/toggle';\nimport { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';\nimport { Badge } from '@/components/ui/badge';\nimport { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';\nimport { Textarea } from '@/components/ui/textarea';\nimport { DropdownMenuSeparator } from '@/components/ui/dropdown-menu';\nimport { ActionButton, actionMenuItem, headerActions, menuActions } from '../button/actions';\nimport type { BlockAction } from '../button/actions';\nimport { ViewerActions } from './menu';\nimport type { ViewerMenuItem } from './menu';\nimport { useViewerText, useViewerWindow } from './viewer-window';\nimport type { ViewerIcons, ViewerLabels } from './viewer-window';\nimport { cn } from '../utils/utils';\nimport type { ManualCopyError } from '../utils/utils';\nimport type { ViewerShare } from './share';\n\nexport type { ViewerShare, ViewerShareRequest, ViewerShareResult } from './share';\nexport { ViewerWindowProvider, defaultViewerIcons, defaultViewerLabels, useViewerText } from './viewer-window';\nexport type { ViewerIcons, ViewerLabels, ViewerWindow } from './viewer-window';\n\nexport type ViewerTone = 'neutral' | 'success' | 'warning' | 'danger';\n/**\n * `kind` says what the status is whatever its (translated) label: the bar hides a plain `saved` (unless `showSavedStatus`) and shows\n * `read-only` as a fact. The bar shows a dot in the default shadcn roles: `success` primary, `warning` and `neutral` muted-foreground, `danger` destructive. Only a\n * `danger` status also shows its words, because the person must act on it; the others keep the label in the tooltip and for screen readers.\n */\nexport interface ViewerStatus { readonly label: string; readonly tone?: ViewerTone; readonly kind?: 'saved' | 'read-only' | undefined }\n\nexport interface ViewerFrameProps {\n /** What is shown, for example the file name. */\n readonly title: string;\n /** Type, version, path, size: short facts under the title. */\n readonly subtitle?: ReactNode;\n /** Saved, Unsaved, Saving, Changed elsewhere, Read-only. */\n readonly status?: ViewerStatus | undefined;\n /** Keep a quiet saved acknowledgement visible, for autosaving editors. */\n readonly showSavedStatus?: boolean | undefined;\n /** Opaque identifiers handed to `onShare` so the host can build a link to this exact thing. */\n readonly target?: unknown;\n readonly revision?: string | undefined;\n /** Leading header content, for example Back before the file title. */\n readonly headerStart?: ReactNode;\n /** Viewer-specific controls that stay visible: a mode toggle, Save while there are edits, zoom steps, a version switcher when there are several. */\n readonly controls?: ReactNode;\n /**\n * The one control that matters most here (Comment on a version to try, for example), kept in the bar at every width. With the compact\n * header a phone folds `controls` into the \"…\" menu; this one stays beside it, so the main action is one tap away. Test id `<testId>-main`.\n */\n readonly mainControl?: ReactNode;\n /** Viewer-specific secondary actions, listed in the \"…\" menu after the standard ones. */\n readonly menu?: readonly ViewerMenuItem[];\n /** Viewer-specific native dropdown groups, before the standard secondary actions. */\n readonly menuContent?: ReactNode;\n /**\n * The host's actions for this bar, after the window's (`ViewerWindow.actions`): `header` ones as buttons beside Share, `menu` ones at the\n * end of the \"…\" menu. Test ids `<testId>-<id>`.\n */\n readonly actions?: readonly BlockAction[] | undefined;\n /** This bar's words and icons, over the window's and the defaults (`defaultViewerLabels`, `defaultViewerIcons`). */\n readonly labels?: Partial<ViewerLabels> | undefined;\n readonly icons?: Partial<ViewerIcons> | undefined;\n /** Each standard action appears only when its handler is supplied. Share is always visible; Reload, Copy, Download and Open in new tab live in the \"…\" menu. */\n readonly onRefresh?: () => unknown;\n readonly onShare?: ViewerShare;\n readonly onCopy?: () => unknown;\n readonly onDownload?: () => unknown;\n readonly onOpenInNewTab?: () => unknown;\n readonly onClose?: () => unknown;\n /** Prefix of the `data-testid` of the bar and its actions (`viewer-share`, `viewer-more`, and `viewer-refresh` for the menu's Reload, ...). */\n readonly testId?: string;\n readonly titleTestId?: string;\n /** Quiet one-row bar with secondary actions in the menu. False retains the former visible Share/fullscreen/close layout. */\n readonly compactHeader?: boolean;\n readonly className?: string;\n readonly children?: ReactNode;\n}\n\n\n/** Narrow panes drop optional controls. */\nconst TIGHT_WIDTH = { touch: 440, pointer: 340 };\nconst touchScreen = () => typeof matchMedia === 'function' && matchMedia('(max-width: 639px), (pointer: coarse)').matches;\n\n/** How much room the bar has, for a pane that wants to drop optional controls (the zoom percentage, for example) on a narrow panel. */\nexport interface ViewerBarSpace { readonly tight: boolean }\nconst ViewerBarContext = createContext<ViewerBarSpace>({ tight: false });\nexport const useViewerBar = (): ViewerBarSpace => useContext(ViewerBarContext);\nconst NOTICE_MS = 2600;\n\n/**\n * One choice of a mode toggle, for example Rich | Source. Icon only: `label` is the accessible name and the tooltip, `text` the short word\n * for hosts that want it (shown when there is no icon). A disabled choice shows `reason` as its tooltip.\n */\nexport interface ViewerToggleOption<T extends string> { readonly id: T; readonly label: string; readonly text: string; readonly icon?: ReactNode; readonly disabled?: boolean; readonly reason?: string | undefined; readonly testId?: string }\n\n/** A segmented control over the consumer's outlined ToggleGroup: the pressed segment is filled with a full-contrast icon, the others muted. */\nexport function ViewerToggle<T extends string>({ label, value, options, onChange }: { readonly label: string; readonly value: T; readonly options: readonly ViewerToggleOption<T>[]; readonly onChange: (id: T) => void }) {\n return <ToggleGroup aria-label={label} variant=\"outline\" size=\"sm\" spacing={1} value={[value]} onValueChange={values => {\n const selected = options.find(option => option.id === values[0] && !option.disabled);\n if (selected && selected.id !== value) onChange(selected.id);\n }}>\n {options.map(option => <ToggleGroupItem key={option.id} value={option.id} aria-label={option.label} title={option.disabled && option.reason ? option.reason : option.label} disabled={option.disabled}\n className=\"disabled:pointer-events-auto disabled:cursor-not-allowed\" data-testid={option.testId} data-mode={option.id}>\n <span className={cn('inline-flex items-center', value !== option.id && 'text-muted-foreground')}>{option.icon ?? option.text}</span></ToggleGroupItem>)}\n </ToggleGroup>;\n}\n\n/** The copy helper throws this when the browser allowed no automatic copy at all; the person copies by hand instead. */\nconst needsManualCopy = (error: unknown): error is ManualCopyError => (error as { name?: string } | null)?.name === 'ManualCopyError' && typeof (error as { text?: unknown }).text === 'string';\n\n/** Last resort for Copy and Share: the text, already selected, so Ctrl/Cmd+C or the long-press menu finishes the job. */\nfunction ManualCopy({ testId, link, text, onClose, labels }: { readonly testId: string; readonly link: boolean; readonly text: string; readonly onClose: () => void; readonly labels: ViewerLabels }) {\n const field = useRef<HTMLTextAreaElement>(null);\n return <Dialog open onOpenChange={open => { if (!open) onClose(); }}>\n <DialogContent showCloseButton={false} className=\"boring-viewer\" data-testid={`${testId}-manual-copy`} initialFocus={field}>\n <DialogHeader><DialogTitle>{labels.manualCopy(link)}</DialogTitle></DialogHeader>\n <Textarea ref={field} readOnly rows={Math.min(6, Math.max(2, Math.ceil(text.length / 48)))} value={text} data-testid={`${testId}-manual-copy-text`} onFocus={event => event.currentTarget.select()} />\n <div className=\"flex justify-end\"><Button variant=\"ghost\" size=\"sm\" data-testid={`${testId}-manual-copy-close`} onClick={onClose}>{labels.done}</Button></div>\n </DialogContent>\n </Dialog>;\n}\n\n/** Dot colours: default shadcn roles only. */\nconst DOTS: Record<ViewerTone, string> = {\n neutral: 'fill-muted-foreground',\n success: 'fill-primary',\n warning: 'fill-muted-foreground',\n danger: 'fill-destructive',\n};\n\n/** The save status as a coloured dot; the label is the tooltip and the screen-reader text, and is shown only when the person must act (`danger`). */\nfunction StatusMark({ status, testId }: { readonly status: ViewerStatus; readonly testId: string }) {\n const tone = status.tone ?? 'neutral';\n return <span role=\"status\" aria-label={status.label} title={status.label} data-testid={`${testId}-status`} data-tone={tone} data-kind={status.kind}\n className=\"inline-flex h-6 min-w-6 shrink-0 cursor-default items-center justify-center gap-1.5 text-xs\">\n <CircleIcon aria-hidden=\"true\" strokeWidth={0} className={cn('size-2 shrink-0', DOTS[tone])} />\n <span data-testid={`${testId}-status-text`} className={tone === 'danger' ? 'max-w-48 truncate text-destructive' : 'sr-only'}>{status.label}</span>\n </span>;\n}\n\n/** The one top bar of every viewer, always a single row: title and facts, viewer controls, a status dot when something needs attention, Share, full screen, Close and the \"…\" menu. */\nexport function ViewerFrame({ title, subtitle, status, showSavedStatus = false, target, revision, headerStart, controls, mainControl, menu, menuContent, actions, labels: ownLabels, icons: ownIcons, onRefresh, onShare, onCopy, onDownload, onOpenInNewTab, onClose, testId = 'viewer', titleTestId, compactHeader = true, className, children }: ViewerFrameProps) {\n const bar = useRef<HTMLElement>(null);\n const [space, setSpace] = useState<ViewerBarSpace>({ tight: false });\n const [busy, setBusy] = useState<'refresh' | 'share' | undefined>();\n const [notice, setNotice] = useState<{ readonly text: string; readonly tone: ViewerTone } | undefined>();\n const [copied, setCopied] = useState(false);\n const [manual, setManual] = useState<{ readonly text: string; readonly link: boolean } | undefined>();\n const host = useViewerWindow();\n const { labels, icons } = useViewerText(ownLabels, ownIcons);\n useLayoutEffect(() => {\n const element = bar.current;\n if (!element || typeof ResizeObserver === 'undefined') return;\n const measure = () => {\n const width = element.getBoundingClientRect().width;\n const next = { tight: width > 0 && width < (touchScreen() ? TIGHT_WIDTH.touch : TIGHT_WIDTH.pointer) };\n setSpace(now => now.tight === next.tight ? now : next);\n };\n measure();\n const observer = new ResizeObserver(measure);\n observer.observe(element);\n return () => observer.disconnect();\n }, [host?.renderHeader]);\n useEffect(() => {\n if (!notice) return;\n const timer = setTimeout(() => setNotice(undefined), NOTICE_MS);\n return () => clearTimeout(timer);\n }, [notice]);\n\n // A result after unmount updates nothing: React ignores a state update on an unmounted component.\n const guarded = useCallback(async (kind: 'refresh' | 'share', run: () => unknown, failed: string) => {\n setBusy(kind);\n setNotice(undefined);\n try { return await run(); }\n catch (error) { if (needsManualCopy(error)) setManual({ text: error.text, link: kind === 'share' }); else setNotice({ text: failed, tone: 'danger' }); return undefined; }\n finally { setBusy(undefined); }\n }, []);\n const refresh = () => { if (onRefresh) void guarded('refresh', onRefresh, labels.refreshFailed); };\n const share = () => {\n if (!onShare) return;\n void guarded('share', () => onShare({ title, target, revision }), labels.shareFailed).then(result => {\n if (result === 'copied') setNotice({ text: labels.linkCopied, tone: 'success' });\n else if (result === 'shared') setNotice({ text: labels.shared, tone: 'success' });\n });\n };\n const copy = () => {\n void Promise.resolve().then(onCopy).then(() => { setCopied(true); setNotice({ text: labels.copied, tone: 'success' }); setTimeout(() => setCopied(false), 1500); })\n .catch(error => { if (needsManualCopy(error)) setManual({ text: error.text, link: false }); else setNotice({ text: labels.copyFailed, tone: 'danger' }); });\n };\n const run = (action: (() => unknown) | undefined) => { void Promise.resolve().then(action).catch(() => setNotice({ text: labels.actionFailed, tone: 'danger' })); };\n // The host's actions: the window's (every viewer in the panel), then this frame's. Each runs through `run`, so a failure shows as a notice.\n const hostActions = [...host?.actions ?? [], ...actions ?? []].map(action => ({ ...action, onSelect: () => run(action.onSelect) }));\n\n const reading = status?.kind === 'read-only' || status?.label === labels.readOnly;\n const menuItems: ViewerMenuItem[] = ([\n ...(compactHeader && onShare ? [{ id: 'share', label: labels.share, icon: <icons.share className=\"size-4\" />, onSelect: share }] : []),\n ...(compactHeader && host?.onFullscreenChange ? [{ id: 'fullscreen', label: host.fullscreen ? labels.exitFullscreen : labels.enterFullscreen, icon: <icons.enterFullscreen className=\"size-4\" />, onSelect: () => host.onFullscreenChange!(!host.fullscreen) }] : []),\n ...(onRefresh ? [{ id: 'refresh', separatorBefore: true, label: labels.reload, icon: <RefreshCwIcon className=\"size-4\" />, disabled: busy === 'refresh', onSelect: refresh }] : []),\n ...(onCopy ? [{ id: 'copy', label: labels.copy, icon: copied ? <CheckIcon className=\"size-4\" /> : <CopyIcon className=\"size-4\" />, onSelect: copy }] : []),\n ...(onDownload ? [{ id: 'download', label: labels.download, icon: <DownloadIcon className=\"size-4\" />, onSelect: () => run(onDownload) }] : []),\n ...(onOpenInNewTab ? [{ id: 'open', label: labels.openInNewTab, icon: <ExternalLinkIcon className=\"size-4\" />, onSelect: () => run(onOpenInNewTab) }] : []),\n ...(menu ?? []).map((item, index) => ({ ...item, separatorBefore: index === 0 || item.separatorBefore })),\n ...menuActions(hostActions).map((action, index) => ({ ...actionMenuItem(action, testId), separatorBefore: index === 0 })),\n ] as ViewerMenuItem[]).map(entry => ({ ...entry, testId: entry.testId ?? `${testId}-${entry.id}` }));\n\n const stacked = !compactHeader && host?.headerLayout === 'stacked';\n const toolbar = controls && <div data-testid={`${testId}-controls`} className={cn('flex shrink-0 items-center gap-1', compactHeader && 'max-[641px]:hidden', stacked && 'flex-wrap px-3 py-2')}>{controls}</div>;\n const titlebar = <>\n {compactHeader && !headerStart && onClose && <Button variant=\"ghost\" size=\"icon-sm\" aria-label={labels.close} title={labels.close} data-testid={`${testId}-close`} onClick={() => run(onClose)}><ArrowLeftIcon data-icon=\"inline-start\" className=\"min-[641px]:hidden\" aria-hidden=\"true\" /><icons.close data-icon=\"inline-start\" className=\"hidden min-[641px]:block\" aria-hidden=\"true\" /></Button>}\n {headerStart}\n <div className=\"group/title relative min-w-0 flex-1 basis-0 text-left\">\n <h2 data-testid={titleTestId ?? `${testId}-title`} title={title} className=\"m-0 truncate text-sm leading-5 font-semibold tracking-tight\">{title}</h2>\n {(subtitle || reading) && <p data-testid={`${testId}-subtitle`} className={cn('m-0 flex min-w-0 items-center gap-x-1.5 text-xs leading-4 whitespace-nowrap text-muted-foreground', compactHeader ? 'hidden group-hover/title:flex max-[641px]:hidden' : 'overflow-hidden')}>{subtitle}{subtitle && reading && <span aria-hidden=\"true\">·</span>}{reading && <span data-testid={`${testId}-readonly`}>{labels.readOnly}</span>}</p>}\n </div>\n {!stacked && toolbar}\n {mainControl && <div data-testid={`${testId}-main`} className=\"flex shrink-0 items-center\">{mainControl}</div>}\n {status && !reading && (showSavedStatus || status.kind !== 'saved' && status.label !== labels.saved) && <StatusMark status={status} testId={testId} />}\n <div className=\"flex shrink-0 items-center gap-0.5\">\n {headerActions(hostActions).map(action => <ActionButton key={action.id} action={action} testId={testId} size=\"icon-sm\" />)}\n {!compactHeader && onShare && <Button variant=\"ghost\" size=\"icon-sm\" aria-label={labels.share} title={labels.share} data-testid={`${testId}-share`} disabled={busy === 'share'} onClick={share}><icons.share data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>}\n {(menuItems.length > 0 || controls || menuContent) && <ViewerActions label={labels.moreActions} testId={`${testId}-more`} icon={<icons.more data-icon=\"inline-start\" aria-hidden=\"true\" />} items={menuItems}>{menuContent}{menuContent && menuItems.length > 0 && <DropdownMenuSeparator />}{compactHeader && controls && <div className=\"boring-viewer flex flex-wrap items-center gap-2 p-2 min-[641px]:hidden\">{controls}</div>}</ViewerActions>}\n {!compactHeader && host?.onFullscreenChange && <Toggle size=\"sm\" aria-label={host.fullscreen ? labels.exitFullscreen : labels.enterFullscreen} title={host.fullscreen ? labels.exitFullscreen : labels.enterFullscreen} data-testid={`${testId}-fullscreen`} pressed={host.fullscreen === true}\n onPressedChange={pressed => host.onFullscreenChange!(pressed)}>{host.fullscreen ? <icons.exitFullscreen data-icon=\"inline-start\" aria-hidden=\"true\" /> : <icons.enterFullscreen data-icon=\"inline-start\" aria-hidden=\"true\" />}</Toggle>}\n {(!compactHeader || headerStart) && onClose && <Button variant=\"ghost\" size=\"icon-sm\" className={compactHeader ? 'max-[641px]:hidden' : undefined} aria-label={labels.close} title={labels.close} data-testid={`${testId}-close`} onClick={() => run(onClose)}><icons.close data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>}\n </div>\n </>;\n const header = <header ref={bar} data-testid={`${testId}-bar`} data-layout={stacked ? 'stacked' : undefined}\n className={cn('relative flex shrink-0', stacked ? 'flex-col' : 'h-13 flex-nowrap items-center gap-x-1.5 px-2 sm:px-3')}>\n {stacked ? <><div data-testid={`${testId}-titlebar`} className=\"flex min-h-14 min-w-0 items-center gap-x-1.5 px-3 py-1.5\">{titlebar}</div>{toolbar}</> : titlebar}\n {notice && <Badge role=\"status\" aria-live=\"polite\" data-testid={`${testId}-notice`} data-tone={notice.tone} variant={notice.tone === 'danger' ? 'destructive' : 'secondary'}>{notice.text}</Badge>}\n {manual && <ManualCopy testId={testId} link={manual.link} text={manual.text} labels={labels} onClose={() => setManual(undefined)} />}\n </header>;\n return <ViewerBarContext.Provider value={space}>\n <section data-boring=\"viewer-frame\" data-testid={`${testId}-frame`} data-tight={space.tight || undefined}\n className={cn('boring-viewer isolate flex h-full min-h-0 min-w-0 flex-col bg-background text-foreground', className)}>\n {host?.renderHeader ? host.renderHeader(header) : header}\n <div data-testid={`${testId}-body`} className=\"flex min-h-0 min-w-0 flex-1 flex-col\">{children}</div>\n </section>\n </ViewerBarContext.Provider>;\n}\n",
|
|
50
|
+
"content": "'use client';\n\nimport { useMedia, usePhone } from '../utils/use-media';\n\nimport { createContext, useCallback, useContext, useEffect, useLayoutEffect, useRef, useState } from 'react';\nimport type { ReactNode } from 'react';\nimport { ArrowLeftIcon, CheckIcon, CircleIcon, CopyIcon, DownloadIcon, ExternalLinkIcon, RefreshCwIcon } from 'lucide-react';\nimport { Button } from '@/components/ui/button';\nimport { Toggle } from '@/components/ui/toggle';\nimport { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';\nimport { Badge } from '@/components/ui/badge';\nimport { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';\nimport { Textarea } from '@/components/ui/textarea';\nimport { DropdownMenuSeparator } from '@/components/ui/dropdown-menu';\nimport { ActionButton, actionMenuItem, headerActions, menuActions } from '../button/actions';\nimport type { BlockAction } from '../button/actions';\nimport { ViewerActions } from './menu';\nimport type { ViewerMenuItem } from './menu';\nimport { useViewerText, useViewerWindow } from './viewer-window';\nimport type { ViewerIcons, ViewerLabels } from './viewer-window';\nimport { cn } from '../utils/utils';\nimport type { ManualCopyError } from '../utils/utils';\nimport type { ViewerShare } from './share';\n\nexport type { ViewerShare, ViewerShareRequest, ViewerShareResult } from './share';\nexport { ViewerWindowProvider, defaultViewerIcons, defaultViewerLabels, useViewerText } from './viewer-window';\nexport type { ViewerIcons, ViewerLabels, ViewerWindow } from './viewer-window';\n\nexport type ViewerTone = 'neutral' | 'success' | 'warning' | 'danger';\n/**\n * `kind` says what the status is whatever its (translated) label: the bar hides a plain `saved` (unless `showSavedStatus`) and shows\n * `read-only` as a fact. The bar shows a dot in the default shadcn roles: `success` primary, `warning` and `neutral` muted-foreground, `danger` destructive. Only a\n * `danger` status also shows its words, because the person must act on it; the others keep the label in the tooltip and for screen readers.\n */\nexport interface ViewerStatus { readonly label: string; readonly tone?: ViewerTone; readonly kind?: 'saved' | 'read-only' | undefined }\n\nexport interface ViewerFrameProps {\n /** What is shown, for example the file name. */\n readonly title: string;\n /** Type, version, path, size: short facts under the title. */\n readonly subtitle?: ReactNode;\n /** Saved, Unsaved, Saving, Changed elsewhere, Read-only. */\n readonly status?: ViewerStatus | undefined;\n /** Keep a quiet saved acknowledgement visible, for autosaving editors. */\n readonly showSavedStatus?: boolean | undefined;\n /** Opaque identifiers handed to `onShare` so the host can build a link to this exact thing. */\n readonly target?: unknown;\n readonly revision?: string | undefined;\n /** Leading header content, for example Back before the file title. */\n readonly headerStart?: ReactNode;\n /** Viewer-specific controls that stay visible: a mode toggle, Save while there are edits, zoom steps, a version switcher when there are several. */\n readonly controls?: ReactNode;\n /**\n * The one control that matters most here (Comment on a version to try, for example), kept in the bar at every width. With the compact\n * header a phone folds `controls` into the \"…\" menu; this one stays beside it, so the main action is one tap away. Test id `<testId>-main`.\n */\n readonly mainControl?: ReactNode;\n /** Viewer-specific secondary actions, listed in the \"…\" menu after the standard ones. */\n readonly menu?: readonly ViewerMenuItem[];\n /** Viewer-specific native dropdown groups, before the standard secondary actions. */\n readonly menuContent?: ReactNode;\n /**\n * The host's actions for this bar, after the window's (`ViewerWindow.actions`): `header` ones as buttons beside Share, `menu` ones at the\n * end of the \"…\" menu. Test ids `<testId>-<id>`.\n */\n readonly actions?: readonly BlockAction[] | undefined;\n /** This bar's words and icons, over the window's and the defaults (`defaultViewerLabels`, `defaultViewerIcons`). */\n readonly labels?: Partial<ViewerLabels> | undefined;\n readonly icons?: Partial<ViewerIcons> | undefined;\n /** Each standard action appears only when its handler is supplied. Share is always visible; Reload, Copy, Download and Open in new tab live in the \"…\" menu. */\n readonly onRefresh?: () => unknown;\n readonly onShare?: ViewerShare;\n readonly onCopy?: () => unknown;\n readonly onDownload?: () => unknown;\n readonly onOpenInNewTab?: () => unknown;\n readonly onClose?: () => unknown;\n /** Prefix of the `data-testid` of the bar and its actions (`viewer-share`, `viewer-more`, and `viewer-refresh` for the menu's Reload, ...). */\n readonly testId?: string;\n readonly titleTestId?: string;\n /** Quiet one-row bar with secondary actions in the menu. False retains the former visible Share/fullscreen/close layout. */\n readonly compactHeader?: boolean;\n readonly className?: string;\n readonly children?: ReactNode;\n}\n\n\n/** Narrow panes drop optional controls. */\nconst TIGHT_WIDTH = { touch: 440, pointer: 340 };\n\n/** How much room the bar has, for a pane that wants to drop optional controls (the zoom percentage, for example) on a narrow panel. */\nexport interface ViewerBarSpace { readonly tight: boolean }\nconst ViewerBarContext = createContext<ViewerBarSpace>({ tight: false });\nexport const useViewerBar = (): ViewerBarSpace => useContext(ViewerBarContext);\nconst NOTICE_MS = 2600;\n\n/**\n * One choice of a mode toggle, for example Rich | Source. Icon only: `label` is the accessible name and the tooltip, `text` the short word\n * for hosts that want it (shown when there is no icon). A disabled choice shows `reason` as its tooltip.\n */\nexport interface ViewerToggleOption<T extends string> { readonly id: T; readonly label: string; readonly text: string; readonly icon?: ReactNode; readonly disabled?: boolean; readonly reason?: string | undefined; readonly testId?: string }\n\n/** A segmented control over the consumer's outlined ToggleGroup: the pressed segment is filled with a full-contrast icon, the others muted. */\nexport function ViewerToggle<T extends string>({ label, value, options, onChange }: { readonly label: string; readonly value: T; readonly options: readonly ViewerToggleOption<T>[]; readonly onChange: (id: T) => void }) {\n return <ToggleGroup aria-label={label} variant=\"outline\" size=\"sm\" spacing={1} value={[value]} onValueChange={values => {\n const selected = options.find(option => option.id === values[0] && !option.disabled);\n if (selected && selected.id !== value) onChange(selected.id);\n }}>\n {options.map(option => <ToggleGroupItem key={option.id} value={option.id} aria-label={option.label} title={option.disabled && option.reason ? option.reason : option.label} disabled={option.disabled}\n className=\"disabled:pointer-events-auto disabled:cursor-not-allowed\" data-testid={option.testId} data-mode={option.id}>\n <span className={cn('inline-flex items-center', value !== option.id && 'text-muted-foreground')}>{option.icon ?? option.text}</span></ToggleGroupItem>)}\n </ToggleGroup>;\n}\n\n/** The copy helper throws this when the browser allowed no automatic copy at all; the person copies by hand instead. */\nconst needsManualCopy = (error: unknown): error is ManualCopyError => (error as { name?: string } | null)?.name === 'ManualCopyError' && typeof (error as { text?: unknown }).text === 'string';\n\n/** Last resort for Copy and Share: the text, already selected, so Ctrl/Cmd+C or the long-press menu finishes the job. */\nfunction ManualCopy({ testId, link, text, onClose, labels }: { readonly testId: string; readonly link: boolean; readonly text: string; readonly onClose: () => void; readonly labels: ViewerLabels }) {\n const field = useRef<HTMLTextAreaElement>(null);\n return <Dialog open onOpenChange={open => { if (!open) onClose(); }}>\n <DialogContent showCloseButton={false} className=\"boring-viewer\" data-testid={`${testId}-manual-copy`} initialFocus={field}>\n <DialogHeader><DialogTitle>{labels.manualCopy(link)}</DialogTitle></DialogHeader>\n <Textarea ref={field} readOnly rows={Math.min(6, Math.max(2, Math.ceil(text.length / 48)))} value={text} data-testid={`${testId}-manual-copy-text`} onFocus={event => event.currentTarget.select()} />\n <div className=\"flex justify-end\"><Button variant=\"ghost\" size=\"sm\" data-testid={`${testId}-manual-copy-close`} onClick={onClose}>{labels.done}</Button></div>\n </DialogContent>\n </Dialog>;\n}\n\n/** Dot colours: default shadcn roles only. */\nconst DOTS: Record<ViewerTone, string> = {\n neutral: 'fill-muted-foreground',\n success: 'fill-primary',\n warning: 'fill-muted-foreground',\n danger: 'fill-destructive',\n};\n\n/** The save status as a coloured dot; the label is the tooltip and the screen-reader text, and is shown only when the person must act (`danger`). */\nfunction StatusMark({ status, testId }: { readonly status: ViewerStatus; readonly testId: string }) {\n const tone = status.tone ?? 'neutral';\n return <span role=\"status\" aria-label={status.label} title={status.label} data-testid={`${testId}-status`} data-tone={tone} data-kind={status.kind}\n className=\"inline-flex h-6 min-w-6 shrink-0 cursor-default items-center justify-center gap-1.5 text-xs\">\n <CircleIcon aria-hidden=\"true\" strokeWidth={0} className={cn('size-2 shrink-0', DOTS[tone])} />\n <span data-testid={`${testId}-status-text`} className={tone === 'danger' ? 'max-w-48 truncate text-destructive' : 'sr-only'}>{status.label}</span>\n </span>;\n}\n\n/** The one top bar of every viewer, always a single row: title and facts, viewer controls, a status dot when something needs attention, Share, full screen, Close and the \"…\" menu. */\nexport function ViewerFrame({ title, subtitle, status, showSavedStatus = false, target, revision, headerStart, controls, mainControl, menu, menuContent, actions, labels: ownLabels, icons: ownIcons, onRefresh, onShare, onCopy, onDownload, onOpenInNewTab, onClose, testId = 'viewer', titleTestId, compactHeader = true, className, children }: ViewerFrameProps) {\n const bar = useRef<HTMLElement>(null);\n const phone = usePhone(), coarse = useMedia('(pointer: coarse)');\n const tightWidth = phone || coarse ? TIGHT_WIDTH.touch : TIGHT_WIDTH.pointer;\n const [space, setSpace] = useState<ViewerBarSpace>({ tight: false });\n const [busy, setBusy] = useState<'refresh' | 'share' | undefined>();\n const [notice, setNotice] = useState<{ readonly text: string; readonly tone: ViewerTone } | undefined>();\n const [copied, setCopied] = useState(false);\n const [manual, setManual] = useState<{ readonly text: string; readonly link: boolean } | undefined>();\n const host = useViewerWindow();\n const { labels, icons } = useViewerText(ownLabels, ownIcons);\n useLayoutEffect(() => {\n const element = bar.current;\n if (!element || typeof ResizeObserver === 'undefined') return;\n const measure = () => {\n const width = element.getBoundingClientRect().width;\n const next = { tight: width > 0 && width < tightWidth };\n setSpace(now => now.tight === next.tight ? now : next);\n };\n measure();\n const observer = new ResizeObserver(measure);\n observer.observe(element);\n return () => observer.disconnect();\n }, [host?.renderHeader, tightWidth]);\n useEffect(() => {\n if (!notice) return;\n const timer = setTimeout(() => setNotice(undefined), NOTICE_MS);\n return () => clearTimeout(timer);\n }, [notice]);\n\n // A result after unmount updates nothing: React ignores a state update on an unmounted component.\n const guarded = useCallback(async (kind: 'refresh' | 'share', run: () => unknown, failed: string) => {\n setBusy(kind);\n setNotice(undefined);\n try { return await run(); }\n catch (error) { if (needsManualCopy(error)) setManual({ text: error.text, link: kind === 'share' }); else setNotice({ text: failed, tone: 'danger' }); return undefined; }\n finally { setBusy(undefined); }\n }, []);\n const refresh = () => { if (onRefresh) void guarded('refresh', onRefresh, labels.refreshFailed); };\n const share = () => {\n if (!onShare) return;\n void guarded('share', () => onShare({ title, target, revision }), labels.shareFailed).then(result => {\n if (result === 'copied') setNotice({ text: labels.linkCopied, tone: 'success' });\n else if (result === 'shared') setNotice({ text: labels.shared, tone: 'success' });\n });\n };\n const copy = () => {\n void Promise.resolve().then(onCopy).then(() => { setCopied(true); setNotice({ text: labels.copied, tone: 'success' }); setTimeout(() => setCopied(false), 1500); })\n .catch(error => { if (needsManualCopy(error)) setManual({ text: error.text, link: false }); else setNotice({ text: labels.copyFailed, tone: 'danger' }); });\n };\n const run = (action: (() => unknown) | undefined) => { void Promise.resolve().then(action).catch(() => setNotice({ text: labels.actionFailed, tone: 'danger' })); };\n // The host's actions: the window's (every viewer in the panel), then this frame's. Each runs through `run`, so a failure shows as a notice.\n const hostActions = [...host?.actions ?? [], ...actions ?? []].map(action => ({ ...action, onSelect: () => run(action.onSelect) }));\n\n const reading = status?.kind === 'read-only' || status?.label === labels.readOnly;\n const menuItems: ViewerMenuItem[] = ([\n ...(compactHeader && onShare ? [{ id: 'share', label: labels.share, icon: <icons.share className=\"size-4\" />, onSelect: share }] : []),\n ...(compactHeader && host?.onFullscreenChange ? [{ id: 'fullscreen', label: host.fullscreen ? labels.exitFullscreen : labels.enterFullscreen, icon: <icons.enterFullscreen className=\"size-4\" />, onSelect: () => host.onFullscreenChange!(!host.fullscreen) }] : []),\n ...(onRefresh ? [{ id: 'refresh', separatorBefore: true, label: labels.reload, icon: <RefreshCwIcon className=\"size-4\" />, disabled: busy === 'refresh', onSelect: refresh }] : []),\n ...(onCopy ? [{ id: 'copy', label: labels.copy, icon: copied ? <CheckIcon className=\"size-4\" /> : <CopyIcon className=\"size-4\" />, onSelect: copy }] : []),\n ...(onDownload ? [{ id: 'download', label: labels.download, icon: <DownloadIcon className=\"size-4\" />, onSelect: () => run(onDownload) }] : []),\n ...(onOpenInNewTab ? [{ id: 'open', label: labels.openInNewTab, icon: <ExternalLinkIcon className=\"size-4\" />, onSelect: () => run(onOpenInNewTab) }] : []),\n ...(menu ?? []).map((item, index) => ({ ...item, separatorBefore: index === 0 || item.separatorBefore })),\n ...menuActions(hostActions).map((action, index) => ({ ...actionMenuItem(action, testId), separatorBefore: index === 0 })),\n ] as ViewerMenuItem[]).map(entry => ({ ...entry, testId: entry.testId ?? `${testId}-${entry.id}` }));\n\n const stacked = !compactHeader && host?.headerLayout === 'stacked';\n const toolbar = controls && <div data-testid={`${testId}-controls`} className={cn('flex shrink-0 items-center gap-1', compactHeader && 'max-md:hidden', stacked && 'flex-wrap px-3 py-2')}>{controls}</div>;\n const titlebar = <>\n {compactHeader && !headerStart && onClose && <Button variant=\"ghost\" size=\"icon-sm\" aria-label={labels.close} title={labels.close} data-testid={`${testId}-close`} onClick={() => run(onClose)}><ArrowLeftIcon data-icon=\"inline-start\" className=\"md:hidden\" aria-hidden=\"true\" /><icons.close data-icon=\"inline-start\" className=\"hidden md:block\" aria-hidden=\"true\" /></Button>}\n {headerStart}\n <div className=\"group/title relative min-w-0 flex-1 basis-0 text-left\">\n <h2 data-testid={titleTestId ?? `${testId}-title`} title={title} className=\"m-0 truncate text-sm leading-5 font-semibold tracking-tight\">{title}</h2>\n {(subtitle || reading) && <p data-testid={`${testId}-subtitle`} className={cn('m-0 flex min-w-0 items-center gap-x-1.5 text-xs leading-4 whitespace-nowrap text-muted-foreground', compactHeader ? 'hidden group-hover/title:flex max-md:hidden' : 'overflow-hidden')}>{subtitle}{subtitle && reading && <span aria-hidden=\"true\">·</span>}{reading && <span data-testid={`${testId}-readonly`}>{labels.readOnly}</span>}</p>}\n </div>\n {!stacked && toolbar}\n {mainControl && <div data-testid={`${testId}-main`} className=\"flex shrink-0 items-center\">{mainControl}</div>}\n {status && !reading && (showSavedStatus || status.kind !== 'saved' && status.label !== labels.saved) && <StatusMark status={status} testId={testId} />}\n <div className=\"flex shrink-0 items-center gap-0.5\">\n {headerActions(hostActions).map(action => <ActionButton key={action.id} action={action} testId={testId} size=\"icon-sm\" />)}\n {!compactHeader && onShare && <Button variant=\"ghost\" size=\"icon-sm\" aria-label={labels.share} title={labels.share} data-testid={`${testId}-share`} disabled={busy === 'share'} onClick={share}><icons.share data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>}\n {(menuItems.length > 0 || controls || menuContent) && <ViewerActions label={labels.moreActions} testId={`${testId}-more`} icon={<icons.more data-icon=\"inline-start\" aria-hidden=\"true\" />} items={menuItems}>{menuContent}{menuContent && menuItems.length > 0 && <DropdownMenuSeparator />}{compactHeader && controls && <div className=\"boring-viewer flex flex-wrap items-center gap-2 p-2 md:hidden\">{controls}</div>}</ViewerActions>}\n {!compactHeader && host?.onFullscreenChange && <Toggle size=\"sm\" aria-label={host.fullscreen ? labels.exitFullscreen : labels.enterFullscreen} title={host.fullscreen ? labels.exitFullscreen : labels.enterFullscreen} data-testid={`${testId}-fullscreen`} pressed={host.fullscreen === true}\n onPressedChange={pressed => host.onFullscreenChange!(pressed)}>{host.fullscreen ? <icons.exitFullscreen data-icon=\"inline-start\" aria-hidden=\"true\" /> : <icons.enterFullscreen data-icon=\"inline-start\" aria-hidden=\"true\" />}</Toggle>}\n {(!compactHeader || headerStart) && onClose && <Button variant=\"ghost\" size=\"icon-sm\" className={compactHeader ? 'max-md:hidden' : undefined} aria-label={labels.close} title={labels.close} data-testid={`${testId}-close`} onClick={() => run(onClose)}><icons.close data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>}\n </div>\n </>;\n const header = <header ref={bar} data-testid={`${testId}-bar`} data-layout={stacked ? 'stacked' : undefined}\n className={cn('relative flex shrink-0', stacked ? 'flex-col' : 'h-13 flex-nowrap items-center gap-x-1.5 px-2 md:px-3')}>\n {stacked ? <><div data-testid={`${testId}-titlebar`} className=\"flex min-h-14 min-w-0 items-center gap-x-1.5 px-3 py-1.5\">{titlebar}</div>{toolbar}</> : titlebar}\n {notice && <Badge role=\"status\" aria-live=\"polite\" data-testid={`${testId}-notice`} data-tone={notice.tone} variant={notice.tone === 'danger' ? 'destructive' : 'secondary'}>{notice.text}</Badge>}\n {manual && <ManualCopy testId={testId} link={manual.link} text={manual.text} labels={labels} onClose={() => setManual(undefined)} />}\n </header>;\n return <ViewerBarContext.Provider value={space}>\n <section data-boring=\"viewer-frame\" data-testid={`${testId}-frame`} data-tight={space.tight || undefined}\n className={cn('boring-viewer isolate flex h-full min-h-0 min-w-0 flex-col bg-background text-foreground', className)}>\n {host?.renderHeader ? host.renderHeader(header) : header}\n <div data-testid={`${testId}-body`} className=\"flex min-h-0 min-w-0 flex-1 flex-col\">{children}</div>\n </section>\n </ViewerBarContext.Provider>;\n}\n",
|
|
51
51
|
"type": "registry:component",
|
|
52
52
|
"target": "components/viewer-frame/viewer-frame.tsx"
|
|
53
53
|
},
|
|
@@ -66,7 +66,7 @@
|
|
|
66
66
|
],
|
|
67
67
|
"css": {
|
|
68
68
|
"@layer utilities": {
|
|
69
|
-
"@media (
|
|
69
|
+
"@media (width < 768px), (pointer: coarse)": {
|
|
70
70
|
".boring-viewer :is([data-slot=\"button\"], [data-slot=\"toggle\"], [data-slot=\"toggle-group-item\"], [class~=\"group/button\"], [class~=\"group/toggle\"])": {
|
|
71
71
|
"min-height": "2.75rem",
|
|
72
72
|
"min-width": "2.75rem"
|
|
@@ -400,7 +400,7 @@
|
|
|
400
400
|
".boring-viewer .boring-viewer-stage": {
|
|
401
401
|
"overflow": "auto"
|
|
402
402
|
},
|
|
403
|
-
"@media (
|
|
403
|
+
"@media (width < 768px)": {
|
|
404
404
|
".boring-viewer .boring-markdown-content": {
|
|
405
405
|
"padding": "1.25rem 1.25rem 3rem"
|
|
406
406
|
},
|