@hachej/boring-ui-kit 0.4.9 → 0.4.11

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.
@@ -4,17 +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.9",
7
+ "@hachej/boring-ui-kit@^0.4.11",
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.9/r/pi-chat.json",
13
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.9/r/theme.json",
12
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.11/r/pi-chat.json",
13
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.11/r/theme.json",
14
14
  "avatar",
15
15
  "badge",
16
16
  "button",
17
- "card",
18
17
  "drawer",
19
18
  "popover",
20
19
  "tooltip",
@@ -28,7 +27,7 @@
28
27
  "files": [
29
28
  {
30
29
  "path": "registry/shared-chat/shared-chat.tsx",
31
- "content": "'use client';\nimport { PiChat } from '../pi-chat/pi-chat';\nimport type { PiChatProps } from '../pi-chat/pi-chat';\nimport type { SharedChatController } from '@hachej/boring-ui-kit/shared-chat';\nimport type { SharedLabels } from './shared-labels';\nimport { useSharedChatProps } from './use-shared-chat';\n\nexport { useSharedChat, useSharedChatProps } from './use-shared-chat';\nexport { defaultSharedLabels } from './shared-labels';\nexport { frenchSharedLabels } from './shared-labels-fr';\nexport type { SharedLabels } from './shared-labels';\nexport { createSharedChat, createChannelSocket, createFakeChannelSource, channelAvatarStates, ChannelRequestError } from '@hachej/boring-ui-kit/shared-chat';\nexport type { ChannelSource, ChannelEvent, ChannelMessage, Member, Conversation, Card, Step, AvatarState, SharedChatController } from '@hachej/boring-ui-kit/shared-chat';\n\nexport interface SharedChatProps extends Omit<PiChatProps, 'controller'> {\n readonly controller: SharedChatController;\n readonly participantLabels?: Partial<SharedLabels>;\n}\n/** Optional composition. PiChat still owns the regular shell, draft, send and composer lifecycle. */\nexport function SharedChat({ participantLabels, ...props }: SharedChatProps) {\n const composition = useSharedChatProps(props.controller, { locale: props.locale, labels: participantLabels });\n return <PiChat {...props} {...composition} controls={<>{composition.controls}{props.controls}</>} composerChips={<>{props.composerChips}{composition.composerChips}</>} />;\n}\n",
30
+ "content": "'use client';\nimport { PiChat } from '../pi-chat/pi-chat';\nimport type { PiChatProps } from '../pi-chat/pi-chat';\nimport type { SharedChatController } from '@hachej/boring-ui-kit/shared-chat';\nimport type { SharedLabels } from './shared-labels';\nimport { useSharedChatProps } from './use-shared-chat';\n\nexport { useSharedChat, useSharedChatProps } from './use-shared-chat';\nexport { defaultSharedLabels } from './shared-labels';\nexport { frenchSharedLabels } from './shared-labels-fr';\nexport type { SharedLabels } from './shared-labels';\nexport { createSharedChat, createChannelSocket, createFakeChannelSource, channelAvatarStates, cardActions, ChannelRequestError } from '@hachej/boring-ui-kit/shared-chat';\nexport type { ChannelSource, ChannelEvent, ChannelMessage, Member, Conversation, Card, CardAction, Step, AvatarState, SharedChatController } from '@hachej/boring-ui-kit/shared-chat';\n\nexport interface SharedChatProps extends Omit<PiChatProps, 'controller'> {\n readonly controller: SharedChatController;\n readonly participantLabels?: Partial<SharedLabels>;\n}\n/** Optional composition. PiChat still owns the regular shell, draft, send and composer lifecycle. */\nexport function SharedChat({ participantLabels, ...props }: SharedChatProps) {\n const composition = useSharedChatProps(props.controller, { locale: props.locale, labels: participantLabels });\n return <PiChat {...props} {...composition} controls={<>{composition.controls}{props.controls}</>} composerChips={<>{props.composerChips}{composition.composerChips}</>} />;\n}\n",
32
31
  "type": "registry:component",
33
32
  "target": "components/shared-chat/shared-chat.tsx"
34
33
  },
@@ -40,13 +39,13 @@
40
39
  },
41
40
  {
42
41
  "path": "registry/shared-chat/messages.tsx",
43
- "content": "'use client';\n\nimport { useEffect, useRef, useState, useSyncExternalStore } from 'react';\nimport type { ComponentProps, ReactNode } from 'react';\nimport { channelAvatarStates } from '@hachej/boring-ui-kit/shared-chat';\nimport type { Card as Request, ChannelMessage, SharedChatController, Member, SharedChatState } from '@hachej/boring-ui-kit/shared-chat';\nimport { BotIcon, CircleIcon, ReplyIcon, Trash2Icon, XIcon } from 'lucide-react';\nimport { Avatar, AvatarFallback, AvatarGroup, AvatarGroupCount, AvatarImage } from '@/components/ui/avatar';\nimport { Badge } from '@/components/ui/badge';\nimport { Button } from '@/components/ui/button';\nimport { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';\nimport { Drawer, DrawerContent, DrawerTitle } from '@/components/ui/drawer';\nimport { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';\nimport { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';\nimport { relativeTime } from '../pi-chat/history';\nimport { useChatText } from './text';\n\n// Text shares one treatment across headings and muted bubbles; only AA-qualified default palette entries colour it.\nconst agentTones = [\n { token: 'chart-1', icon: 'text-chart-1', text: 'text-foreground' },\n { token: 'chart-2', icon: 'text-chart-2', text: 'text-foreground dark:text-chart-2' },\n { token: 'chart-3', icon: 'text-chart-3', text: 'text-chart-3' },\n { token: 'chart-4', icon: 'text-chart-4', text: 'text-foreground' },\n { token: 'chart-5', icon: 'text-chart-5', text: 'text-foreground' },\n];\nexport function agentTone(member: Pick<Member, 'id' | 'kind'>) {\n if (member.kind !== 'agent') return undefined;\n let hash = 0;\n for (const character of member.id) hash = (Math.imul(hash, 31) + character.charCodeAt(0)) >>> 0;\n return agentTones[hash % agentTones.length];\n}\n\nfunction usePhone() {\n const [phone, setPhone] = useState(() => globalThis.matchMedia?.('(max-width: 640px)').matches === true);\n useEffect(() => { const media = globalThis.matchMedia?.('(max-width: 640px)'); if (!media) return; const change = () => setPhone(media.matches); media.addEventListener('change', change); return () => media.removeEventListener('change', change); }, []);\n return phone;\n}\nconst latestCard = (shared: SharedChatState, id: string) => shared.messages.findLast(message => message.author === id && message.card)?.card;\nconst memberOf = (shared: SharedChatState, id: string): Member => shared.members.find(member => member.id === id) ?? { id, name: id, kind: 'person' };\nexport function sharedQuote(shared: SharedChatState, id: string | undefined) {\n const message = shared.messages.find(message => message.id === id);\n return message ? { message, author: memberOf(shared, message.author) } : undefined;\n}\n\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\nexport function MemberAvatar({ member, card, interactive = true, size = 'default', compact = false }: { readonly member: Member; readonly card?: Request | undefined; readonly interactive?: boolean; readonly size?: ComponentProps<typeof Avatar>['size']; readonly compact?: boolean }) {\n const { labels } = useChatText();\n const phone = usePhone();\n const [open, setOpen] = useState(false);\n useEffect(() => {\n if (!open) return;\n const close = () => setOpen(false);\n document.addEventListener('scroll', close, true);\n return () => document.removeEventListener('scroll', close, true);\n }, [open]);\n const state = member.kind === 'agent' && card?.state && Object.hasOwn(channelAvatarStates, card.state) ? channelAvatarStates[card.state] ?? 'neutral' : 'neutral';\n const tone = state === 'blocked' ? 'text-destructive' : state === 'waiting' ? 'text-muted-foreground' : 'text-primary';\n const agent = agentTone(member);\n const avatarSrc = safeAvatarUrl(member.avatarUrl);\n const avatar = <Avatar size={compact ? 'sm' : size} data-agent-color={agent?.token} data-testid=\"member-avatar\" role=\"img\" aria-label={member.kind === 'agent' ? `${member.name}, ${labels.sharedAgent}` : member.name} data-state={state}>\n {avatarSrc && <AvatarImage src={avatarSrc} alt={member.name} />}\n <AvatarFallback>{member.kind === 'agent' ? <BotIcon data-lucide=\"bot\" className={`${compact ? 'size-3' : 'size-4'} ${agent?.icon ?? ''}`} aria-hidden=\"true\" /> : member.name.replace(/[()]/g, '').split(/\\s+/).slice(0, 2).map(word => word[0]).join('').toUpperCase()}</AvatarFallback>\n {state !== 'neutral' && <span data-testid=\"avatar-indicator\" data-state={state} className={`absolute right-0 bottom-0 ${tone}`}>\n <CircleIcon className=\"size-2 fill-current\" aria-hidden=\"true\" />\n </span>}\n </Avatar>;\n if (member.kind !== 'agent' || !card || !interactive) return avatar;\n const trigger = <span role=\"button\" tabIndex={0} className=\"inline-flex shrink-0\" data-testid={`agent-avatar-${member.id}`} data-state={state} aria-label={`${member.name}: ${card.line}`}>{avatar}</span>;\n return phone\n ? <Popover open={open} onOpenChange={setOpen}><PopoverTrigger nativeButton={false} render={trigger} /><PopoverContent data-testid=\"agent-state-popover\" side=\"bottom\" align=\"start\" className=\"max-w-[calc(100vw-1rem)]\">{card.line}</PopoverContent></Popover>\n : <Tooltip><TooltipTrigger render={trigger} /><TooltipContent>{card.line}</TooltipContent></Tooltip>;\n}\nfunction MemberBadge({ member }: { readonly member: Member }) {\n const { labels } = useChatText();\n return member.kind === 'admin' ? <Badge data-testid=\"author-badge\" variant=\"secondary\">{labels.sharedAdmin}</Badge> : null;\n}\nfunction Dot({ value }: { readonly value: SharedChatState['status'] | Member['presence'] }) {\n return <CircleIcon aria-hidden=\"true\" className={`size-2 fill-current ${value === 'live' || value === 'here' ? 'text-primary' : value === 'offline' ? 'text-destructive' : 'text-muted-foreground'}`} />;\n}\n\n/** Mounted in the existing bar. The source owns membership and visibility, including the admin. */\nexport function SharedMembers({ controller }: { readonly controller: SharedChatController }) {\n const state = useSyncExternalStore(controller.subscribe, controller.getSnapshot, controller.getSnapshot);\n const { labels } = useChatText();\n const [open, setOpen] = useState(false);\n const phone = usePhone();\n const shared = state.shared;\n if (!shared) return null;\n const name = shared.status === 'live' ? labels.live : shared.status === 'reconnecting' ? labels.reconnecting : labels.sharedOffline;\n const content = <ul data-testid=\"member-list\" className=\"m-0 flex min-w-0 list-none flex-col gap-3 p-4\">\n {shared.members.map(member => <li key={member.id} data-member-id={member.id} className=\"flex min-w-0 items-center gap-2\">\n <MemberAvatar member={member} card={latestCard(shared, member.id)} /><span className=\"min-w-0 flex-1 truncate\">{member.name}</span>\n <Badge variant=\"secondary\">{member.role ?? member.kind}</Badge>\n <span data-testid=\"presence\" data-presence={member.presence ?? 'away'} role=\"img\" aria-label={member.presence === 'here' ? labels.sharedHere : labels.sharedAway}><Dot value={member.presence} /></span>\n </li>)}\n </ul>;\n const stack = <AvatarGroup>{shared.members.slice(0, phone ? 1 : 3).map(member => <MemberAvatar key={member.id} member={member} card={latestCard(shared, member.id)} interactive={false} />)}<AvatarGroupCount>{shared.members.length}</AvatarGroupCount></AvatarGroup>;\n const trigger = <Button variant=\"ghost\" data-testid=\"shared-members\" aria-label={labels.sharedMemberCount(shared.members.length)} aria-haspopup=\"dialog\" aria-expanded={open}>{stack}</Button>;\n return <div className=\"flex shrink-0 items-center gap-2\">\n <span data-testid=\"shared-connection-announcement\" className=\"sr-only\" aria-live=\"polite\" aria-atomic=\"true\">{name}</span>\n {shared.status !== 'live' && <Tooltip><TooltipTrigger render={<span tabIndex={0} data-testid=\"shared-status\" data-status={shared.status} role=\"img\" aria-label={name}><Dot value={shared.status} /></span>} /><TooltipContent>{name}</TooltipContent></Tooltip>}\n {phone ? <><Button variant=\"ghost\" data-testid=\"shared-members\" aria-label={labels.sharedMemberCount(shared.members.length)} aria-haspopup=\"dialog\" aria-expanded={open} onClick={() => setOpen(true)}>{stack}</Button>\n {open && <Drawer open onOpenChange={setOpen} showSwipeHandle><DrawerContent className=\"pi-chat max-h-[80dvh]\"><div className=\"flex shrink-0 items-center justify-between p-4\"><DrawerTitle>{labels.sharedMembers}</DrawerTitle><Button variant=\"ghost\" size=\"icon\" data-testid=\"shared-members-close\" aria-label={labels.close} onClick={() => setOpen(false)}><XIcon aria-hidden=\"true\" /></Button></div><div className=\"min-h-0 overflow-y-auto\">{content}</div></DrawerContent></Drawer>}</>\n : <Popover open={open} onOpenChange={setOpen}><PopoverTrigger render={trigger} /><PopoverContent role=\"dialog\" aria-label={labels.sharedMembers} align=\"end\" className=\"max-w-[calc(100vw-1rem)]\">{content}</PopoverContent></Popover>}\n </div>;\n}\n\nexport function RequestCard({ card, action }: { readonly card: Request; readonly action: (card: Request) => Promise<void> }) {\n const { labels } = useChatText();\n const [busy, setBusy] = useState(false), [error, setError] = useState<string>();\n return <Card data-testid=\"request-card\">\n <CardHeader><CardTitle>{card.title}</CardTitle></CardHeader>\n <CardContent><p className=\"m-0\">{card.line}</p>{card.since && <time dateTime={card.since} className=\"text-muted-foreground\">{labels.sharedSince(relativeTime(card.since, Date.now(), labels))}</time>}{error && <p role=\"alert\">{error}</p>}</CardContent>\n {card.action && <CardFooter><Button disabled={busy} onClick={() => { setBusy(true); setError(undefined); void action(card).catch(error => setError(error instanceof Error ? error.message : labels.actionFailedShort)).finally(() => setBusy(false)); }}>{card.action.label}</Button></CardFooter>}\n </Card>;\n}\n\n/** Both the composer chip and the timeline quote use this one parent reference. No thread or routing policy. */\nexport function ReplyQuote({ targetId, quote, onRemove, onReveal }: { readonly targetId: string; readonly quote: ReturnType<typeof sharedQuote>; readonly onRemove?: () => void; readonly onReveal?: ((messageId: string) => void) | undefined }) {\n const { labels } = useChatText();\n const text = quote?.message.removed ? labels.sharedRemoved : quote?.message.card?.title ?? quote?.message.text?.split(/\\r?\\n/)[0] ?? labels.sharedEarlierMessage;\n return <div data-testid={onRemove ? 'composer-reply' : 'message-quote'} className=\"flex w-full min-w-0 items-center gap-1\">\n <Button variant=\"ghost\" className=\"min-w-0 max-w-full justify-start\" onClick={event => {\n const chat = event.currentTarget.closest('[data-boring=pi-chat]');\n const parent = [...(chat?.querySelectorAll<HTMLElement>('[data-shared-message]') ?? [])].find(element => element.dataset.sharedMessage === targetId);\n if (parent) parent.scrollIntoView({ block: 'center', behavior: 'instant' }); else onReveal?.(targetId);\n }}><ReplyIcon aria-hidden=\"true\" /><span className=\"truncate text-muted-foreground\">{quote ? `${quote.author.name}: ${text}` : text}</span></Button>\n {onRemove && <Tooltip><TooltipTrigger render={<Button variant=\"ghost\" size=\"icon\" data-testid=\"composer-reply-remove\" aria-label={labels.sharedCancelReply} onClick={onRemove}><XIcon aria-hidden=\"true\" /></Button>} /><TooltipContent>{labels.sharedCancelReply}</TooltipContent></Tooltip>}\n </div>;\n}\n\n/** Participant metadata surrounds the existing bubbles, Markdown, cards and tool trace. */\nexport function SharedMessage({ shared, children, remove, canRemove, openFile, onReply, onReveal }: {\n readonly shared: SharedItem; readonly children: ReactNode;\n readonly remove?: ((message: ChannelMessage) => Promise<void>) | undefined;\n readonly canRemove?: ((message: ChannelMessage) => boolean) | undefined;\n readonly openFile?: ((path: string) => void) | undefined;\n readonly onReply?: ((messageId: string) => void) | undefined;\n readonly onReveal?: ((messageId: string) => void) | undefined;\n}) {\n const { labels } = useChatText();\n const [error, setError] = useState<string>();\n const [actionsOpen, setActionsOpen] = useState(false);\n const phone = usePhone();\n const element = useRef<HTMLDivElement>(null);\n const press = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);\n const start = useRef<{ x: number; y: number } | undefined>(undefined);\n const cancelPress = () => { clearTimeout(press.current); start.current = undefined; };\n useEffect(() => cancelPress, []);\n useEffect(() => {\n if (!actionsOpen) return;\n const outside = (event: PointerEvent) => { if (event.target instanceof Node && !element.current?.contains(event.target)) setActionsOpen(false); };\n document.addEventListener('pointerdown', outside);\n return () => document.removeEventListener('pointerdown', outside);\n }, [actionsOpen]);\n const { message, author, first, own, lastItem } = shared;\n const replyable = Boolean(onReply && ((author.kind === 'agent' && !message.via) || message.card));\n const removable = Boolean(remove && canRemove?.(message));\n const actionable = !message.removed && (replyable || removable);\n const heading = first && !own && <div data-testid=\"message-author-row\" className=\"ml-8 flex min-w-0 items-center gap-2\">\n <div className=\"flex min-w-0 items-center gap-2\">\n <span data-testid=\"message-author\" data-agent-color={agentTone(author)?.token} className={`min-w-0 truncate text-sm ${agentTone(author)?.text ?? 'text-muted-foreground'}`}>{author.name}</span>\n {message.via && <span data-testid=\"message-via\" className={`min-w-0 truncate text-muted-foreground${own ? '' : ' text-sm'}`}>{labels.sharedVia(message.via)}</span>}\n {!(message.via && author.kind === 'agent') && <MemberBadge member={author} />}\n </div>\n </div>;\n const body = <>\n {message.replyTo && <ReplyQuote targetId={message.replyTo} quote={shared.quote} onReveal={onReveal} />}\n {message.removed ? <p className=\"text-muted-foreground\">{labels.sharedRemoved}</p> : children}\n </>;\n return <div ref={element} data-shared-message={message.id} className=\"group/shared-message relative flex min-w-0 flex-col gap-1\"\n tabIndex={phone && actionable ? 0 : undefined} aria-keyshortcuts={phone && actionable ? 'Shift+F10' : undefined}\n onKeyDown={event => {\n if (!phone || !actionable) return;\n if (event.key === 'ContextMenu' || event.key === 'F10' && event.shiftKey) { event.preventDefault(); setActionsOpen(true); }\n if (event.key === 'Escape') { setActionsOpen(false); element.current?.focus(); }\n }}\n onPointerDown={event => {\n if (!phone || !actionable || event.pointerType !== 'touch' || event.target instanceof Element && event.target.closest('button, a, input, textarea, [role=button], [role=link]')) return;\n cancelPress(); start.current = { x: event.clientX, y: event.clientY };\n press.current = setTimeout(() => setActionsOpen(true), 400);\n }}\n onPointerMove={event => { if (start.current && (Math.abs(event.clientX - start.current.x) > 8 || Math.abs(event.clientY - start.current.y) > 8)) cancelPress(); }}\n onPointerUp={cancelPress} onPointerCancel={cancelPress}\n onContextMenu={event => { if (phone && actionable && event.target instanceof Element && !event.target.closest('button, a, input, textarea, [role=button], [role=link]')) { event.preventDefault(); setActionsOpen(true); } }}>\n {heading}\n <div className=\"flex min-w-0 items-end gap-2\">\n {!own && <div data-testid=\"message-avatar\" className=\"flex w-6 shrink-0 items-end\">{lastItem && <MemberAvatar member={author} card={message.via ? undefined : shared.latestCard} size=\"sm\" />}</div>}\n <div data-testid=\"shared-message-content\" className=\"flex min-w-0 flex-1 flex-col gap-1\">\n {body}\n {!message.removed && message.attachments?.map(file => <Button key={file.path} variant=\"outline\" disabled={!openFile} onClick={() => openFile?.(file.path)} className=\"max-w-full justify-start\"><span className=\"truncate\">{file.title ?? file.path}</span></Button>)}\n {error && <p role=\"alert\">{error}</p>}\n </div></div>\n {actionable && <div data-testid=\"shared-message-actions\" hidden={phone && !actionsOpen} className={`absolute right-0 top-0 ${phone && !actionsOpen ? 'hidden' : 'flex'} items-center gap-1${phone ? '' : ' opacity-0 group-hover/shared-message:opacity-100 group-focus-within/shared-message:opacity-100'}`}>\n {replyable && onReply && <Tooltip><TooltipTrigger render={<Button variant=\"ghost\" size={phone ? 'icon' : 'icon-xs'} data-testid=\"shared-reply\" aria-label={labels.sharedReply} onClick={() => { setActionsOpen(false); onReply(message.id); }}><ReplyIcon aria-hidden=\"true\" /></Button>} /><TooltipContent>{labels.sharedReply}</TooltipContent></Tooltip>}\n {removable && remove && <Tooltip><TooltipTrigger render={<Button variant=\"ghost\" size={phone ? 'icon' : 'icon-xs'} aria-label={labels.delete} onClick={() => { void remove(message).catch(error => setError(error instanceof Error ? error.message : labels.actionFailedShort)); }}><Trash2Icon aria-hidden=\"true\" /></Button>} /><TooltipContent>{labels.delete}</TooltipContent></Tooltip>}\n </div>}\n </div>;\n}\n\nexport interface SharedItem {\n readonly key: string; readonly message: ChannelMessage; readonly author: Member; readonly own: boolean; readonly first: boolean; readonly lastItem: boolean; readonly separator: boolean;\n readonly showSpinner: boolean; readonly latestCard: Request | undefined; readonly quote: ReturnType<typeof sharedQuote>;\n}\nexport function sharedItems(shared: SharedChatState): SharedItem[] {\n const working = new Map(shared.messages.filter(message => message.streaming).map(message => [message.author, message.id]));\n const groups: ChannelMessage[][] = [];\n for (const message of shared.messages) {\n const group = groups.at(-1), previous = group?.at(-1);\n const elapsed = previous ? Date.parse(message.at) - Date.parse(previous.at) : NaN;\n if (group && previous?.author === message.author && previous.via === message.via && elapsed >= 0 && elapsed <= 15 * 60_000) group.push(message);\n else groups.push([message]);\n }\n return groups.flatMap((group, groupIndex) => {\n const lastItem = group.at(-1);\n return group.map((message, index): SharedItem => {\n const previous = index > 0 ? group[index - 1] : groups[groupIndex - 1]?.at(-1);\n return { key: `shared:${message.id}`, message, author: memberOf(shared, message.author), own: message.author === shared.me.id,\n first: index === 0, lastItem: message === lastItem,\n separator: !previous || Date.parse(message.at) - Date.parse(previous.at) > 15 * 60_000,\n showSpinner: working.get(message.author) === message.id, latestCard: latestCard(shared, message.author), quote: sharedQuote(shared, message.replyTo) };\n });\n });\n}\n",
42
+ "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 { BotIcon, CircleIcon, ReplyIcon, Trash2Icon, XIcon } from 'lucide-react';\nimport { Avatar, AvatarFallback, AvatarGroup, AvatarGroupCount, AvatarImage } from '@/components/ui/avatar';\nimport { Badge } from '@/components/ui/badge';\nimport { Button } from '@/components/ui/button';\nimport { Drawer, DrawerContent, DrawerTitle } from '@/components/ui/drawer';\nimport { Item, ItemActions, ItemContent, ItemDescription, ItemFooter, ItemTitle } from '@/components/ui/item';\nimport { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';\nimport { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';\nimport { relativeTime } from '../pi-chat/history';\nimport { useChatText } from './text';\n\n// Text shares one treatment across headings and muted bubbles; only AA-qualified default palette entries colour it.\nconst agentTones = [\n { token: 'chart-1', icon: 'text-chart-1', text: 'text-foreground' },\n { token: 'chart-2', icon: 'text-chart-2', text: 'text-foreground dark:text-chart-2' },\n { token: 'chart-3', icon: 'text-chart-3', text: 'text-chart-3' },\n { token: 'chart-4', icon: 'text-chart-4', text: 'text-foreground' },\n { token: 'chart-5', icon: 'text-chart-5', text: 'text-foreground' },\n];\nexport function agentTone(member: Pick<Member, 'id' | 'kind'>) {\n if (member.kind !== 'agent') return undefined;\n let hash = 0;\n for (const character of member.id) hash = (Math.imul(hash, 31) + character.charCodeAt(0)) >>> 0;\n return agentTones[hash % agentTones.length];\n}\n\nfunction usePhone() {\n const [phone, setPhone] = useState(() => globalThis.matchMedia?.('(max-width: 640px)').matches === true);\n useEffect(() => { const media = globalThis.matchMedia?.('(max-width: 640px)'); if (!media) return; const change = () => setPhone(media.matches); media.addEventListener('change', change); return () => media.removeEventListener('change', change); }, []);\n return phone;\n}\nconst latestCard = (shared: SharedChatState, id: string) => shared.messages.findLast(message => message.author === id && message.card)?.card;\nconst memberOf = (shared: SharedChatState, id: string): Member => shared.members.find(member => member.id === id) ?? { id, name: id, kind: 'person' };\nexport function sharedQuote(shared: SharedChatState, id: string | undefined) {\n const message = shared.messages.find(message => message.id === id);\n return message ? { message, author: memberOf(shared, message.author) } : undefined;\n}\n\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\nexport function MemberAvatar({ member, card, interactive = true, size = 'default', compact = false }: { readonly member: Member; readonly card?: Request | undefined; readonly interactive?: boolean; readonly size?: ComponentProps<typeof Avatar>['size']; readonly compact?: boolean }) {\n const { labels } = useChatText();\n const phone = usePhone();\n const [open, setOpen] = useState(false);\n useEffect(() => {\n if (!open) return;\n const close = () => setOpen(false);\n document.addEventListener('scroll', close, true);\n return () => document.removeEventListener('scroll', close, true);\n }, [open]);\n const state = member.kind === 'agent' && card?.state && Object.hasOwn(channelAvatarStates, card.state) ? channelAvatarStates[card.state] ?? 'neutral' : 'neutral';\n const tone = state === 'blocked' ? 'text-destructive' : state === 'waiting' ? 'text-muted-foreground' : 'text-primary';\n const agent = agentTone(member);\n const avatarSrc = safeAvatarUrl(member.avatarUrl);\n const avatar = <Avatar size={compact ? 'sm' : size} data-agent-color={agent?.token} data-testid=\"member-avatar\" role=\"img\" aria-label={member.kind === 'agent' ? `${member.name}, ${labels.sharedAgent}` : member.name} data-state={state}>\n {avatarSrc && <AvatarImage src={avatarSrc} alt={member.name} />}\n <AvatarFallback>{member.kind === 'agent' ? <BotIcon data-lucide=\"bot\" className={`${compact ? 'size-3' : 'size-4'} ${agent?.icon ?? ''}`} aria-hidden=\"true\" /> : member.name.replace(/[()]/g, '').split(/\\s+/).slice(0, 2).map(word => word[0]).join('').toUpperCase()}</AvatarFallback>\n {state !== 'neutral' && <span data-testid=\"avatar-indicator\" data-state={state} className={`absolute right-0 bottom-0 ${tone}`}>\n <CircleIcon className=\"size-2 fill-current\" aria-hidden=\"true\" />\n </span>}\n </Avatar>;\n if (member.kind !== 'agent' || !card || !interactive) return avatar;\n const trigger = <span role=\"button\" tabIndex={0} className=\"inline-flex shrink-0\" data-testid={`agent-avatar-${member.id}`} data-state={state} aria-label={`${member.name}: ${card.line}`}>{avatar}</span>;\n return phone\n ? <Popover open={open} onOpenChange={setOpen}><PopoverTrigger nativeButton={false} render={trigger} /><PopoverContent data-testid=\"agent-state-popover\" side=\"bottom\" align=\"start\" className=\"max-w-[calc(100vw-1rem)]\">{card.line}</PopoverContent></Popover>\n : <Tooltip><TooltipTrigger render={trigger} /><TooltipContent>{card.line}</TooltipContent></Tooltip>;\n}\nfunction MemberBadge({ member }: { readonly member: Member }) {\n const { labels } = useChatText();\n return member.kind === 'admin' ? <Badge data-testid=\"author-badge\" variant=\"secondary\">{labels.sharedAdmin}</Badge> : null;\n}\nfunction Dot({ value }: { readonly value: SharedChatState['status'] | Member['presence'] }) {\n return <CircleIcon aria-hidden=\"true\" className={`size-2 fill-current ${value === 'live' || value === 'here' ? 'text-primary' : value === 'offline' ? 'text-destructive' : 'text-muted-foreground'}`} />;\n}\n\n/** Mounted in the existing bar. The source owns membership and visibility, including the admin. */\nexport function SharedMembers({ controller }: { readonly controller: SharedChatController }) {\n const state = useSyncExternalStore(controller.subscribe, controller.getSnapshot, controller.getSnapshot);\n const { labels } = useChatText();\n const [open, setOpen] = useState(false);\n const phone = usePhone();\n const shared = state.shared;\n if (!shared) return null;\n const name = shared.status === 'live' ? labels.live : shared.status === 'reconnecting' ? labels.reconnecting : labels.sharedOffline;\n const content = <ul data-testid=\"member-list\" className=\"m-0 flex min-w-0 list-none flex-col gap-3 p-4\">\n {shared.members.map(member => <li key={member.id} data-member-id={member.id} className=\"flex min-w-0 items-center gap-2\">\n <MemberAvatar member={member} card={latestCard(shared, member.id)} /><span className=\"min-w-0 flex-1 truncate\">{member.name}</span>\n <Badge variant=\"secondary\">{member.role ?? member.kind}</Badge>\n <span data-testid=\"presence\" data-presence={member.presence ?? 'away'} role=\"img\" aria-label={member.presence === 'here' ? labels.sharedHere : labels.sharedAway}><Dot value={member.presence} /></span>\n </li>)}\n </ul>;\n const stack = <AvatarGroup>{shared.members.slice(0, phone ? 1 : 3).map(member => <MemberAvatar key={member.id} member={member} card={latestCard(shared, member.id)} interactive={false} />)}<AvatarGroupCount>{shared.members.length}</AvatarGroupCount></AvatarGroup>;\n const trigger = <Button variant=\"ghost\" data-testid=\"shared-members\" aria-label={labels.sharedMemberCount(shared.members.length)} aria-haspopup=\"dialog\" aria-expanded={open}>{stack}</Button>;\n return <div className=\"flex shrink-0 items-center gap-2\">\n <span data-testid=\"shared-connection-announcement\" className=\"sr-only\" aria-live=\"polite\" aria-atomic=\"true\">{name}</span>\n {shared.status !== 'live' && <Tooltip><TooltipTrigger render={<span tabIndex={0} data-testid=\"shared-status\" data-status={shared.status} role=\"img\" aria-label={name}><Dot value={shared.status} /></span>} /><TooltipContent>{name}</TooltipContent></Tooltip>}\n {phone ? <><Button variant=\"ghost\" data-testid=\"shared-members\" aria-label={labels.sharedMemberCount(shared.members.length)} aria-haspopup=\"dialog\" aria-expanded={open} onClick={() => setOpen(true)}>{stack}</Button>\n {open && <Drawer open onOpenChange={setOpen} showSwipeHandle><DrawerContent className=\"pi-chat max-h-[80dvh]\"><div className=\"flex shrink-0 items-center justify-between p-4\"><DrawerTitle>{labels.sharedMembers}</DrawerTitle><Button variant=\"ghost\" size=\"icon\" data-testid=\"shared-members-close\" aria-label={labels.close} onClick={() => setOpen(false)}><XIcon aria-hidden=\"true\" /></Button></div><div className=\"min-h-0 overflow-y-auto\">{content}</div></DrawerContent></Drawer>}</>\n : <Popover open={open} onOpenChange={setOpen}><PopoverTrigger render={trigger} /><PopoverContent role=\"dialog\" aria-label={labels.sharedMembers} align=\"end\" className=\"max-w-[calc(100vw-1rem)]\">{content}</PopoverContent></Popover>}\n </div>;\n}\n\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/** An outline Item, like the other conversation cards, so its border follows the theme's border colour and width. */\nexport function RequestCard({ card, action }: { readonly card: Request; readonly action: (card: Request, actionId?: string) => Promise<void> }) {\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 <Item variant=\"outline\" render={<section />} data-testid=\"request-card\" aria-label={card.title} className=\"text-start\">\n <ItemContent className=\"min-w-0 basis-0\">\n <ItemTitle className=\"line-clamp-none\">{card.title}</ItemTitle>\n <ItemDescription className=\"line-clamp-none\">{card.line}</ItemDescription>\n {card.since && <ItemDescription><time dateTime={card.since}>{labels.sharedSince(relativeTime(card.since, Date.now(), labels))}</time></ItemDescription>}\n </ItemContent>\n {actions.length > 0 && <ItemFooter><ItemActions role=\"group\" aria-label={card.title} className=\"flex-wrap\">\n {actions.map((choice, index) => {\n const primary = (choice.variant ?? (index === 0 ? 'primary' : 'secondary')) === 'primary';\n return <Button key={choice.id} data-testid=\"request-action\" data-action={choice.id} data-action-variant={primary ? 'primary' : 'secondary'} variant={primary ? 'default' : 'secondary'} disabled={busy !== undefined} aria-busy={busy === choice.id || undefined} onClick={() => run(choice.id)}>{choice.label}</Button>;\n })}\n </ItemActions></ItemFooter>}\n {error && <ItemFooter><p role=\"alert\" className=\"m-0 text-sm text-destructive\">{error}</p></ItemFooter>}\n </Item>;\n}\n\n/** Both the composer chip and the timeline quote use this one parent reference. No thread or routing policy. */\nexport function ReplyQuote({ targetId, quote, onRemove, onReveal }: { readonly targetId: string; readonly quote: ReturnType<typeof sharedQuote>; readonly onRemove?: () => void; readonly onReveal?: ((messageId: string) => void) | undefined }) {\n const { labels } = useChatText();\n const text = quote?.message.removed ? labels.sharedRemoved : quote?.message.card?.title ?? quote?.message.text?.split(/\\r?\\n/)[0] ?? labels.sharedEarlierMessage;\n return <div data-testid={onRemove ? 'composer-reply' : 'message-quote'} className=\"flex w-full min-w-0 items-center gap-1\">\n <Button variant=\"ghost\" className=\"min-w-0 max-w-full justify-start\" onClick={event => {\n const chat = event.currentTarget.closest('[data-boring=pi-chat]');\n const parent = [...(chat?.querySelectorAll<HTMLElement>('[data-shared-message]') ?? [])].find(element => element.dataset.sharedMessage === targetId);\n if (parent) parent.scrollIntoView({ block: 'center', behavior: 'instant' }); else onReveal?.(targetId);\n }}><ReplyIcon aria-hidden=\"true\" /><span className=\"truncate text-muted-foreground\">{quote ? `${quote.author.name}: ${text}` : text}</span></Button>\n {onRemove && <Tooltip><TooltipTrigger render={<Button variant=\"ghost\" size=\"icon\" data-testid=\"composer-reply-remove\" aria-label={labels.sharedCancelReply} onClick={onRemove}><XIcon aria-hidden=\"true\" /></Button>} /><TooltipContent>{labels.sharedCancelReply}</TooltipContent></Tooltip>}\n </div>;\n}\n\n/** Participant metadata surrounds the existing bubbles, Markdown, cards and tool trace. */\nexport function SharedMessage({ shared, children, remove, canRemove, openFile, onReply, onReveal }: {\n readonly shared: SharedItem; readonly children: ReactNode;\n readonly remove?: ((message: ChannelMessage) => Promise<void>) | undefined;\n readonly canRemove?: ((message: ChannelMessage) => boolean) | undefined;\n readonly openFile?: ((path: string) => void) | undefined;\n readonly onReply?: ((messageId: string) => void) | undefined;\n readonly onReveal?: ((messageId: string) => void) | undefined;\n}) {\n const { labels } = useChatText();\n const [error, setError] = useState<string>();\n const [actionsOpen, setActionsOpen] = useState(false);\n const phone = usePhone();\n const element = useRef<HTMLDivElement>(null);\n const press = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);\n const start = useRef<{ x: number; y: number } | undefined>(undefined);\n const cancelPress = () => { clearTimeout(press.current); start.current = undefined; };\n useEffect(() => cancelPress, []);\n useEffect(() => {\n if (!actionsOpen) return;\n const outside = (event: PointerEvent) => { if (event.target instanceof Node && !element.current?.contains(event.target)) setActionsOpen(false); };\n document.addEventListener('pointerdown', outside);\n return () => document.removeEventListener('pointerdown', outside);\n }, [actionsOpen]);\n const { message, author, first, own, lastItem } = shared;\n const role = useRoleLine(author);\n const replyable = Boolean(onReply && ((author.kind === 'agent' && !message.via) || message.card));\n const removable = Boolean(remove && canRemove?.(message));\n const actionable = !message.removed && (replyable || removable);\n const heading = first && !own && <div data-testid=\"message-author-row\" className=\"ml-8 flex min-w-0 items-center gap-2\">\n <div className=\"flex min-w-0 items-center gap-2\">\n <span data-testid=\"message-author\" data-agent-color={agentTone(author)?.token} className={`min-w-0 truncate text-sm ${agentTone(author)?.text ?? 'text-muted-foreground'}`}>{author.name}</span>\n {message.via && <span data-testid=\"message-via\" className={`min-w-0 truncate text-muted-foreground${own ? '' : ' text-sm'}`}>{labels.sharedVia(message.via)}</span>}\n {role && !(message.via && author.kind === 'agent') && <span data-testid=\"message-role\" className=\"min-w-0 truncate text-sm text-muted-foreground\"><span aria-hidden=\"true\">· </span>{role}</span>}\n {!(message.via && author.kind === 'agent') && <MemberBadge member={author} />}\n </div>\n </div>;\n const body = <>\n {message.replyTo && <ReplyQuote targetId={message.replyTo} quote={shared.quote} onReveal={onReveal} />}\n {message.removed ? <p className=\"text-muted-foreground\">{labels.sharedRemoved}</p> : children}\n </>;\n return <div ref={element} data-shared-message={message.id} className=\"group/shared-message relative flex min-w-0 flex-col gap-1\"\n tabIndex={phone && actionable ? 0 : undefined} aria-keyshortcuts={phone && actionable ? 'Shift+F10' : undefined}\n onKeyDown={event => {\n if (!phone || !actionable) return;\n if (event.key === 'ContextMenu' || event.key === 'F10' && event.shiftKey) { event.preventDefault(); setActionsOpen(true); }\n if (event.key === 'Escape') { setActionsOpen(false); element.current?.focus(); }\n }}\n onPointerDown={event => {\n if (!phone || !actionable || event.pointerType !== 'touch' || event.target instanceof Element && event.target.closest('button, a, input, textarea, [role=button], [role=link]')) return;\n cancelPress(); start.current = { x: event.clientX, y: event.clientY };\n press.current = setTimeout(() => setActionsOpen(true), 400);\n }}\n onPointerMove={event => { if (start.current && (Math.abs(event.clientX - start.current.x) > 8 || Math.abs(event.clientY - start.current.y) > 8)) cancelPress(); }}\n onPointerUp={cancelPress} onPointerCancel={cancelPress}\n onContextMenu={event => { if (phone && actionable && event.target instanceof Element && !event.target.closest('button, a, input, textarea, [role=button], [role=link]')) { event.preventDefault(); setActionsOpen(true); } }}>\n {heading}\n <div className=\"flex min-w-0 items-end gap-2\">\n {!own && <div data-testid=\"message-avatar\" className=\"flex w-6 shrink-0 items-end\">{lastItem && <MemberAvatar member={author} card={message.via ? undefined : shared.latestCard} size=\"sm\" />}</div>}\n <div data-testid=\"shared-message-content\" className=\"flex min-w-0 flex-1 flex-col gap-1\">\n {body}\n {!message.removed && message.attachments?.map(file => <Button key={file.path} variant=\"outline\" disabled={!openFile} onClick={() => openFile?.(file.path)} className=\"max-w-full justify-start\"><span className=\"truncate\">{file.title ?? file.path}</span></Button>)}\n {error && <p role=\"alert\">{error}</p>}\n </div></div>\n {actionable && <div data-testid=\"shared-message-actions\" hidden={phone && !actionsOpen} className={`absolute right-0 top-0 ${phone && !actionsOpen ? 'hidden' : 'flex'} items-center gap-1${phone ? '' : ' opacity-0 group-hover/shared-message:opacity-100 group-focus-within/shared-message:opacity-100'}`}>\n {replyable && onReply && <Tooltip><TooltipTrigger render={<Button variant=\"ghost\" size={phone ? 'icon' : 'icon-xs'} data-testid=\"shared-reply\" aria-label={labels.sharedReply} onClick={() => { setActionsOpen(false); onReply(message.id); }}><ReplyIcon aria-hidden=\"true\" /></Button>} /><TooltipContent>{labels.sharedReply}</TooltipContent></Tooltip>}\n {removable && remove && <Tooltip><TooltipTrigger render={<Button variant=\"ghost\" size={phone ? 'icon' : 'icon-xs'} aria-label={labels.delete} onClick={() => { void remove(message).catch(error => setError(error instanceof Error ? error.message : labels.actionFailedShort)); }}><Trash2Icon aria-hidden=\"true\" /></Button>} /><TooltipContent>{labels.delete}</TooltipContent></Tooltip>}\n </div>}\n </div>;\n}\n\nexport interface SharedItem {\n readonly key: string; readonly message: ChannelMessage; readonly author: Member; readonly own: boolean; readonly first: boolean; readonly lastItem: boolean; readonly separator: boolean;\n readonly showSpinner: boolean; readonly latestCard: Request | undefined; readonly quote: ReturnType<typeof sharedQuote>;\n}\nexport function sharedItems(shared: SharedChatState): SharedItem[] {\n const working = new Map(shared.messages.filter(message => message.streaming).map(message => [message.author, message.id]));\n const groups: ChannelMessage[][] = [];\n for (const message of shared.messages) {\n const group = groups.at(-1), previous = group?.at(-1);\n const elapsed = previous ? Date.parse(message.at) - Date.parse(previous.at) : NaN;\n if (group && previous?.author === message.author && previous.via === message.via && elapsed >= 0 && elapsed <= 15 * 60_000) group.push(message);\n else groups.push([message]);\n }\n return groups.flatMap((group, groupIndex) => {\n const lastItem = group.at(-1);\n return group.map((message, index): SharedItem => {\n const previous = index > 0 ? group[index - 1] : groups[groupIndex - 1]?.at(-1);\n return { key: `shared:${message.id}`, message, author: memberOf(shared, message.author), own: message.author === shared.me.id,\n first: index === 0, lastItem: message === lastItem,\n separator: !previous || Date.parse(message.at) - Date.parse(previous.at) > 15 * 60_000,\n showSpinner: working.get(message.author) === message.id, latestCard: latestCard(shared, message.author), quote: sharedQuote(shared, message.replyTo) };\n });\n });\n}\n",
44
43
  "type": "registry:component",
45
44
  "target": "components/shared-chat/messages.tsx"
46
45
  },
47
46
  {
48
47
  "path": "registry/shared-chat/timeline.tsx",
49
- "content": "'use client';\nimport { Children, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';\nimport type { ReactNode, RefObject } from 'react';\nimport type { SharedChatController } from '@hachej/boring-ui-kit/shared-chat';\nimport type { MentionsConfig } from '../pi-chat/config';\nimport { MessageText } from '../pi-chat/message';\nimport { Markdown } from '../pi-chat/markdown';\nimport { useTranscript } from '../pi-chat/session';\nimport { TranscriptHistory } from '../pi-chat/history';\nimport { Message, MessageContent } from '@/components/ui/message';\nimport { Bubble, BubbleContent } from '@/components/ui/bubble';\nimport { MessageScroller, MessageScrollerViewport, MessageScrollerContent, MessageScrollerItem, MessageScrollerButton } from '@/components/ui/message-scroller';\nimport { SharedMessage, RequestCard, sharedItems } from './messages';\nimport { HostTrace } from './host-trace';\nimport { useChatText } from './text';\n\nexport interface TimelineApi { readonly reset: () => void; readonly reveal: (id: string) => void }\nexport function SharedTimeline({ controller, api, focusComposer, openFile, mentions, pendingVoice }: {\n readonly controller: SharedChatController; readonly api: RefObject<TimelineApi | undefined>;\n readonly focusComposer: () => void; readonly openFile?: ((path: string) => void) | undefined; readonly mentions: MentionsConfig; readonly pendingVoice?: ReactNode;\n}) {\n const state = useSyncExternalStore(controller.subscribe, controller.getSnapshot, controller.getSnapshot);\n const { labels, icons } = useChatText();\n const items = useMemo(() => sharedItems(state.shared), [state.shared]);\n const connected = state.connection.kind === 'connected';\n const transcript = useTranscript(items, 60, 40, { state: state.history, connected: !state.disposed && connected, load: controller.loadEarlier });\n const { stick, hidden } = transcript;\n const root = useRef<HTMLDivElement>(null);\n const [revealing, setRevealing] = useState<string>();\n useEffect(() => {\n const value = { reset: () => { transcript.reset(); void stick.scrollToBottom('instant'); }, reveal: setRevealing };\n api.current = value;\n return () => { if (api.current === value) api.current = undefined; };\n });\n useEffect(() => {\n if (!revealing) return;\n const key = `shared:${revealing}`;\n if (items.some(item => item.key === key)) {\n transcript.revealTo(key);\n const frame = requestAnimationFrame(() => {\n const parent = [...(root.current?.querySelectorAll<HTMLElement>('[data-shared-message]') ?? [])].find(element => element.dataset.sharedMessage === revealing);\n if (parent) { parent.scrollIntoView({ block: 'center', behavior: 'instant' }); setRevealing(undefined); }\n });\n return () => cancelAnimationFrame(frame);\n }\n if (state.history.kind === 'loading') return;\n if (state.history.kind === 'ready' && state.history.hasMore) void controller.loadEarlier().catch(() => setRevealing(undefined));\n else setRevealing(undefined);\n }, [revealing, items, hidden, state.history]);\n const hasContent = items.length > 0 || Children.count(pendingVoice) > 0;\n const context = { pieces: { mentions: true, skills: [], renderMention: mentions.renderSent }, openFile };\n const renderMention = (path: string, text: string) => mentions.renderSent?.(path) ?? text;\n return <MessageScroller>\n <MessageScrollerViewport ref={node => { root.current = node; stick.scrollRef(node); }} data-testid=\"transcript-scroll\" aria-label={labels.messages} className=\"min-h-0 flex-1 scrollbar-thumb-border scrollbar-track-transparent\">\n {items.length > 0 && (hidden > 0 || state.history.kind === 'loading' || state.history.kind === 'error' || state.history.kind === 'ready' && state.history.hasMore) && <TranscriptHistory history={state.history} connected={!state.disposed && connected} onLoad={transcript.reveal} sentinelRef={transcript.sentinelRef} hidden={hidden} loaded={transcript.loaded} />}\n <div className=\"mx-auto w-full max-w-[720px] px-4 pt-6 pb-8 sm:px-6\">\n <MessageScrollerContent aria-label={labels.messages} aria-live=\"polite\" data-testid=\"transcript\" className=\"gap-0 [&>[data-local-voice]]:mt-6\">\n {transcript.visible(items).map((item, index) => {\n const { message, author, own } = item;\n const person = own || author.kind !== 'agent' || message.via !== undefined;\n return <MessageScrollerItem key={item.key} messageId={item.key} className={`[content-visibility:visible]${index > 0 ? item.first ? ' mt-6' : ' mt-2' : ''}`}>\n {item.separator && <div data-testid=\"time-separator\" className=\"mb-6 flex justify-center text-xs text-muted-foreground\"><time dateTime={message.at}>{labels.sharedTimestamp(message.at)}</time></div>}\n <SharedMessage shared={item} remove={controller.remove} canRemove={controller.canRemove} openFile={openFile} onReply={id => { controller.setReplyTo(id); focusComposer(); }} onReveal={setRevealing}>\n {message.card ? <article data-row-id={item.key} data-role=\"event\" className=\"max-w-[80%]\"><RequestCard card={message.card} action={controller.action} /></article>\n : <article data-row-id={item.key} data-role={person ? 'user' : 'assistant'} data-streaming={message.streaming ? 'true' : undefined} className=\"group/message\"><Message align={own ? 'end' : 'start'}><MessageContent>\n {own ? <MessageText text={message.text ?? ''} context={context} /> : <Bubble variant=\"muted\"><BubbleContent>\n {message.text && <Markdown text={message.text} streaming={Boolean(message.streaming)} renderMention={renderMention} />}\n {!message.via && author.kind === 'agent' && (message.streaming || message.steps?.length) && <HostTrace participant={author.name} steps={message.steps ?? []} streaming={Boolean(message.streaming)} active={item.showSpinner} />}\n </BubbleContent></Bubble>}\n </MessageContent></Message></article>}\n </SharedMessage>\n </MessageScrollerItem>;\n })}\n {pendingVoice}\n </MessageScrollerContent>\n </div>\n </MessageScrollerViewport>\n {hasContent && <div data-testid=\"transcript-navigation\" className=\"flex h-12 shrink-0 items-center justify-center\">{!stick.isAtBottom && <MessageScrollerButton className=\"static inset-auto translate-x-0\" data-testid=\"jump-latest\" aria-label={labels.jumpToLatest} title={labels.jumpToLatest} onClick={() => api.current?.reset()}><icons.jumpToLatest aria-hidden=\"true\" /></MessageScrollerButton>}</div>}\n </MessageScroller>;\n}\n",
48
+ "content": "'use client';\nimport { Children, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';\nimport type { ReactNode, RefObject } from 'react';\nimport type { SharedChatController } from '@hachej/boring-ui-kit/shared-chat';\nimport type { MentionsConfig } from '../pi-chat/config';\nimport { MessageText } from '../pi-chat/message';\nimport { Markdown } from '../pi-chat/markdown';\nimport { useTranscript } from '../pi-chat/session';\nimport { TranscriptHistory } from '../pi-chat/history';\nimport { Message, MessageContent } from '@/components/ui/message';\nimport { Bubble, BubbleContent } from '@/components/ui/bubble';\nimport { MessageScroller, MessageScrollerViewport, MessageScrollerContent, MessageScrollerItem, MessageScrollerButton } from '@/components/ui/message-scroller';\nimport { SharedMessage, RequestCard, sharedItems } from './messages';\nimport { HostTrace } from './host-trace';\nimport { useChatText } from './text';\n\nexport interface TimelineApi { readonly reset: () => void; readonly reveal: (id: string) => void }\nexport function SharedTimeline({ controller, api, focusComposer, openFile, mentions, pendingVoice }: {\n readonly controller: SharedChatController; readonly api: RefObject<TimelineApi | undefined>;\n readonly focusComposer: () => void; readonly openFile?: ((path: string) => void) | undefined; readonly mentions: MentionsConfig; readonly pendingVoice?: ReactNode;\n}) {\n const state = useSyncExternalStore(controller.subscribe, controller.getSnapshot, controller.getSnapshot);\n const { labels, icons } = useChatText();\n const items = useMemo(() => sharedItems(state.shared), [state.shared]);\n const connected = state.connection.kind === 'connected';\n const transcript = useTranscript(items, 60, 40, { state: state.history, connected: !state.disposed && connected, load: controller.loadEarlier });\n const { stick, hidden } = transcript;\n const root = useRef<HTMLDivElement>(null);\n const [revealing, setRevealing] = useState<string>();\n useEffect(() => {\n const value = { reset: () => { transcript.reset(); void stick.scrollToBottom('instant'); }, reveal: setRevealing };\n api.current = value;\n return () => { if (api.current === value) api.current = undefined; };\n });\n useEffect(() => {\n if (!revealing) return;\n const key = `shared:${revealing}`;\n if (items.some(item => item.key === key)) {\n transcript.revealTo(key);\n const frame = requestAnimationFrame(() => {\n const parent = [...(root.current?.querySelectorAll<HTMLElement>('[data-shared-message]') ?? [])].find(element => element.dataset.sharedMessage === revealing);\n if (parent) { parent.scrollIntoView({ block: 'center', behavior: 'instant' }); setRevealing(undefined); }\n });\n return () => cancelAnimationFrame(frame);\n }\n if (state.history.kind === 'loading') return;\n if (state.history.kind === 'ready' && state.history.hasMore) void controller.loadEarlier().catch(() => setRevealing(undefined));\n else setRevealing(undefined);\n }, [revealing, items, hidden, state.history]);\n const hasContent = items.length > 0 || Children.count(pendingVoice) > 0;\n const context = { pieces: { mentions: true, skills: [], renderMention: mentions.renderSent }, openFile };\n const renderMention = (path: string, text: string) => mentions.renderSent?.(path) ?? text;\n return <MessageScroller>\n <MessageScrollerViewport ref={node => { root.current = node; stick.scrollRef(node); }} data-testid=\"transcript-scroll\" aria-label={labels.messages} className=\"min-h-0 flex-1 scrollbar-thumb-border scrollbar-track-transparent\">\n {items.length > 0 && (hidden > 0 || state.history.kind === 'loading' || state.history.kind === 'error' || state.history.kind === 'ready' && state.history.hasMore) && <TranscriptHistory history={state.history} connected={!state.disposed && connected} onLoad={transcript.reveal} sentinelRef={transcript.sentinelRef} hidden={hidden} loaded={transcript.loaded} />}\n <div className=\"mx-auto w-full max-w-[720px] px-4 pt-6 pb-8 sm:px-6\">\n <MessageScrollerContent aria-label={labels.messages} aria-live=\"polite\" data-testid=\"transcript\" className=\"gap-0 [&>[data-local-voice]]:mt-6\">\n {transcript.visible(items).map((item, index) => {\n const { message, author, own } = item;\n const person = own || author.kind !== 'agent' || message.via !== undefined;\n return <MessageScrollerItem key={item.key} messageId={item.key} className={`[content-visibility:visible]${index > 0 ? item.first ? ' mt-6' : ' mt-2' : ''}`}>\n {item.separator && <div data-testid=\"time-separator\" className=\"mb-6 flex justify-center text-xs text-muted-foreground\"><time dateTime={message.at}>{labels.sharedTimestamp(message.at)}</time></div>}\n <SharedMessage shared={item} remove={controller.remove} canRemove={controller.canRemove} openFile={openFile} onReply={id => { controller.setReplyTo(id); focusComposer(); }} onReveal={setRevealing}>\n {message.card ? <article data-row-id={item.key} data-role=\"event\" className=\"max-w-[80%]\"><RequestCard card={message.card} action={controller.action} /></article>\n : <article data-row-id={item.key} data-role={person ? 'user' : 'assistant'} data-streaming={message.streaming ? 'true' : undefined} className=\"group/message\"><Message align={own ? 'end' : 'start'}><MessageContent>\n {own ? <div data-testid=\"own-message\" className=\"contents [&_[data-testid=message-mention]]:text-inherit\"><MessageText text={message.text ?? ''} context={context} variant=\"default\" /></div> : <Bubble variant=\"muted\"><BubbleContent>\n {message.text && <Markdown text={message.text} streaming={Boolean(message.streaming)} renderMention={renderMention} />}\n {!message.via && author.kind === 'agent' && (message.streaming || message.steps?.length) && <HostTrace participant={author.name} steps={message.steps ?? []} streaming={Boolean(message.streaming)} active={item.showSpinner} />}\n </BubbleContent></Bubble>}\n </MessageContent></Message></article>}\n </SharedMessage>\n </MessageScrollerItem>;\n })}\n {pendingVoice}\n </MessageScrollerContent>\n </div>\n </MessageScrollerViewport>\n {hasContent && <div data-testid=\"transcript-navigation\" className=\"flex h-12 shrink-0 items-center justify-center\">{!stick.isAtBottom && <MessageScrollerButton className=\"static inset-auto translate-x-0\" data-testid=\"jump-latest\" aria-label={labels.jumpToLatest} title={labels.jumpToLatest} onClick={() => api.current?.reset()}><icons.jumpToLatest aria-hidden=\"true\" /></MessageScrollerButton>}</div>}\n </MessageScroller>;\n}\n",
50
49
  "type": "registry:component",
51
50
  "target": "components/shared-chat/timeline.tsx"
52
51
  },
@@ -64,13 +63,13 @@
64
63
  },
65
64
  {
66
65
  "path": "registry/shared-chat/shared-labels.ts",
67
- "content": "export function formatTimestamp(at: string, locale: string) {\n const date = new Date(at);\n if (!Number.isFinite(date.getTime())) return at;\n return `${new Intl.DateTimeFormat(locale, { weekday: 'short', day: 'numeric', month: 'short' }).format(date)}, ${new Intl.DateTimeFormat(locale, { hour: '2-digit', minute: '2-digit' }).format(date)}`;\n}\nexport const defaultSharedLabels = {\n sharedTimestamp: (at: string) => formatTimestamp(at, 'en'),\n sharedMembers: 'Members', sharedMemberCount: (count: number) => `${count} members`,\n sharedAgent: 'Agent', sharedVia: (name: string) => `via ${name}`,\n sharedWorking: (name: string) => `${name} is working…`,\n sharedReply: 'Reply', sharedCancelReply: 'Cancel reply', sharedEarlierMessage: 'Earlier message',\n sharedAdmin: 'Admin', sharedHere: 'Here', sharedAway: 'Away', sharedOffline: 'Offline',\n sharedRemoved: 'Message removed', sharedSince: (time: string) => `Since ${time}`,\n};\nexport type SharedLabels = typeof defaultSharedLabels;\n",
66
+ "content": "export function formatTimestamp(at: string, locale: string) {\n const date = new Date(at);\n if (!Number.isFinite(date.getTime())) return at;\n return `${new Intl.DateTimeFormat(locale, { weekday: 'short', day: 'numeric', month: 'short' }).format(date)}, ${new Intl.DateTimeFormat(locale, { hour: '2-digit', minute: '2-digit' }).format(date)}`;\n}\ntype RoleOf = { readonly kind: 'person' | 'agent' | 'admin'; readonly role?: string | undefined };\n/** owner, validator and member read as themselves; every agent is the app's agent; the admin keeps its badge instead. */\nexport function roleLine(member: RoleOf, words: { readonly owner: string; readonly validator: string; readonly member: string; readonly agent: string }): string | undefined {\n if (member.kind === 'admin' || member.role === 'admin') return undefined;\n if (member.kind === 'agent' || member.role?.startsWith('agent:')) return words.agent;\n return member.role === 'owner' || member.role === 'validator' || member.role === 'member' ? words[member.role] : undefined;\n}\nexport const defaultSharedLabels = {\n sharedTimestamp: (at: string) => formatTimestamp(at, 'en'),\n sharedMembers: 'Members', sharedMemberCount: (count: number) => `${count} members`,\n sharedAgent: 'Agent', sharedVia: (name: string) => `via ${name}`,\n sharedWorking: (name: string) => `${name} is working…`,\n sharedReply: 'Reply', sharedCancelReply: 'Cancel reply', sharedEarlierMessage: 'Earlier message',\n sharedAdmin: 'Admin', sharedHere: 'Here', sharedAway: 'Away', sharedOffline: 'Offline',\n sharedRemoved: 'Message removed', sharedSince: (time: string) => `Since ${time}`,\n /** The line beside an author (« Léa · member »). Return undefined for none; a member's own `roleLabel` wins. */\n sharedRole: (member: RoleOf): string | undefined => roleLine(member, { owner: 'owner', validator: 'validator', member: 'member', agent: 'the app\\u2019s agent' }),\n};\nexport type SharedLabels = typeof defaultSharedLabels;\n",
68
67
  "type": "registry:lib",
69
68
  "target": "components/shared-chat/shared-labels.ts"
70
69
  },
71
70
  {
72
71
  "path": "registry/shared-chat/shared-labels-fr.ts",
73
- "content": "import type { SharedLabels } from './shared-labels';\nimport { formatTimestamp } from './shared-labels';\nexport const frenchSharedLabels: SharedLabels = {\n sharedTimestamp: (at: string) => formatTimestamp(at, 'fr'),\n sharedMembers: 'Membres', sharedMemberCount: (count: number) => `${count} membres`,\n sharedAgent: 'Agent', sharedVia: (name: string) => `via ${name}`,\n sharedWorking: (name: string) => `${name} travaille…`,\n sharedReply: 'Répondre', sharedCancelReply: 'Annuler la réponse', sharedEarlierMessage: 'Message précédent',\n sharedAdmin: 'Admin', sharedHere: 'Présent', sharedAway: 'Absent', sharedOffline: 'Hors ligne',\n sharedRemoved: 'Message supprimé', sharedSince: (time: string) => `Depuis ${time}`,\n};\n",
72
+ "content": "import type { SharedLabels } from './shared-labels';\nimport { formatTimestamp, roleLine } from './shared-labels';\nexport const frenchSharedLabels: SharedLabels = {\n sharedTimestamp: (at: string) => formatTimestamp(at, 'fr'),\n sharedMembers: 'Membres', sharedMemberCount: (count: number) => `${count} membres`,\n sharedAgent: 'Agent', sharedVia: (name: string) => `via ${name}`,\n sharedWorking: (name: string) => `${name} travaille…`,\n sharedReply: 'Répondre', sharedCancelReply: 'Annuler la réponse', sharedEarlierMessage: 'Message précédent',\n sharedAdmin: 'Admin', sharedHere: 'Présent', sharedAway: 'Absent', sharedOffline: 'Hors ligne',\n sharedRemoved: 'Message supprimé', sharedSince: (time: string) => `Depuis ${time}`,\n sharedRole: member => roleLine(member, { owner: 'propriétaire', validator: 'validateur', member: 'membre', agent: 'l\\u2019agent de l\\u2019app' }),\n};\n",
74
73
  "type": "registry:lib",
75
74
  "target": "components/shared-chat/shared-labels-fr.ts"
76
75
  }
@@ -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.9",
8
- "@hachej/boring-files@^0.4.9",
7
+ "@hachej/boring-ui-kit@^0.4.11",
8
+ "@hachej/boring-files@^0.4.11",
9
9
  "react@^19.0.0",
10
10
  "react-dom@^19.0.0",
11
11
  "zod@^4.6.5"
@@ -4,12 +4,12 @@
4
4
  "title": "Version history rows",
5
5
  "description": "Shared view-only version rows and menu metadata with relative time, provenance and Current. No file reads, publication or restoration.",
6
6
  "dependencies": [
7
- "@hachej/boring-files@^0.4.9",
7
+ "@hachej/boring-files@^0.4.11",
8
8
  "react@^19.0.0"
9
9
  ],
10
10
  "registryDependencies": [
11
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.9/r/theme.json",
12
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.9/r/button.json",
11
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.11/r/theme.json",
12
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.11/r/button.json",
13
13
  "button"
14
14
  ],
15
15
  "files": [
package/r/viewers.json CHANGED
@@ -4,8 +4,8 @@
4
4
  "title": "Viewers",
5
5
  "description": "The standard viewer top bar (title, status, viewer controls, Refresh, Share, Copy, Download, Open in new tab, Close, overflow menu) with Tailwind panes over it: the Markdown editor with a formatting toolbar and prose styling, the passive HTML preview and source editor, an image viewer with zoom and pan, a PDF viewer, and LocalFileViewer for browser-only files. Owned, editable source over the borrowed controllers; no AI SDK.",
6
6
  "dependencies": [
7
- "@hachej/boring-ui-kit@^0.4.9",
8
- "@hachej/boring-files@^0.4.9",
7
+ "@hachej/boring-ui-kit@^0.4.11",
8
+ "@hachej/boring-files@^0.4.11",
9
9
  "react@^19.0.0",
10
10
  "react-dom@^19.0.0",
11
11
  "marked@^17.0.6",
@@ -25,18 +25,18 @@
25
25
  "pdfjs-dist@6.3.289"
26
26
  ],
27
27
  "registryDependencies": [
28
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.9/r/theme.json",
29
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.9/r/utils.json",
30
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.9/r/button.json",
28
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.11/r/theme.json",
29
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.11/r/utils.json",
30
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.11/r/button.json",
31
31
  "button",
32
32
  "toggle",
33
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.9/r/version-history.json",
33
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.11/r/version-history.json",
34
34
  "toggle-group",
35
35
  "dialog",
36
36
  "textarea",
37
37
  "badge",
38
38
  "dropdown-menu",
39
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.9/r/markdown-editor.json"
39
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.11/r/markdown-editor.json"
40
40
  ],
41
41
  "files": [
42
42
  {
package/r/windows.json CHANGED
@@ -9,7 +9,7 @@
9
9
  "lucide-react@^1.51.0"
10
10
  ],
11
11
  "registryDependencies": [
12
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.9/r/utils.json",
12
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.11/r/utils.json",
13
13
  "button",
14
14
  "card",
15
15
  "badge",