@hachej/boring-ui-kit 0.4.58 → 0.4.59

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/bin/boring-ui-blocks.mjs +49 -4
  2. package/package.json +6 -6
  3. package/r/_shared.json +19 -0
  4. package/r/agent-target.json +4 -4
  5. package/r/app-shell.json +17 -17
  6. package/r/board-viewer.json +10 -10
  7. package/r/button.json +4 -4
  8. package/r/data-table.json +21 -0
  9. package/r/diagram-viewer.json +6 -4
  10. package/r/dock.json +1 -1
  11. package/r/documents.json +13 -14
  12. package/r/drawing-viewer.json +6 -4
  13. package/r/empty-state.json +22 -0
  14. package/r/feedback.json +14 -14
  15. package/r/file-tree.json +4 -4
  16. package/r/highlights.json +3 -3
  17. package/r/html-viewer.json +3 -3
  18. package/r/kpi-stat.json +22 -0
  19. package/r/list-row.json +21 -0
  20. package/r/looks-viewer.json +6 -6
  21. package/r/maquette-viewer.json +7 -6
  22. package/r/markdown-editor.json +10 -9
  23. package/r/mockup-viewer.json +5 -5
  24. package/r/page-header.json +22 -0
  25. package/r/pi-ambient.json +12 -12
  26. package/r/pi-app-kinds.json +10 -10
  27. package/r/pi-app.json +17 -17
  28. package/r/pi-artifacts.json +4 -4
  29. package/r/pi-chat-approval.json +4 -4
  30. package/r/pi-chat-attachments.json +4 -4
  31. package/r/pi-chat-markdown.json +3 -3
  32. package/r/pi-chat-mentions.json +4 -4
  33. package/r/pi-chat-pickers.json +4 -4
  34. package/r/pi-chat-plugins.json +11 -11
  35. package/r/pi-chat-question.json +3 -3
  36. package/r/pi-chat-slash.json +5 -5
  37. package/r/pi-chat-subagent.json +4 -4
  38. package/r/pi-chat-voice.json +7 -7
  39. package/r/pi-chat.json +17 -17
  40. package/r/pi-workspace.json +3 -3
  41. package/r/placed-agent.json +7 -7
  42. package/r/provider-setup.json +4 -4
  43. package/r/record-viewer.json +5 -3
  44. package/r/registry.json +494 -280
  45. package/r/shared-chat.json +11 -11
  46. package/r/spaces.json +7 -5
  47. package/r/task-list-viewer.json +28 -3
  48. package/r/theme.json +115 -50
  49. package/r/version-history.json +2 -2
  50. package/r/viewer-code.json +5 -5
  51. package/r/viewer-frame.json +9 -9
  52. package/r/viewer-html.json +7 -7
  53. package/r/viewer-image.json +5 -5
  54. package/r/viewer-markdown.json +7 -7
  55. package/r/viewer-pdf.json +6 -6
  56. package/r/viewers.json +6 -6
  57. package/r/windows.json +4 -4
@@ -4,16 +4,16 @@
4
4
  "title": "Shared chat",
5
5
  "description": "Optional multi-party host log presentation over PiChat's generic composition seams. No agent runtime or speaking policy.",
6
6
  "dependencies": [
7
- "@hachej/boring-ui-kit@^0.4.58",
8
- "react@^19.0.0",
9
- "lucide-react@^1.51.0"
7
+ "@hachej/boring-ui-kit@^0.4.59",
8
+ "react@^19.0.0"
10
9
  ],
11
10
  "registryDependencies": [
12
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.58/r/pi-chat.json",
13
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.58/r/pi-chat-plugins.json",
14
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.58/r/pi-chat-markdown.json",
15
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.58/r/pi-chat-mentions.json",
16
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.58/r/utils.json"
11
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/pi-chat.json",
12
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/pi-chat-plugins.json",
13
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/pi-chat-markdown.json",
14
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/pi-chat-mentions.json",
15
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/utils.json",
16
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/_shared.json"
17
17
  ],
18
18
  "files": [
19
19
  {
@@ -24,13 +24,13 @@
24
24
  },
25
25
  {
26
26
  "path": "registry/shared-chat/use-shared-chat.tsx",
27
- "content": "'use client';\nimport { useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';\nimport type { ReactNode } from 'react';\nimport { UserIcon } from 'lucide-react';\nimport { useControllerPerConversation } from '@hachej/boring-ui-kit/chat-react';\nimport { createSharedChat } from '@hachej/boring-ui-kit/shared-chat';\nimport type { ChannelSource, Conversation, SharedChatController } from '@hachej/boring-ui-kit/shared-chat';\nimport type { PiChatProps } from '../pi-chat/pi-chat';\nimport type { ConversationsConfig, MentionsConfig } from '../pi-chat/config';\nimport { MentionPicker } from '../pi-chat-mentions/mention-menu';\nimport { Badge } from '@/components/ui/badge';\nimport { Button } from '@/components/ui/button';\nimport { ItemContent, ItemDescription, ItemMedia, ItemTitle } from '@/components/ui/item';\nimport { namedMember } from './names';\nimport { MemberAvatar, SharedMembers, ReplyQuote, sharedQuote } from './messages';\nimport { SharedTimeline } from './timeline';\nimport type { TimelineApi } from './timeline';\nimport { SharedTextProvider } from './text';\nimport type { SharedTextOptions } from './text';\nimport { defaultSharedLabels } from './shared-labels';\nimport { frenchSharedLabels } from './shared-labels-fr';\n\nconst subscribeNothing = () => () => {};\n/** Explicit host composition through PiChat's generic transcript, composer and catalog seams. */\nexport function useSharedChatProps(controller: SharedChatController | undefined, options: SharedTextOptions = {}): Omit<PiChatProps, 'controller'> {\n const state = useSyncExternalStore(controller?.subscribe ?? subscribeNothing, () => controller?.getSnapshot(), () => controller?.getSnapshot());\n const api = useRef<TimelineApi | undefined>(undefined);\n const raw = state?.shared.members;\n // Plain names only: an id or a slug the host sent in a name's place never reaches the picker, a chip or a mention.\n const meId = state?.shared.me.id;\n const labels = { ...(options.locale === 'fr' ? frenchSharedLabels : defaultSharedLabels), ...options.labels };\n const nameOf = labels.sharedName, sharedMembers = labels.sharedMembers, sharedRole = labels.sharedRole;\n const members = useMemo(() => (raw ?? []).map(member => namedMember(member, nameOf)), [raw, nameOf]);\n const mentions = useMemo<MentionsConfig>(() => ({\n listLabel: sharedMembers, icon: <UserIcon aria-hidden=\"true\" />, Picker: MentionPicker,\n label: id => members.find(member => member.id === id)?.name ?? namedMember({ id, name: id, kind: 'person' }, nameOf).name,\n search: async query => members.filter(member => member.id !== meId && member.name.toLowerCase().includes(query.toLowerCase()))\n .sort((a, b) => Number(b.kind === 'agent') - Number(a.kind === 'agent')).map(member => ({ path: member.id, label: member.name })),\n render: id => <Badge variant=\"secondary\">@{(members.find(member => member.id === id) ?? namedMember({ id, name: id, kind: 'person' }, nameOf)).name}</Badge>,\n renderSent: id => {\n const member = members.find(member => member.id === id) ?? namedMember({ id, name: id, kind: 'person' }, nameOf);\n return <span data-testid=\"message-mention\" data-path={id} className=\"inline-flex items-baseline gap-1 align-baseline font-medium\"><span aria-hidden=\"true\"><MemberAvatar member={member} compact interactive={false} /></span><span data-testid=\"mention-name\">{member.name}</span></span>;\n },\n renderOption: item => {\n const member = members.find(member => member.id === item.path);\n const role = member ? sharedRole(member) : undefined;\n return <><ItemMedia variant=\"icon\">{member ? <MemberAvatar member={member} compact interactive={false} indicator={false} /> : <UserIcon aria-hidden=\"true\" />}</ItemMedia><ItemContent className=\"min-w-0\"><ItemTitle>{item.label}</ItemTitle>{role && <ItemDescription className=\"m-0 truncate\">{role}</ItemDescription>}</ItemContent></>;\n },\n }), [members, meId, nameOf, sharedMembers, sharedRole]);\n const wrap = (children: ReactNode) => <SharedTextProvider {...options}>{children}</SharedTextProvider>;\n return {\n locale: options.locale,\n timeline: { count: state?.shared.messages.length ?? 0,\n render: context => controller ? wrap(<SharedTimeline controller={controller} api={api} mentions={mentions} {...context} />) : null,\n afterSend: () => api.current?.reset() },\n controls: controller && options.presence !== false ? wrap(<SharedMembers controller={controller} />) : undefined,\n mentions,\n composerHeader: controller && state?.shared.replyTo ? wrap(<ReplyQuote targetId={state.shared.replyTo} quote={sharedQuote(state.shared, state.shared.replyTo, nameOf)} onReveal={id => api.current?.reveal(id)} onRemove={() => controller.setReplyTo(undefined)} />) : undefined,\n composerHasContent: Boolean(state?.shared.files.length),\n onPickFiles: files => { if (controller) controller.setFiles([...controller.getSnapshot().shared.files, ...files]); },\n composerChips: controller && wrap(state?.shared.files.map((file, index) => <Badge key={index} variant=\"secondary\">{file.name}<Button variant=\"ghost\" size=\"icon\" aria-label={`Remove ${file.name}`} onClick={() => controller.setFiles(controller.getSnapshot().shared.files.filter((_, at) => at !== index))}><span aria-hidden=\"true\">×</span></Button></Badge>)),\n renderConnectionError: error => error instanceof Error ? error.message : String(error),\n // WhatsApp's composer; a host spreading these props can still override either.\n composerLayout: 'pill', voiceReplacesSend: true,\n };\n}\n\n/** Owns only the adapters it creates; each adapter borrows the host source. `limit`: messages per history page read from the source (default 60). */\nexport function useSharedChat(source: ChannelSource, options: SharedTextOptions & { readonly limit?: number | undefined } = {}) {\n const [list, setList] = useState<{ source: ChannelSource; items: Conversation[] }>();\n const [selection, setSelection] = useState<{ source: ChannelSource; id: string }>();\n // A failure belongs to the source that raised it: another source starts clean.\n const [failure, setFailure] = useState<{ source: ChannelSource; message: string }>();\n const error = failure?.source === source ? failure.message : undefined;\n const items = list?.source === source ? list.items : [];\n const conversationId = selection?.source === source ? selection.id : items[0]?.id;\n useEffect(() => {\n let off = false;\n void source.conversations().then(items => { if (!off) setList({ source, items }); }, cause => { if (!off) setFailure({ source, message: String(cause) }); });\n return () => { off = true; };\n }, [source]);\n const principalId = source.me.id;\n const key = useMemo(() => conversationId === undefined ? undefined : { source, conversationId, principalId, limit: options.limit }, [source, conversationId, principalId, options.limit]);\n const selected = useControllerPerConversation({ key,\n create: () => createSharedChat(source, { conversationId: conversationId!, ...(options.limit === undefined ? {} : { limit: options.limit }) }),\n });\n const controller = selected.status === 'ready' ? selected.controller : undefined;\n const conversations: ConversationsConfig = { items, activeId: conversationId, loading: list?.source !== source, onSelect: id => setSelection({ source, id }) };\n const chat = useSharedChatProps(controller, options);\n return { controller, conversationId, conversations, error,\n chatProps: controller ? { ...chat, controller, conversations } : undefined,\n workspaceProps: { controller, conversationId, conversations, locale: options.locale, controls: chat.controls, chat },\n };\n}\n",
27
+ "content": "'use client';\nimport { useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';\nimport type { ReactNode } from 'react';\nimport { UserIcon } from '../_shared/icons';\nimport { useControllerPerConversation } from '@hachej/boring-ui-kit/chat-react';\nimport { createSharedChat } from '@hachej/boring-ui-kit/shared-chat';\nimport type { ChannelSource, Conversation, SharedChatController } from '@hachej/boring-ui-kit/shared-chat';\nimport type { PiChatProps } from '../pi-chat/pi-chat';\nimport type { ConversationsConfig, MentionsConfig } from '../pi-chat/config';\nimport { MentionPicker } from '../pi-chat-mentions/mention-menu';\nimport { Badge } from '@/components/ui/badge';\nimport { Button } from '@/components/ui/button';\nimport { ItemContent, ItemDescription, ItemMedia, ItemTitle } from '@/components/ui/item';\nimport { namedMember } from './names';\nimport { MemberAvatar, SharedMembers, ReplyQuote, sharedQuote } from './messages';\nimport { SharedTimeline } from './timeline';\nimport type { TimelineApi } from './timeline';\nimport { SharedTextProvider } from './text';\nimport type { SharedTextOptions } from './text';\nimport { defaultSharedLabels } from './shared-labels';\nimport { frenchSharedLabels } from './shared-labels-fr';\n\nconst subscribeNothing = () => () => {};\n/** Explicit host composition through PiChat's generic transcript, composer and catalog seams. */\nexport function useSharedChatProps(controller: SharedChatController | undefined, options: SharedTextOptions = {}): Omit<PiChatProps, 'controller'> {\n const state = useSyncExternalStore(controller?.subscribe ?? subscribeNothing, () => controller?.getSnapshot(), () => controller?.getSnapshot());\n const api = useRef<TimelineApi | undefined>(undefined);\n const raw = state?.shared.members;\n // Plain names only: an id or a slug the host sent in a name's place never reaches the picker, a chip or a mention.\n const meId = state?.shared.me.id;\n const labels = { ...(options.locale === 'fr' ? frenchSharedLabels : defaultSharedLabels), ...options.labels };\n const nameOf = labels.sharedName, sharedMembers = labels.sharedMembers, sharedRole = labels.sharedRole;\n const members = useMemo(() => (raw ?? []).map(member => namedMember(member, nameOf)), [raw, nameOf]);\n const mentions = useMemo<MentionsConfig>(() => ({\n listLabel: sharedMembers, icon: <UserIcon aria-hidden=\"true\" />, Picker: MentionPicker,\n label: id => members.find(member => member.id === id)?.name ?? namedMember({ id, name: id, kind: 'person' }, nameOf).name,\n search: async query => members.filter(member => member.id !== meId && member.name.toLowerCase().includes(query.toLowerCase()))\n .sort((a, b) => Number(b.kind === 'agent') - Number(a.kind === 'agent')).map(member => ({ path: member.id, label: member.name })),\n render: id => <Badge variant=\"secondary\">@{(members.find(member => member.id === id) ?? namedMember({ id, name: id, kind: 'person' }, nameOf)).name}</Badge>,\n renderSent: id => {\n const member = members.find(member => member.id === id) ?? namedMember({ id, name: id, kind: 'person' }, nameOf);\n return <span data-testid=\"message-mention\" data-path={id} className=\"inline-flex items-baseline gap-1 align-baseline font-medium\"><span aria-hidden=\"true\"><MemberAvatar member={member} compact interactive={false} /></span><span data-testid=\"mention-name\">{member.name}</span></span>;\n },\n renderOption: item => {\n const member = members.find(member => member.id === item.path);\n const role = member ? sharedRole(member) : undefined;\n return <><ItemMedia variant=\"icon\">{member ? <MemberAvatar member={member} compact interactive={false} indicator={false} /> : <UserIcon aria-hidden=\"true\" />}</ItemMedia><ItemContent className=\"min-w-0\"><ItemTitle>{item.label}</ItemTitle>{role && <ItemDescription className=\"m-0 truncate\">{role}</ItemDescription>}</ItemContent></>;\n },\n }), [members, meId, nameOf, sharedMembers, sharedRole]);\n const wrap = (children: ReactNode) => <SharedTextProvider {...options}>{children}</SharedTextProvider>;\n return {\n locale: options.locale,\n timeline: { count: state?.shared.messages.length ?? 0,\n render: context => controller ? wrap(<SharedTimeline controller={controller} api={api} mentions={mentions} {...context} />) : null,\n afterSend: () => api.current?.reset() },\n controls: controller && options.presence !== false ? wrap(<SharedMembers controller={controller} />) : undefined,\n mentions,\n composerHeader: controller && state?.shared.replyTo ? wrap(<ReplyQuote targetId={state.shared.replyTo} quote={sharedQuote(state.shared, state.shared.replyTo, nameOf)} onReveal={id => api.current?.reveal(id)} onRemove={() => controller.setReplyTo(undefined)} />) : undefined,\n composerHasContent: Boolean(state?.shared.files.length),\n onPickFiles: files => { if (controller) controller.setFiles([...controller.getSnapshot().shared.files, ...files]); },\n composerChips: controller && wrap(state?.shared.files.map((file, index) => <Badge key={index} variant=\"secondary\">{file.name}<Button variant=\"ghost\" size=\"icon\" aria-label={`Remove ${file.name}`} onClick={() => controller.setFiles(controller.getSnapshot().shared.files.filter((_, at) => at !== index))}><span aria-hidden=\"true\">×</span></Button></Badge>)),\n renderConnectionError: error => error instanceof Error ? error.message : String(error),\n // WhatsApp's composer; a host spreading these props can still override either.\n composerLayout: 'pill', voiceReplacesSend: true,\n };\n}\n\n/** Owns only the adapters it creates; each adapter borrows the host source. `limit`: messages per history page read from the source (default 60). */\nexport function useSharedChat(source: ChannelSource, options: SharedTextOptions & { readonly limit?: number | undefined } = {}) {\n const [list, setList] = useState<{ source: ChannelSource; items: Conversation[] }>();\n const [selection, setSelection] = useState<{ source: ChannelSource; id: string }>();\n // A failure belongs to the source that raised it: another source starts clean.\n const [failure, setFailure] = useState<{ source: ChannelSource; message: string }>();\n const error = failure?.source === source ? failure.message : undefined;\n const items = list?.source === source ? list.items : [];\n const conversationId = selection?.source === source ? selection.id : items[0]?.id;\n useEffect(() => {\n let off = false;\n void source.conversations().then(items => { if (!off) setList({ source, items }); }, cause => { if (!off) setFailure({ source, message: String(cause) }); });\n return () => { off = true; };\n }, [source]);\n const principalId = source.me.id;\n const key = useMemo(() => conversationId === undefined ? undefined : { source, conversationId, principalId, limit: options.limit }, [source, conversationId, principalId, options.limit]);\n const selected = useControllerPerConversation({ key,\n create: () => createSharedChat(source, { conversationId: conversationId!, ...(options.limit === undefined ? {} : { limit: options.limit }) }),\n });\n const controller = selected.status === 'ready' ? selected.controller : undefined;\n const conversations: ConversationsConfig = { items, activeId: conversationId, loading: list?.source !== source, onSelect: id => setSelection({ source, id }) };\n const chat = useSharedChatProps(controller, options);\n return { controller, conversationId, conversations, error,\n chatProps: controller ? { ...chat, controller, conversations } : undefined,\n workspaceProps: { controller, conversationId, conversations, locale: options.locale, controls: chat.controls, chat },\n };\n}\n",
28
28
  "type": "registry:component",
29
29
  "target": "components/shared-chat/use-shared-chat.tsx"
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 { 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 if (!open) return null;\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 && (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 = !message.question?.answered && 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.question?.answered && 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",
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 '../_shared/icons';\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 if (!open) return null;\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 && (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 = !message.question?.answered && 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.question?.answered && 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
  },
@@ -42,7 +42,7 @@
42
42
  },
43
43
  {
44
44
  "path": "registry/shared-chat/host-trace.tsx",
45
- "content": "'use client';\nimport { useEffect, useState } from 'react';\nimport { AlertCircleIcon, CheckIcon, WrenchIcon } from 'lucide-react';\nimport type { Step } from '@hachej/boring-ui-kit/shared-chat';\nimport { Spinner } from '@/components/ui/spinner';\nimport { Trace, TraceCollapsible, TraceCollapsibleContent, TraceCollapsibleTrigger, TraceIcon, TraceLine, TraceTitle } from '@/components/ui/trace';\nimport { useChatText } from './text';\n\n/** Host observations only. This trace neither creates Pi tool calls nor runs work. */\nexport function HostTrace({ steps, participant, streaming, active }: { readonly steps: readonly Step[]; readonly participant: string; readonly streaming: boolean; readonly active: boolean }) {\n const { labels } = useChatText();\n const [chosen, setChosen] = useState<boolean | undefined>();\n // Another participant or a run that starts or ends drops the reader's choice.\n // eslint-disable-next-line react-hooks/set-state-in-effect -- resets the choice once per participant or run change, after commit\n useEffect(() => setChosen(undefined), [participant, streaming]);\n const open = chosen ?? false;\n const state = streaming ? 'running' : steps.some(step => step.status === 'failed') ? 'failed' : 'done';\n const running = steps.reduce((found, step, index) => found === -1 && step.status === 'running' ? index : found, -1);\n const current = steps[running];\n const progress = current ? ` · ${current.label} (${running + 1}/${steps.length})` : '';\n const Icon = streaming ? Spinner : state === 'failed' ? AlertCircleIcon : CheckIcon;\n return <Trace data-testid=\"activity\" data-boring=\"activity\" data-state={state} data-open={open ? 'true' : 'false'} className=\"my-1.5\">\n <TraceCollapsible open={open} onOpenChange={setChosen}>\n <TraceCollapsibleTrigger className=\"min-h-8 max-md:min-h-11 pointer-coarse:min-h-11\">\n <TraceLine {...(streaming ? { status: 'running' } : state === 'failed' ? { status: 'error' } : {})}>\n {!(open || streaming && !active) && <TraceIcon><Icon aria-hidden=\"true\" /></TraceIcon>}\n <TraceTitle data-testid=\"activity-label\">{streaming ? `${labels.sharedWorking(participant)}${progress}` : labels.steps(steps.length)}</TraceTitle>\n </TraceLine>\n </TraceCollapsibleTrigger>\n <TraceCollapsibleContent><Trace data-testid=\"activity-steps\">\n {steps.map((step, index) => {\n const StepIcon = step.status === 'running' ? open && active && streaming && index === running ? Spinner : WrenchIcon : step.status === 'done' ? CheckIcon : AlertCircleIcon;\n return <div key={index} data-testid=\"activity-step\" data-kind=\"host\" data-status={step.status === 'done' ? 'completed' : step.status}>\n <TraceLine data-testid=\"host-step\" {...(step.status === 'running' ? { status: 'running' } : step.status === 'failed' ? { status: 'error' } : {})}>\n <TraceIcon><StepIcon aria-hidden=\"true\" /></TraceIcon><TraceTitle><div className=\"flex min-w-0 flex-col\"><span>{step.label}</span>{step.detail && <pre data-testid=\"step-detail\" className=\"m-0 text-xs font-mono whitespace-pre-wrap text-muted-foreground [overflow-wrap:anywhere]\">{step.detail}</pre>}</div></TraceTitle>\n </TraceLine>\n </div>;\n })}\n </Trace></TraceCollapsibleContent>\n </TraceCollapsible>\n </Trace>;\n}\n",
45
+ "content": "'use client';\nimport { useEffect, useState } from 'react';\nimport { AlertCircleIcon, CheckIcon, WrenchIcon } from '../_shared/icons';\nimport type { Step } from '@hachej/boring-ui-kit/shared-chat';\nimport { Spinner } from '@/components/ui/spinner';\nimport { Trace, TraceCollapsible, TraceCollapsibleContent, TraceCollapsibleTrigger, TraceIcon, TraceLine, TraceTitle } from '@/components/ui/trace';\nimport { useChatText } from './text';\n\n/** Host observations only. This trace neither creates Pi tool calls nor runs work. */\nexport function HostTrace({ steps, participant, streaming, active }: { readonly steps: readonly Step[]; readonly participant: string; readonly streaming: boolean; readonly active: boolean }) {\n const { labels } = useChatText();\n const [chosen, setChosen] = useState<boolean | undefined>();\n // Another participant or a run that starts or ends drops the reader's choice.\n // eslint-disable-next-line react-hooks/set-state-in-effect -- resets the choice once per participant or run change, after commit\n useEffect(() => setChosen(undefined), [participant, streaming]);\n const open = chosen ?? false;\n const state = streaming ? 'running' : steps.some(step => step.status === 'failed') ? 'failed' : 'done';\n const running = steps.reduce((found, step, index) => found === -1 && step.status === 'running' ? index : found, -1);\n const current = steps[running];\n const progress = current ? ` · ${current.label} (${running + 1}/${steps.length})` : '';\n const Icon = streaming ? Spinner : state === 'failed' ? AlertCircleIcon : CheckIcon;\n return <Trace data-testid=\"activity\" data-boring=\"activity\" data-state={state} data-open={open ? 'true' : 'false'} className=\"my-1.5\">\n <TraceCollapsible open={open} onOpenChange={setChosen}>\n <TraceCollapsibleTrigger className=\"min-h-8 max-md:min-h-11 pointer-coarse:min-h-11\">\n <TraceLine {...(streaming ? { status: 'running' } : state === 'failed' ? { status: 'error' } : {})}>\n {!(open || streaming && !active) && <TraceIcon><Icon aria-hidden=\"true\" /></TraceIcon>}\n <TraceTitle data-testid=\"activity-label\">{streaming ? `${labels.sharedWorking(participant)}${progress}` : labels.steps(steps.length)}</TraceTitle>\n </TraceLine>\n </TraceCollapsibleTrigger>\n <TraceCollapsibleContent><Trace data-testid=\"activity-steps\">\n {steps.map((step, index) => {\n const StepIcon = step.status === 'running' ? open && active && streaming && index === running ? Spinner : WrenchIcon : step.status === 'done' ? CheckIcon : AlertCircleIcon;\n return <div key={index} data-testid=\"activity-step\" data-kind=\"host\" data-status={step.status === 'done' ? 'completed' : step.status}>\n <TraceLine data-testid=\"host-step\" {...(step.status === 'running' ? { status: 'running' } : step.status === 'failed' ? { status: 'error' } : {})}>\n <TraceIcon><StepIcon aria-hidden=\"true\" /></TraceIcon><TraceTitle><div className=\"flex min-w-0 flex-col\"><span>{step.label}</span>{step.detail && <pre data-testid=\"step-detail\" className=\"m-0 text-xs font-mono whitespace-pre-wrap text-muted-foreground [overflow-wrap:anywhere]\">{step.detail}</pre>}</div></TraceTitle>\n </TraceLine>\n </div>;\n })}\n </Trace></TraceCollapsibleContent>\n </TraceCollapsible>\n </Trace>;\n}\n",
46
46
  "type": "registry:component",
47
47
  "target": "components/shared-chat/host-trace.tsx"
48
48
  },
package/r/spaces.json CHANGED
@@ -4,9 +4,11 @@
4
4
  "title": "Spaces",
5
5
  "description": "A client for a host's Spaces service: useSpaces, SpaceSwitcher and MembersSheet over a transport the app gives. Membership and roles stay the host's; no route or app wording.",
6
6
  "dependencies": [
7
- "@hachej/boring-files@^0.4.58",
8
- "react@^19.0.0",
9
- "lucide-react@^1.51.0"
7
+ "@hachej/boring-files@^0.4.59",
8
+ "react@^19.0.0"
9
+ ],
10
+ "registryDependencies": [
11
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/_shared.json"
10
12
  ],
11
13
  "files": [
12
14
  {
@@ -29,13 +31,13 @@
29
31
  },
30
32
  {
31
33
  "path": "registry/spaces/space-switcher.tsx",
32
- "content": "'use client';\n\n// Choosing the open space among the person's own.\nimport { CheckIcon, ChevronsUpDownIcon } from 'lucide-react';\nimport { Button } from '@/components/ui/button';\nimport { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';\nimport { spacesLabels } from './spaces-labels';\nimport type { SpacesLabels } from './spaces-labels';\nimport type { SpaceSummary } from './use-spaces';\n\nexport interface SpaceSwitcherProps {\n /** What a space is called here, as a heading (\"Projects\"). */\n readonly label: string;\n readonly spaces: readonly SpaceSummary[];\n readonly current: SpaceSummary | null;\n readonly onSelect: (id: string) => void;\n /** The word for a role (default: the role's id). */\n readonly roleLabel?: (role: string) => string;\n readonly labels?: SpacesLabels;\n}\n\nexport function SpaceSwitcher({ label, spaces, current, onSelect, roleLabel = role => role, labels = spacesLabels }: SpaceSwitcherProps) {\n return <div className=\"flex items-center gap-2\" data-testid=\"space-switcher\">\n <DropdownMenu>\n <DropdownMenuTrigger render={<Button variant=\"outline\" className=\"max-w-72 justify-between gap-2\" />} data-testid=\"space-switcher-trigger\">\n <span className=\"truncate\">{current?.title ?? labels.choose(label)}</span>\n <ChevronsUpDownIcon className=\"size-4 text-muted-foreground\" aria-hidden=\"true\" />\n </DropdownMenuTrigger>\n <DropdownMenuContent align=\"start\" className=\"w-72\">\n <DropdownMenuGroup>\n <DropdownMenuLabel>{label}</DropdownMenuLabel>\n {spaces.length === 0 && <div className=\"px-2 py-1.5 text-sm text-muted-foreground\">{labels.noSpaces}</div>}\n {spaces.map(space => <DropdownMenuItem key={space.id} onClick={() => onSelect(space.id)} data-testid={`space-${space.id}`}>\n <CheckIcon className={space.id === current?.id ? 'size-4' : 'size-4 invisible'} aria-hidden=\"true\" />\n <span className=\"flex-1 truncate\">{space.title}</span>\n <span className=\"text-xs text-muted-foreground\">{roleLabel(space.role)}</span>\n </DropdownMenuItem>)}\n </DropdownMenuGroup>\n </DropdownMenuContent>\n </DropdownMenu>\n </div>;\n}\n",
34
+ "content": "'use client';\n\n// Choosing the open space among the person's own.\nimport { CheckIcon, ChevronsUpDownIcon } from '../_shared/icons';\nimport { Button } from '@/components/ui/button';\nimport { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';\nimport { spacesLabels } from './spaces-labels';\nimport type { SpacesLabels } from './spaces-labels';\nimport type { SpaceSummary } from './use-spaces';\n\nexport interface SpaceSwitcherProps {\n /** What a space is called here, as a heading (\"Projects\"). */\n readonly label: string;\n readonly spaces: readonly SpaceSummary[];\n readonly current: SpaceSummary | null;\n readonly onSelect: (id: string) => void;\n /** The word for a role (default: the role's id). */\n readonly roleLabel?: (role: string) => string;\n readonly labels?: SpacesLabels;\n}\n\nexport function SpaceSwitcher({ label, spaces, current, onSelect, roleLabel = role => role, labels = spacesLabels }: SpaceSwitcherProps) {\n return <div className=\"flex items-center gap-2\" data-testid=\"space-switcher\">\n <DropdownMenu>\n <DropdownMenuTrigger render={<Button variant=\"outline\" className=\"max-w-72 justify-between gap-2\" />} data-testid=\"space-switcher-trigger\">\n <span className=\"truncate\">{current?.title ?? labels.choose(label)}</span>\n <ChevronsUpDownIcon className=\"size-4 text-muted-foreground\" aria-hidden=\"true\" />\n </DropdownMenuTrigger>\n <DropdownMenuContent align=\"start\" className=\"w-72\">\n <DropdownMenuGroup>\n <DropdownMenuLabel>{label}</DropdownMenuLabel>\n {spaces.length === 0 && <div className=\"px-2 py-1.5 text-sm text-muted-foreground\">{labels.noSpaces}</div>}\n {spaces.map(space => <DropdownMenuItem key={space.id} onClick={() => onSelect(space.id)} data-testid={`space-${space.id}`}>\n <CheckIcon className={space.id === current?.id ? 'size-4' : 'size-4 invisible'} aria-hidden=\"true\" />\n <span className=\"flex-1 truncate\">{space.title}</span>\n <span className=\"text-xs text-muted-foreground\">{roleLabel(space.role)}</span>\n </DropdownMenuItem>)}\n </DropdownMenuGroup>\n </DropdownMenuContent>\n </DropdownMenu>\n </div>;\n}\n",
33
35
  "type": "registry:component",
34
36
  "target": "components/spaces/space-switcher.tsx"
35
37
  },
36
38
  {
37
39
  "path": "registry/spaces/members-sheet.tsx",
38
- "content": "'use client';\n\n// The open space's members and roles, the invite link, remove, leave, and every administrator access to it.\n// What a person may do is the host's to decide: the sheet only hides what would be refused.\nimport { useState } from 'react';\nimport { copyToClipboard } from '@hachej/boring-files/platform';\nimport { CopyIcon, LogOutIcon, ShieldAlertIcon, UserMinusIcon } from 'lucide-react';\nimport { Button } from '@/components/ui/button';\nimport { Input } from '@/components/ui/input';\nimport { Item, ItemActions, ItemContent } from '@/components/ui/item';\nimport { NativeSelect, NativeSelectOption } from '@/components/ui/native-select';\nimport { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet';\nimport { spaceErrorMessage, spacesLabels } from './spaces-labels';\nimport type { SpacesLabels } from './spaces-labels';\nimport type { SpaceMember, SpaceRoleOption, SpaceView } from './use-spaces';\n\nexport interface MembersSheetProps {\n readonly open: boolean;\n readonly onOpenChange: (open: boolean) => void;\n readonly view: SpaceView | null;\n readonly roles: readonly SpaceRoleOption[];\n /** The person looking: never offered to remove themselves. */\n readonly me: string;\n readonly onInvite: (email: string, role: string) => Promise<{ readonly link: string }>;\n readonly onSetRole: (person: string, role: string) => Promise<unknown>;\n readonly onRemove: (person: string) => Promise<unknown>;\n readonly onLeave: () => Promise<unknown>;\n readonly labels?: SpacesLabels;\n}\n\ntype Act = (run: () => Promise<unknown>) => Promise<void>;\n\nfunction RoleSelect({ roles, value, onChange, className, label }: { readonly roles: readonly SpaceRoleOption[]; readonly value: string; readonly onChange: (role: string) => void; readonly className: string; readonly label: string }) {\n return <NativeSelect aria-label={label} value={value} onChange={event => onChange(event.target.value)} className={className}>\n {roles.map(role => <NativeSelectOption key={role.id} value={role.id}>{role.label}</NativeSelectOption>)}\n </NativeSelect>;\n}\n\nfunction MemberRow({ member, editable, me, roles, labels, onSetRole, onRemove }: { readonly member: SpaceMember; readonly editable: boolean; readonly me: string; readonly roles: readonly SpaceRoleOption[]; readonly labels: SpacesLabels; readonly onSetRole: (role: string) => void; readonly onRemove: () => void }) {\n const managed = editable && member.person && member.person !== me;\n return <li className=\"flex items-center gap-2 px-3 py-2\" data-testid={`member-${member.person ?? member.name}`}>\n <span className=\"flex-1 truncate text-sm\">{member.name || member.person}{member.person === me ? labels.you : ''}</span>\n {managed\n ? <RoleSelect roles={roles} value={member.role} onChange={onSetRole} className=\"w-36\" label={labels.role} />\n : <span className=\"text-xs text-muted-foreground\">{roles.find(role => role.id === member.role)?.label ?? member.role}</span>}\n {managed && <Button size=\"icon\" variant=\"ghost\" aria-label={labels.remove} title={labels.remove} onClick={onRemove}><UserMinusIcon className=\"size-4\" /></Button>}\n </li>;\n}\n\nfunction InviteForm({ roles, labels, onInvite, act }: { readonly roles: readonly SpaceRoleOption[]; readonly labels: SpacesLabels; readonly onInvite: MembersSheetProps['onInvite']; readonly act: Act }) {\n const [email, setEmail] = useState('');\n const [role, setRole] = useState(roles.find(option => !option.manage)?.id ?? roles[0]?.id ?? '');\n const [link, setLink] = useState<string | null>(null);\n const submit = () => act(async () => { setLink((await onInvite(email.trim(), role)).link); setEmail(''); });\n return <section className=\"flex flex-col gap-2\" aria-label={labels.invite}>\n <h3 className=\"text-sm font-medium\">{labels.invite}</h3>\n <form className=\"flex flex-col gap-2\" onSubmit={event => { event.preventDefault(); void submit(); }}>\n <Input type=\"email\" required value={email} onChange={event => setEmail(event.target.value)} placeholder={labels.emailPlaceholder} aria-label={labels.email} data-testid=\"invite-email\" />\n <div className=\"flex gap-2\">\n <RoleSelect roles={roles} value={role} onChange={setRole} className=\"flex-1\" label={labels.role} />\n <Button type=\"submit\" data-testid=\"invite-submit\">{labels.createLink}</Button>\n </div>\n </form>\n {link && <Item variant=\"muted\" size=\"sm\" data-testid=\"invite-link\">\n <ItemContent className=\"min-w-0\"><span className=\"truncate text-xs\">{link}</span></ItemContent>\n <ItemActions><Button size=\"icon\" variant=\"ghost\" aria-label={labels.copy} onClick={() => void copyToClipboard(link)}><CopyIcon className=\"size-4\" /></Button></ItemActions>\n </Item>}\n {link && <p className=\"text-xs text-muted-foreground\">{labels.inviteHint}</p>}\n </section>;\n}\n\nexport function MembersSheet({ open, onOpenChange, view, roles, me, onInvite, onSetRole, onRemove, onLeave, labels = spacesLabels }: MembersSheetProps) {\n const [error, setError] = useState<string | null>(null);\n const myRole = view?.space.role ?? null;\n const manager = Boolean(roles.find(role => role.id === myRole)?.manage);\n const access = manager ? view?.access ?? [] : [];\n const act: Act = async run => { setError(null); try { await run(); } catch (problem) { setError(spaceErrorMessage(problem, labels)); } };\n return <Sheet open={open} onOpenChange={next => onOpenChange(next)}>\n <SheetContent side=\"right\" className=\"w-full sm:max-w-md\" data-testid=\"members-sheet\">\n <SheetHeader>\n <SheetTitle>{view?.space.title ?? '…'}</SheetTitle>\n <SheetDescription>{labels.membersDescription}</SheetDescription>\n </SheetHeader>\n <div className=\"flex flex-col gap-6 overflow-y-auto px-4 pb-6\">\n {error && <p role=\"alert\" className=\"text-sm text-destructive\" data-testid=\"members-error\">{error}</p>}\n <section className=\"flex flex-col gap-2\" aria-label={labels.members}>\n <h3 className=\"text-sm font-medium\">{labels.members}</h3>\n <ul className=\"flex flex-col divide-y\">\n {(view?.members ?? []).map(member => <MemberRow key={member.person ?? member.name} member={member} editable={manager} me={me} roles={roles} labels={labels}\n onSetRole={role => void act(() => onSetRole(member.person!, role))} onRemove={() => void act(() => onRemove(member.person!))} />)}\n </ul>\n </section>\n {manager && <InviteForm key={view?.space.id} roles={roles} labels={labels} onInvite={onInvite} act={act} />}\n {access.length > 0 && <section className=\"flex flex-col gap-2\" aria-label={labels.adminAccess} data-testid=\"admin-access\">\n <h3 className=\"text-sm font-medium\">{labels.adminAccess}</h3>\n <ul className=\"flex flex-col gap-1\">\n {access.map(entry => <li key={entry.at} className=\"flex items-start gap-2 text-sm\">\n <ShieldAlertIcon className=\"mt-0.5 size-4 text-muted-foreground\" aria-hidden=\"true\" />\n <span>{labels.adminAccessEntry(entry)}</span>\n </li>)}\n </ul>\n </section>}\n {myRole && <Button variant=\"outline\" className=\"self-start\" onClick={() => void act(onLeave)} data-testid=\"leave-space\"><LogOutIcon className=\"size-4\" />{labels.leave}</Button>}\n </div>\n </SheetContent>\n </Sheet>;\n}\n",
40
+ "content": "'use client';\n\n// The open space's members and roles, the invite link, remove, leave, and every administrator access to it.\n// What a person may do is the host's to decide: the sheet only hides what would be refused.\nimport { useState } from 'react';\nimport { copyToClipboard } from '@hachej/boring-files/platform';\nimport { CopyIcon, LogOutIcon, ShieldAlertIcon, UserMinusIcon } from '../_shared/icons';\nimport { Button } from '@/components/ui/button';\nimport { Input } from '@/components/ui/input';\nimport { Item, ItemActions, ItemContent } from '@/components/ui/item';\nimport { NativeSelect, NativeSelectOption } from '@/components/ui/native-select';\nimport { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet';\nimport { spaceErrorMessage, spacesLabels } from './spaces-labels';\nimport type { SpacesLabels } from './spaces-labels';\nimport type { SpaceMember, SpaceRoleOption, SpaceView } from './use-spaces';\n\nexport interface MembersSheetProps {\n readonly open: boolean;\n readonly onOpenChange: (open: boolean) => void;\n readonly view: SpaceView | null;\n readonly roles: readonly SpaceRoleOption[];\n /** The person looking: never offered to remove themselves. */\n readonly me: string;\n readonly onInvite: (email: string, role: string) => Promise<{ readonly link: string }>;\n readonly onSetRole: (person: string, role: string) => Promise<unknown>;\n readonly onRemove: (person: string) => Promise<unknown>;\n readonly onLeave: () => Promise<unknown>;\n readonly labels?: SpacesLabels;\n}\n\ntype Act = (run: () => Promise<unknown>) => Promise<void>;\n\nfunction RoleSelect({ roles, value, onChange, className, label }: { readonly roles: readonly SpaceRoleOption[]; readonly value: string; readonly onChange: (role: string) => void; readonly className: string; readonly label: string }) {\n return <NativeSelect aria-label={label} value={value} onChange={event => onChange(event.target.value)} className={className}>\n {roles.map(role => <NativeSelectOption key={role.id} value={role.id}>{role.label}</NativeSelectOption>)}\n </NativeSelect>;\n}\n\nfunction MemberRow({ member, editable, me, roles, labels, onSetRole, onRemove }: { readonly member: SpaceMember; readonly editable: boolean; readonly me: string; readonly roles: readonly SpaceRoleOption[]; readonly labels: SpacesLabels; readonly onSetRole: (role: string) => void; readonly onRemove: () => void }) {\n const managed = editable && member.person && member.person !== me;\n return <li className=\"flex items-center gap-2 px-3 py-2\" data-testid={`member-${member.person ?? member.name}`}>\n <span className=\"flex-1 truncate text-sm\">{member.name || member.person}{member.person === me ? labels.you : ''}</span>\n {managed\n ? <RoleSelect roles={roles} value={member.role} onChange={onSetRole} className=\"w-36\" label={labels.role} />\n : <span className=\"text-xs text-muted-foreground\">{roles.find(role => role.id === member.role)?.label ?? member.role}</span>}\n {managed && <Button size=\"icon\" variant=\"ghost\" aria-label={labels.remove} title={labels.remove} onClick={onRemove}><UserMinusIcon className=\"size-4\" /></Button>}\n </li>;\n}\n\nfunction InviteForm({ roles, labels, onInvite, act }: { readonly roles: readonly SpaceRoleOption[]; readonly labels: SpacesLabels; readonly onInvite: MembersSheetProps['onInvite']; readonly act: Act }) {\n const [email, setEmail] = useState('');\n const [role, setRole] = useState(roles.find(option => !option.manage)?.id ?? roles[0]?.id ?? '');\n const [link, setLink] = useState<string | null>(null);\n const submit = () => act(async () => { setLink((await onInvite(email.trim(), role)).link); setEmail(''); });\n return <section className=\"flex flex-col gap-2\" aria-label={labels.invite}>\n <h3 className=\"text-sm font-medium\">{labels.invite}</h3>\n <form className=\"flex flex-col gap-2\" onSubmit={event => { event.preventDefault(); void submit(); }}>\n <Input type=\"email\" required value={email} onChange={event => setEmail(event.target.value)} placeholder={labels.emailPlaceholder} aria-label={labels.email} data-testid=\"invite-email\" />\n <div className=\"flex gap-2\">\n <RoleSelect roles={roles} value={role} onChange={setRole} className=\"flex-1\" label={labels.role} />\n <Button type=\"submit\" data-testid=\"invite-submit\">{labels.createLink}</Button>\n </div>\n </form>\n {link && <Item variant=\"muted\" size=\"sm\" data-testid=\"invite-link\">\n <ItemContent className=\"min-w-0\"><span className=\"truncate text-xs\">{link}</span></ItemContent>\n <ItemActions><Button size=\"icon\" variant=\"ghost\" aria-label={labels.copy} onClick={() => void copyToClipboard(link)}><CopyIcon className=\"size-4\" /></Button></ItemActions>\n </Item>}\n {link && <p className=\"text-xs text-muted-foreground\">{labels.inviteHint}</p>}\n </section>;\n}\n\nexport function MembersSheet({ open, onOpenChange, view, roles, me, onInvite, onSetRole, onRemove, onLeave, labels = spacesLabels }: MembersSheetProps) {\n const [error, setError] = useState<string | null>(null);\n const myRole = view?.space.role ?? null;\n const manager = Boolean(roles.find(role => role.id === myRole)?.manage);\n const access = manager ? view?.access ?? [] : [];\n const act: Act = async run => { setError(null); try { await run(); } catch (problem) { setError(spaceErrorMessage(problem, labels)); } };\n return <Sheet open={open} onOpenChange={next => onOpenChange(next)}>\n <SheetContent side=\"right\" className=\"w-full sm:max-w-md\" data-testid=\"members-sheet\">\n <SheetHeader>\n <SheetTitle>{view?.space.title ?? '…'}</SheetTitle>\n <SheetDescription>{labels.membersDescription}</SheetDescription>\n </SheetHeader>\n <div className=\"flex flex-col gap-6 overflow-y-auto px-4 pb-6\">\n {error && <p role=\"alert\" className=\"text-sm text-destructive\" data-testid=\"members-error\">{error}</p>}\n <section className=\"flex flex-col gap-2\" aria-label={labels.members}>\n <h3 className=\"text-sm font-medium\">{labels.members}</h3>\n <ul className=\"flex flex-col divide-y\">\n {(view?.members ?? []).map(member => <MemberRow key={member.person ?? member.name} member={member} editable={manager} me={me} roles={roles} labels={labels}\n onSetRole={role => void act(() => onSetRole(member.person!, role))} onRemove={() => void act(() => onRemove(member.person!))} />)}\n </ul>\n </section>\n {manager && <InviteForm key={view?.space.id} roles={roles} labels={labels} onInvite={onInvite} act={act} />}\n {access.length > 0 && <section className=\"flex flex-col gap-2\" aria-label={labels.adminAccess} data-testid=\"admin-access\">\n <h3 className=\"text-sm font-medium\">{labels.adminAccess}</h3>\n <ul className=\"flex flex-col gap-1\">\n {access.map(entry => <li key={entry.at} className=\"flex items-start gap-2 text-sm\">\n <ShieldAlertIcon className=\"mt-0.5 size-4 text-muted-foreground\" aria-hidden=\"true\" />\n <span>{labels.adminAccessEntry(entry)}</span>\n </li>)}\n </ul>\n </section>}\n {myRole && <Button variant=\"outline\" className=\"self-start\" onClick={() => void act(onLeave)} data-testid=\"leave-space\"><LogOutIcon className=\"size-4\" />{labels.leave}</Button>}\n </div>\n </SheetContent>\n </Sheet>;\n}\n",
39
41
  "type": "registry:component",
40
42
  "target": "components/spaces/members-sheet.tsx"
41
43
  }
@@ -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.58",
8
- "@hachej/boring-files@^0.4.58",
7
+ "@hachej/boring-ui-kit@^0.4.59",
8
+ "@hachej/boring-files@^0.4.59",
9
9
  "react@^19.0.0",
10
10
  "react-dom@^19.0.0",
11
11
  "zod@^4.6.5"
@@ -40,11 +40,36 @@
40
40
  "display": "grid",
41
41
  "gap": "0.5rem"
42
42
  },
43
+ ".boring-task-list-recipe[data-boring=\"task-list-viewer\"] h2": {
44
+ "font-size": "var(--text-sm)",
45
+ "font-weight": "600"
46
+ },
43
47
  ".boring-task-list-recipe[data-boring=\"task-list-viewer\"] li": {
48
+ "display": "grid",
49
+ "grid-template-columns": "minmax(0, 1fr) auto",
50
+ "align-items": "center",
51
+ "gap": "0.5rem"
52
+ },
53
+ ".boring-task-list-recipe[data-boring=\"task-list-viewer\"] li > label": {
44
54
  "display": "flex",
45
55
  "align-items": "center",
46
56
  "gap": "0.5rem",
47
- "flex-wrap": "wrap"
57
+ "min-width": "0",
58
+ "overflow-wrap": "anywhere"
59
+ },
60
+ ".boring-task-list-recipe[data-boring=\"task-list-viewer\"] form": {
61
+ "display": "grid",
62
+ "grid-template-columns": "minmax(0, 1fr) auto",
63
+ "align-items": "center",
64
+ "gap": "0.5rem"
65
+ },
66
+ ".boring-task-list-recipe[data-boring=\"task-list-viewer\"] form > label": {
67
+ "grid-column": "1 / -1"
68
+ },
69
+ ".boring-task-list-recipe[data-boring=\"task-list-viewer\"] .boring-task-list-actions": {
70
+ "display": "flex",
71
+ "flex-wrap": "wrap",
72
+ "gap": "0.5rem"
48
73
  },
49
74
  ".boring-task-list-recipe[data-boring=\"task-list-viewer\"] [role=\"alert\"]": {
50
75
  "color": "var(--destructive)"