@hachej/boring-ui-kit 0.4.37 → 0.4.38

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/dist/shared-chat/room.js +23 -7
  2. package/dist/shared-chat/source.d.ts +3 -0
  3. package/package.json +2 -2
  4. package/r/agent-target.json +4 -4
  5. package/r/app-shell.json +10 -10
  6. package/r/board-viewer.json +6 -6
  7. package/r/button.json +1 -1
  8. package/r/dock.json +1 -1
  9. package/r/documents.json +12 -12
  10. package/r/feedback-preview.json +1 -1
  11. package/r/feedback.json +6 -6
  12. package/r/file-tree.json +4 -4
  13. package/r/highlights.json +3 -3
  14. package/r/html-viewer.json +3 -3
  15. package/r/looks-viewer.json +2 -2
  16. package/r/maquette-viewer.json +1 -1
  17. package/r/markdown-editor.json +3 -3
  18. package/r/mockup-viewer.json +1 -1
  19. package/r/pi-ambient.json +8 -8
  20. package/r/pi-app-kinds.json +10 -10
  21. package/r/pi-app.json +10 -10
  22. package/r/pi-artifacts.json +2 -2
  23. package/r/pi-chat-approval.json +2 -2
  24. package/r/pi-chat-attachments.json +1 -1
  25. package/r/pi-chat-markdown.json +2 -2
  26. package/r/pi-chat-mentions.json +1 -1
  27. package/r/pi-chat-pickers.json +1 -1
  28. package/r/pi-chat-plugins.json +11 -11
  29. package/r/pi-chat-question.json +1 -1
  30. package/r/pi-chat-slash.json +2 -2
  31. package/r/pi-chat-subagent.json +1 -1
  32. package/r/pi-chat-voice.json +3 -3
  33. package/r/pi-chat.json +5 -5
  34. package/r/pi-workspace.json +3 -3
  35. package/r/placed-agent.json +4 -4
  36. package/r/pm-panel.json +14 -14
  37. package/r/provider-setup.json +1 -1
  38. package/r/registry.json +183 -183
  39. package/r/requests-board.json +1 -1
  40. package/r/shared-chat.json +9 -9
  41. package/r/task-board.json +2 -2
  42. package/r/task-list-viewer.json +2 -2
  43. package/r/task-mentions.json +4 -4
  44. package/r/version-history.json +2 -2
  45. package/r/viewer-code.json +2 -2
  46. package/r/viewer-frame.json +4 -4
  47. package/r/viewer-html.json +4 -4
  48. package/r/viewer-image.json +2 -2
  49. package/r/viewer-markdown.json +5 -5
  50. package/r/viewer-pdf.json +3 -3
  51. package/r/viewers.json +6 -6
  52. package/r/windows.json +1 -1
@@ -7,7 +7,7 @@
7
7
  "react@^19.0.0"
8
8
  ],
9
9
  "registryDependencies": [
10
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.37/r/utils.json"
10
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.38/r/utils.json"
11
11
  ],
12
12
  "files": [
13
13
  {
@@ -4,21 +4,21 @@
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.37",
7
+ "@hachej/boring-ui-kit@^0.4.38",
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.37/r/pi-chat.json",
13
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.37/r/pi-chat-plugins.json",
14
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.37/r/pi-chat-markdown.json",
15
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.37/r/pi-chat-mentions.json",
16
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.37/r/utils.json"
12
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.38/r/pi-chat.json",
13
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.38/r/pi-chat-plugins.json",
14
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.38/r/pi-chat-markdown.json",
15
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.38/r/pi-chat-mentions.json",
16
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.38/r/utils.json"
17
17
  ],
18
18
  "files": [
19
19
  {
20
20
  "path": "registry/shared-chat/shared-chat.tsx",
21
- "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 type { SharedViewOptions } from './text';\nimport { useSharedChatProps } from './use-shared-chat';\nimport { withChatPlugins } from '../pi-chat-plugins/chat-plugins';\n\nexport { useSharedChat, useSharedChatProps } from './use-shared-chat';\nexport type { SharedViewOptions, SharedTextOptions } from './text';\nexport { defaultSharedLabels } from './shared-labels';\nexport { frenchSharedLabels } from './shared-labels-fr';\nexport type { SharedLabels } from './shared-labels';\nexport { createSharedChat, createChannelSocket, createRoomSource, createFakeChannelSource, channelAvatarStates, cardActions, ChannelRequestError } from '@hachej/boring-ui-kit/shared-chat';\nexport type { RoomSource, RoomSourceOptions, RoomWords, Question } 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'>, SharedViewOptions {\n readonly controller: SharedChatController;\n readonly participantLabels?: Partial<SharedLabels>;\n}\n/**\n * Optional composition. PiChat still owns the regular shell, draft, send and composer lifecycle. The composer defaults to WhatsApp's:\n * one `pill` row and, with `voiceMessage`, the microphone in Send's place while empty; `composerLayout` and `voiceReplacesSend` override.\n * `composerStart` puts an app control at the left edge of the composer, `tools` beside Send.\n */\nexport function SharedChat({ participantLabels, presence, steps, answerActions, footer, answerStyle, ...props }: SharedChatProps) {\n const composition = useSharedChatProps(props.controller, { locale: props.locale, labels: participantLabels, presence, steps, answerActions, footer, answerStyle });\n return <PiChat {...withChatPlugins({ ...props, ...composition })} controls={<>{composition.controls}{props.controls}</>} composerChips={<>{props.composerChips}{composition.composerChips}</>}\n composerLayout={props.composerLayout ?? composition.composerLayout} voiceReplacesSend={props.voiceReplacesSend ?? composition.voiceReplacesSend} />;\n}\n",
21
+ "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 type { SharedViewOptions } from './text';\nimport { useSharedChatProps } from './use-shared-chat';\nimport { withChatPlugins } from '../pi-chat-plugins/chat-plugins';\n\nexport { useSharedChat, useSharedChatProps } from './use-shared-chat';\nexport type { SharedViewOptions, SharedTextOptions } from './text';\nexport { defaultSharedLabels } from './shared-labels';\nexport { frenchSharedLabels } from './shared-labels-fr';\nexport type { SharedLabels } from './shared-labels';\nexport { createSharedChat, createChannelSocket, createRoomSource, createFakeChannelSource, channelAvatarStates, cardActions, ChannelRequestError } from '@hachej/boring-ui-kit/shared-chat';\nexport type { RoomSource, RoomSourceOptions, RoomWords, Question } 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'>, SharedViewOptions {\n readonly controller: SharedChatController;\n readonly participantLabels?: Partial<SharedLabels>;\n}\n/**\n * Optional composition. PiChat still owns the regular shell, draft, send and composer lifecycle. The composer defaults to WhatsApp's:\n * one `pill` row and, with `voiceMessage`, the microphone in Send's place while empty; `composerLayout` and `voiceReplacesSend` override.\n * `composerStart` puts an app control at the left edge of the composer, `tools` beside Send.\n */\nexport function SharedChat({ participantLabels, presence, steps, answerActions, footer, answerStyle, artifacts, ...props }: SharedChatProps) {\n const composition = useSharedChatProps(props.controller, { locale: props.locale, labels: participantLabels, presence, steps, answerActions, footer, answerStyle, artifacts });\n return <PiChat {...withChatPlugins({ ...props, ...composition })} controls={<>{composition.controls}{props.controls}</>} composerChips={<>{props.composerChips}{composition.composerChips}</>}\n composerLayout={props.composerLayout ?? composition.composerLayout} voiceReplacesSend={props.voiceReplacesSend ?? composition.voiceReplacesSend} />;\n}\n",
22
22
  "type": "registry:component",
23
23
  "target": "components/shared-chat/shared-chat.tsx"
24
24
  },
@@ -30,7 +30,7 @@
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 { 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",
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 { artifactKey, parsePresentedArtifact } from '@hachej/boring-ui-kit/chat-view';\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 && view.artifacts?.Card && message.artifacts?.map((value, index) => {\n const descriptor = parsePresentedArtifact(value);\n if (!descriptor) return null;\n const config = view.artifacts!, Card = config.Card!;\n return <Card key={index} artifact={{ state: 'ready', id: artifactKey(descriptor), title: descriptor.title, path: descriptor.type === 'task' ? undefined : descriptor.target.resource.path, own: true, descriptor }}\n open={config.isOpen?.(descriptor as never) ?? false} onOpen={() => config.open(descriptor as never)} kinds={config.kinds} />;\n })}\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 // A question retains cards while waiting; the final reply takes their place without drawing a second copy.\n const latestArtifact = new Map<string, string>();\n const keyOf = (value: unknown) => {\n const artifact = parsePresentedArtifact(value);\n return artifact ? JSON.stringify(artifact.type === 'task' ? artifact.subject : [artifact.target, artifact.revision]) : undefined;\n };\n for (const message of shared.messages) if (!message.removed) for (const artifact of message.artifacts ?? []) {\n const key = keyOf(artifact); if (key) latestArtifact.set(key, message.id);\n }\n const working = new Map(shared.messages.filter(message => message.streaming).map(message => [message.author, message.id]));\n const groups: ChannelMessage[][] = [];\n for (const original of shared.messages) {\n const seen = new Set<string>();\n const artifacts = original.artifacts?.filter(artifact => {\n const key = keyOf(artifact);\n if (!key || seen.has(key) || latestArtifact.get(key) !== original.id) return false;\n seen.add(key); return true;\n });\n const message = artifacts && artifacts.length !== original.artifacts?.length ? { ...original, artifacts } : original;\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
  },
@@ -48,7 +48,7 @@
48
48
  },
49
49
  {
50
50
  "path": "registry/shared-chat/text.tsx",
51
- "content": "'use client';\nimport { createContext, useContext, useMemo } from 'react';\nimport type { ReactNode } from 'react';\nimport type { ChannelMessage } from '@hachej/boring-ui-kit/shared-chat';\nimport { useChatText as useNativeText, useShallowStable } from '../pi-chat/labels';\nimport { defaultSharedLabels } from './shared-labels';\nimport type { SharedLabels } from './shared-labels';\nimport { frenchSharedLabels } from './shared-labels-fr';\n\n/** How the room looks. Every default is today's look; a host turns pieces off or on to match its own plain chat chrome. */\nexport interface SharedViewOptions {\n /** `false`: no members control, no avatars and no author lines. Default `true`. */\n readonly presence?: boolean | undefined;\n /** `'hidden'`: the agent's steps are never shown (no « 3 steps » line, no progress). Default `'shown'`. */\n readonly steps?: 'shown' | 'hidden' | undefined;\n /** Actions on an answer; `'copy'` copies its text. Default none. */\n readonly answerActions?: readonly 'copy'[] | undefined;\n /** A line under each answer (« Generated in 1.0 s »). Keep it stable (memoised or module-level): an inline function changes the view on every render. */\n readonly footer?: ((message: ChannelMessage) => ReactNode) | undefined;\n /** `'plain'`: the agent's answer is plain text, not a bubble; people's messages keep theirs. Default `'bubble'`. */\n readonly answerStyle?: 'bubble' | 'plain' | undefined;\n}\nexport interface SharedTextOptions extends SharedViewOptions { readonly locale?: 'en' | 'fr' | undefined; readonly labels?: Partial<SharedLabels> | undefined }\nconst Context = createContext(defaultSharedLabels);\nconst View = createContext<SharedViewOptions>({});\nexport function SharedTextProvider({ children, locale, labels: given, presence, steps, answerActions: actions, footer, answerStyle }: SharedTextOptions & { readonly children: ReactNode }) {\n // Inline `labels` or `answerActions` keep one context while their content is the same (see useShallowStable).\n const labels = useShallowStable(given), answerActions = useShallowStable(actions);\n const value = useMemo(() => ({ ...(locale === 'fr' ? frenchSharedLabels : defaultSharedLabels), ...labels }), [locale, labels]);\n const view = useMemo<SharedViewOptions>(() => ({ presence, steps, answerActions, footer, answerStyle }), [presence, steps, answerActions, footer, answerStyle]);\n return <Context.Provider value={value}><View.Provider value={view}>{children}</View.Provider></Context.Provider>;\n}\nexport const useSharedView = () => useContext(View);\nexport function useChatText() {\n const native = useNativeText(), labels = useContext(Context);\n return useMemo(() => ({ ...native, labels: { ...native.labels, ...labels } }), [native, labels]);\n}\n",
51
+ "content": "'use client';\nimport { createContext, useContext, useMemo } from 'react';\nimport type { ReactNode } from 'react';\nimport type { ChannelMessage } from '@hachej/boring-ui-kit/shared-chat';\nimport { useChatText as useNativeText, useShallowStable } from '../pi-chat/labels';\nimport type { ChatArtifactsConfig } from '../pi-chat/config';\nimport { defaultSharedLabels } from './shared-labels';\nimport type { SharedLabels } from './shared-labels';\nimport { frenchSharedLabels } from './shared-labels-fr';\n\n/** How the room looks. Every default is today's look; a host turns pieces off or on to match its own plain chat chrome. */\nexport interface SharedViewOptions {\n /** Optional host cards/opener for exact native room descriptors. Does not grant resource read access. */\n readonly artifacts?: ChatArtifactsConfig | undefined;\n /** `false`: no members control, no avatars and no author lines. Default `true`. */\n readonly presence?: boolean | undefined;\n /** `'hidden'`: the agent's steps are never shown (no « 3 steps » line, no progress). Default `'shown'`. */\n readonly steps?: 'shown' | 'hidden' | undefined;\n /** Actions on an answer; `'copy'` copies its text. Default none. */\n readonly answerActions?: readonly 'copy'[] | undefined;\n /** A line under each answer (« Generated in 1.0 s »). Keep it stable (memoised or module-level): an inline function changes the view on every render. */\n readonly footer?: ((message: ChannelMessage) => ReactNode) | undefined;\n /** `'plain'`: the agent's answer is plain text, not a bubble; people's messages keep theirs. Default `'bubble'`. */\n readonly answerStyle?: 'bubble' | 'plain' | undefined;\n}\nexport interface SharedTextOptions extends SharedViewOptions { readonly locale?: 'en' | 'fr' | undefined; readonly labels?: Partial<SharedLabels> | undefined }\nconst Context = createContext(defaultSharedLabels);\nconst View = createContext<SharedViewOptions>({});\nexport function SharedTextProvider({ children, locale, labels: given, presence, steps, answerActions: actions, footer, answerStyle, artifacts }: SharedTextOptions & { readonly children: ReactNode }) {\n // Inline `labels` or `answerActions` keep one context while their content is the same (see useShallowStable).\n const labels = useShallowStable(given), answerActions = useShallowStable(actions);\n const value = useMemo(() => ({ ...(locale === 'fr' ? frenchSharedLabels : defaultSharedLabels), ...labels }), [locale, labels]);\n const view = useMemo<SharedViewOptions>(() => ({ presence, steps, answerActions, footer, answerStyle, artifacts }), [presence, steps, answerActions, footer, answerStyle, artifacts]);\n return <Context.Provider value={value}><View.Provider value={view}>{children}</View.Provider></Context.Provider>;\n}\nexport const useSharedView = () => useContext(View);\nexport function useChatText() {\n const native = useNativeText(), labels = useContext(Context);\n return useMemo(() => ({ ...native, labels: { ...native.labels, ...labels } }), [native, labels]);\n}\n",
52
52
  "type": "registry:component",
53
53
  "target": "components/shared-chat/text.tsx"
54
54
  },
package/r/task-board.json CHANGED
@@ -4,12 +4,12 @@
4
4
  "title": "Task board",
5
5
  "description": "Host-owned business requests: board, stacked task/file overlays and compact own-request rows. No agent or task backend required.",
6
6
  "dependencies": [
7
- "@hachej/boring-ui-kit@^0.4.37",
7
+ "@hachej/boring-ui-kit@^0.4.38",
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.37/r/utils.json"
12
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.38/r/utils.json"
13
13
  ],
14
14
  "files": [
15
15
  {
@@ -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.37",
8
- "@hachej/boring-files@^0.4.37",
7
+ "@hachej/boring-ui-kit@^0.4.38",
8
+ "@hachej/boring-files@^0.4.38",
9
9
  "react@^19.0.0",
10
10
  "react-dom@^19.0.0",
11
11
  "zod@^4.6.5"
@@ -4,14 +4,14 @@
4
4
  "title": "Task mentions",
5
5
  "description": "Optional authorized task catalog for the existing PM composer, composed with host file search.",
6
6
  "dependencies": [
7
- "@hachej/boring-ui-kit@^0.4.37",
7
+ "@hachej/boring-ui-kit@^0.4.38",
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.37/r/task-board.json",
13
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.37/r/pi-chat.json",
14
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.37/r/pi-chat-mentions.json"
12
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.38/r/task-board.json",
13
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.38/r/pi-chat.json",
14
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.38/r/pi-chat-mentions.json"
15
15
  ],
16
16
  "files": [
17
17
  {
@@ -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.37",
7
+ "@hachej/boring-files@^0.4.38",
8
8
  "react@^19.0.0"
9
9
  ],
10
10
  "registryDependencies": [
11
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.37/r/button.json"
11
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.38/r/button.json"
12
12
  ],
13
13
  "files": [
14
14
  {
@@ -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.37/r/viewer-frame.json",
12
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.37/r/pi-chat.json"
11
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.38/r/viewer-frame.json",
12
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.38/r/pi-chat.json"
13
13
  ],
14
14
  "files": [
15
15
  {
@@ -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.37",
7
+ "@hachej/boring-ui-kit@^0.4.38",
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.37/r/utils.json",
14
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.37/r/button.json",
15
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.37/r/version-history.json"
13
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.38/r/utils.json",
14
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.38/r/button.json",
15
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.38/r/version-history.json"
16
16
  ],
17
17
  "files": [
18
18
  {
@@ -4,14 +4,14 @@
4
4
  "title": "HTML viewer",
5
5
  "description": "The HTML kind: `htmlKind` (match, loader) and `HtmlPane`, the passive HTML preview (sandboxed when the host opts in) and source editor, in the viewer frame. The pane loads the first time an HTML file opens.",
6
6
  "dependencies": [
7
- "@hachej/boring-ui-kit@^0.4.37",
7
+ "@hachej/boring-ui-kit@^0.4.38",
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.37/r/viewer-frame.json",
13
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.37/r/utils.json",
14
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.37/r/button.json"
12
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.38/r/viewer-frame.json",
13
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.38/r/utils.json",
14
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.38/r/button.json"
15
15
  ],
16
16
  "files": [
17
17
  {
@@ -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.37/r/viewer-frame.json",
12
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.37/r/utils.json"
11
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.38/r/viewer-frame.json",
12
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.38/r/utils.json"
13
13
  ],
14
14
  "files": [
15
15
  {
@@ -4,7 +4,7 @@
4
4
  "title": "Markdown viewer",
5
5
  "description": "The Markdown kind: `markdownKind` (match, loader) and `MarkdownPane`, the tiptap editor with a formatting toolbar and prose styling over the kit's revisioned Markdown controller, in the viewer frame. The pane and tiptap load the first time a Markdown file opens.",
6
6
  "dependencies": [
7
- "@hachej/boring-ui-kit@^0.4.37",
7
+ "@hachej/boring-ui-kit@^0.4.38",
8
8
  "react@^19.0.0",
9
9
  "lucide-react@^1.51.0",
10
10
  "marked@^17.0.6",
@@ -22,10 +22,10 @@
22
22
  "@tiptap/extension-floating-menu@^3.31.4"
23
23
  ],
24
24
  "registryDependencies": [
25
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.37/r/viewer-frame.json",
26
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.37/r/markdown-editor.json",
27
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.37/r/utils.json",
28
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.37/r/button.json"
25
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.38/r/viewer-frame.json",
26
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.38/r/markdown-editor.json",
27
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.38/r/utils.json",
28
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.38/r/button.json"
29
29
  ],
30
30
  "files": [
31
31
  {
package/r/viewer-pdf.json CHANGED
@@ -4,14 +4,14 @@
4
4
  "title": "PDF viewer",
5
5
  "description": "The PDF kind: `pdfKind` (match, loader) and `PdfPane` in the viewer frame (the browser's viewer, or pdf.js pages where there is none), and LocalFileViewer for browser-only files. The pane loads the first time a PDF opens.",
6
6
  "dependencies": [
7
- "@hachej/boring-files@^0.4.37",
7
+ "@hachej/boring-files@^0.4.38",
8
8
  "react@^19.0.0",
9
9
  "lucide-react@^1.51.0",
10
10
  "pdfjs-dist@6.4.299"
11
11
  ],
12
12
  "registryDependencies": [
13
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.37/r/viewer-frame.json",
14
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.37/r/viewer-image.json"
13
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.38/r/viewer-frame.json",
14
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.38/r/viewer-image.json"
15
15
  ],
16
16
  "files": [
17
17
  {
package/r/viewers.json CHANGED
@@ -4,12 +4,12 @@
4
4
  "title": "Viewers",
5
5
  "description": "Deprecated aggregate: installs every viewer item (viewer-frame, viewer-markdown, viewer-html, viewer-image, viewer-pdf, viewer-code). Add the items you use instead.",
6
6
  "registryDependencies": [
7
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.37/r/viewer-frame.json",
8
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.37/r/viewer-markdown.json",
9
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.37/r/viewer-html.json",
10
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.37/r/viewer-image.json",
11
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.37/r/viewer-pdf.json",
12
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.37/r/viewer-code.json"
7
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.38/r/viewer-frame.json",
8
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.38/r/viewer-markdown.json",
9
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.38/r/viewer-html.json",
10
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.38/r/viewer-image.json",
11
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.38/r/viewer-pdf.json",
12
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.38/r/viewer-code.json"
13
13
  ],
14
14
  "files": [],
15
15
  "type": "registry:component"
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.37/r/utils.json"
12
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.38/r/utils.json"
13
13
  ],
14
14
  "files": [
15
15
  {