@hachej/boring-ui-kit 0.4.11 → 0.4.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/shared-chat/socket.js +3 -1
- package/dist/shared-chat/source.d.ts +7 -1
- package/package.json +2 -2
- package/r/agent-target.json +4 -4
- package/r/app-shell.json +10 -8
- package/r/board-viewer.json +6 -6
- package/r/button.json +1 -1
- package/r/diagram-viewer.json +2 -2
- package/r/dock.json +1 -1
- package/r/documents.json +11 -11
- package/r/feedback-preview.json +1 -1
- package/r/feedback.json +7 -6
- package/r/file-tree.json +5 -5
- package/r/highlights.json +3 -3
- package/r/html-viewer.json +3 -3
- package/r/looks-viewer.json +1 -1
- package/r/maquette-viewer.json +3 -3
- package/r/markdown-editor.json +3 -3
- package/r/mockup-viewer.json +1 -1
- package/r/pi-ambient.json +11 -8
- package/r/pi-app.json +18 -16
- package/r/pi-artifacts.json +32 -0
- package/r/pi-chat-approval.json +26 -0
- package/r/pi-chat-attachments.json +25 -0
- package/r/pi-chat-markdown.json +26 -0
- package/r/pi-chat-mentions.json +23 -0
- package/r/pi-chat-pickers.json +24 -0
- package/r/pi-chat-plugins.json +31 -0
- package/r/pi-chat-question.json +29 -0
- package/r/pi-chat-slash.json +26 -0
- package/r/pi-chat-subagent.json +34 -0
- package/r/pi-chat-voice.json +39 -0
- package/r/pi-chat.json +15 -97
- package/r/pi-workspace.json +4 -4
- package/r/placed-agent.json +4 -4
- package/r/provider-setup.json +2 -2
- package/r/record-viewer.json +1 -1
- package/r/registry.json +409 -177
- package/r/requests-board.json +1 -1
- package/r/shared-chat.json +13 -9
- package/r/task-list-viewer.json +2 -2
- package/r/version-history.json +3 -3
- package/r/viewers.json +7 -7
- package/r/windows.json +1 -1
package/r/requests-board.json
CHANGED
package/r/shared-chat.json
CHANGED
|
@@ -4,18 +4,22 @@
|
|
|
4
4
|
"title": "Shared chat",
|
|
5
5
|
"description": "Optional multi-party host log presentation over PiChat's generic composition seams. No agent runtime or speaking policy.",
|
|
6
6
|
"dependencies": [
|
|
7
|
-
"@hachej/boring-ui-kit@^0.4.
|
|
7
|
+
"@hachej/boring-ui-kit@^0.4.12",
|
|
8
8
|
"react@^19.0.0",
|
|
9
9
|
"lucide-react@^1.51.0"
|
|
10
10
|
],
|
|
11
11
|
"registryDependencies": [
|
|
12
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
13
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
12
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.12/r/pi-chat.json",
|
|
13
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.12/r/pi-chat-plugins.json",
|
|
14
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.12/r/pi-chat-markdown.json",
|
|
15
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.12/r/pi-chat-mentions.json",
|
|
16
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.12/r/theme.json",
|
|
14
17
|
"avatar",
|
|
15
18
|
"badge",
|
|
16
19
|
"button",
|
|
17
20
|
"drawer",
|
|
18
21
|
"popover",
|
|
22
|
+
"separator",
|
|
19
23
|
"tooltip",
|
|
20
24
|
"trace",
|
|
21
25
|
"spinner",
|
|
@@ -27,25 +31,25 @@
|
|
|
27
31
|
"files": [
|
|
28
32
|
{
|
|
29
33
|
"path": "registry/shared-chat/shared-chat.tsx",
|
|
30
|
-
"content": "'use client';\nimport { PiChat } from '../pi-chat/pi-chat';\nimport type { PiChatProps } from '../pi-chat/pi-chat';\nimport type { SharedChatController } from '@hachej/boring-ui-kit/shared-chat';\nimport type { SharedLabels } from './shared-labels';\nimport { useSharedChatProps } from './use-shared-chat';\n\nexport { useSharedChat, useSharedChatProps } from './use-shared-chat';\nexport { defaultSharedLabels } from './shared-labels';\nexport { frenchSharedLabels } from './shared-labels-fr';\nexport type { SharedLabels } from './shared-labels';\nexport { createSharedChat, createChannelSocket, createFakeChannelSource, channelAvatarStates, cardActions, ChannelRequestError } from '@hachej/boring-ui-kit/shared-chat';\nexport type { ChannelSource, ChannelEvent, ChannelMessage, Member, Conversation, Card, CardAction, Step, AvatarState, SharedChatController } from '@hachej/boring-ui-kit/shared-chat';\n\nexport interface SharedChatProps extends Omit<PiChatProps, 'controller'> {\n readonly controller: SharedChatController;\n readonly participantLabels?: Partial<SharedLabels>;\n}\n
|
|
34
|
+
"content": "'use client';\nimport { PiChat } from '../pi-chat/pi-chat';\nimport type { PiChatProps } from '../pi-chat/pi-chat';\nimport type { SharedChatController } from '@hachej/boring-ui-kit/shared-chat';\nimport type { SharedLabels } from './shared-labels';\nimport { useSharedChatProps } from './use-shared-chat';\nimport { withChatPlugins } from '../pi-chat-plugins/chat-plugins';\n\nexport { useSharedChat, useSharedChatProps } from './use-shared-chat';\nexport { defaultSharedLabels } from './shared-labels';\nexport { frenchSharedLabels } from './shared-labels-fr';\nexport type { SharedLabels } from './shared-labels';\nexport { createSharedChat, createChannelSocket, createFakeChannelSource, channelAvatarStates, cardActions, ChannelRequestError } from '@hachej/boring-ui-kit/shared-chat';\nexport type { ChannelSource, ChannelEvent, ChannelMessage, Member, Conversation, Card, CardAction, Step, AvatarState, SharedChatController } from '@hachej/boring-ui-kit/shared-chat';\n\nexport interface SharedChatProps extends Omit<PiChatProps, 'controller'> {\n readonly controller: SharedChatController;\n readonly participantLabels?: Partial<SharedLabels>;\n}\n/**\n * Optional composition. PiChat still owns the regular shell, draft, send and composer lifecycle. The composer defaults to WhatsApp's:\n * one `pill` row and, with `voiceMessage`, the microphone in Send's place while empty; `composerLayout` and `voiceReplacesSend` override.\n * `composerStart` puts an app control at the left edge of the composer, `tools` beside Send.\n */\nexport function SharedChat({ participantLabels, ...props }: SharedChatProps) {\n const composition = useSharedChatProps(props.controller, { locale: props.locale, labels: participantLabels });\n return <PiChat {...withChatPlugins({ ...props, ...composition })} controls={<>{composition.controls}{props.controls}</>} composerChips={<>{props.composerChips}{composition.composerChips}</>}\n composerLayout={props.composerLayout ?? composition.composerLayout} voiceReplacesSend={props.voiceReplacesSend ?? composition.voiceReplacesSend} />;\n}\n",
|
|
31
35
|
"type": "registry:component",
|
|
32
36
|
"target": "components/shared-chat/shared-chat.tsx"
|
|
33
37
|
},
|
|
34
38
|
{
|
|
35
39
|
"path": "registry/shared-chat/use-shared-chat.tsx",
|
|
36
|
-
"content": "'use client';\nimport { useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';\nimport type { ReactNode } from 'react';\nimport { UserIcon } from 'lucide-react';\nimport { createSharedChat } from '@hachej/boring-ui-kit/shared-chat';\nimport type { ChannelSource, Conversation, SharedChatController } from '@hachej/boring-ui-kit/shared-chat';\nimport type { PiChatProps } from '../pi-chat/pi-chat';\nimport type { ConversationsConfig, MentionsConfig } from '../pi-chat/config';\nimport { Badge } from '@/components/ui/badge';\nimport { Button } from '@/components/ui/button';\nimport { ItemContent, ItemDescription, ItemMedia, ItemTitle } from '@/components/ui/item';\nimport { agentTone, 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 members = state?.shared.members ?? [];\n const labels = { ...(options.locale === 'fr' ? frenchSharedLabels : defaultSharedLabels), ...options.labels };\n const mentions = useMemo<MentionsConfig>(() => ({\n listLabel: labels.sharedMembers, icon: <UserIcon aria-hidden=\"true\"
|
|
40
|
+
"content": "'use client';\nimport { useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';\nimport type { ReactNode } from 'react';\nimport { UserIcon } from 'lucide-react';\nimport { createSharedChat } from '@hachej/boring-ui-kit/shared-chat';\nimport type { ChannelSource, Conversation, SharedChatController } from '@hachej/boring-ui-kit/shared-chat';\nimport type { PiChatProps } from '../pi-chat/pi-chat';\nimport type { ConversationsConfig, MentionsConfig } from '../pi-chat/config';\nimport { MentionPicker } from '../pi-chat-mentions/mention-menu';\nimport { Badge } from '@/components/ui/badge';\nimport { Button } from '@/components/ui/button';\nimport { ItemContent, ItemDescription, ItemMedia, ItemTitle } from '@/components/ui/item';\nimport { agentTone, 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 members = state?.shared.members ?? [];\n const labels = { ...(options.locale === 'fr' ? frenchSharedLabels : defaultSharedLabels), ...options.labels };\n const mentions = useMemo<MentionsConfig>(() => ({\n listLabel: labels.sharedMembers, icon: <UserIcon aria-hidden=\"true\" />, Picker: MentionPicker,\n label: id => members.find(member => member.id === id)?.name ?? id,\n search: async query => members.filter(member => `${member.name} ${member.id}`.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)?.name ?? id}</Badge>,\n renderSent: id => {\n const member = members.find(member => member.id === id) ?? { id, name: id, kind: 'person' };\n return <span data-testid=\"message-mention\" data-path={id} data-agent-color={agentTone(member)?.token} className={`inline-flex items-baseline gap-1 align-baseline font-medium ${agentTone(member)?.text ?? ''}`}><span aria-hidden=\"true\"><MemberAvatar member={member} compact interactive={false} /></span><span data-testid=\"mention-name\">{member.name}</span></span>;\n },\n renderOption: item => <><ItemMedia variant=\"icon\"><UserIcon aria-hidden=\"true\" /></ItemMedia><ItemContent className=\"min-w-0\"><ItemTitle>{item.label}</ItemTitle><ItemDescription className=\"m-0 truncate\">{item.path}</ItemDescription></ItemContent></>,\n }), [members, labels.sharedMembers]);\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 ? 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)} 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. */\nexport function useSharedChat(source: ChannelSource, options: SharedTextOptions = {}) {\n const [list, setList] = useState<{ source: ChannelSource; items: Conversation[] }>();\n const [selection, setSelection] = useState<{ source: ChannelSource; id: string }>();\n const [error, setError] = useState<string>();\n const items = list?.source === source ? list.items : [];\n const conversationId = selection?.source === source ? selection.id : items[0]?.id;\n const [binding, setBinding] = useState<{ source: ChannelSource; id: string; controller: SharedChatController }>();\n useEffect(() => {\n let off = false; setError(undefined);\n void source.conversations().then(items => { if (!off) setList({ source, items }); }, cause => { if (!off) setError(String(cause)); });\n return () => { off = true; };\n }, [source]);\n useEffect(() => {\n if (conversationId === undefined) return;\n const controller = createSharedChat(source, { conversationId });\n setBinding({ source, id: conversationId, controller });\n return () => { void controller.dispose(); };\n }, [source, conversationId]);\n const controller = binding?.source === source && binding.id === conversationId ? binding.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",
|
|
37
41
|
"type": "registry:component",
|
|
38
42
|
"target": "components/shared-chat/use-shared-chat.tsx"
|
|
39
43
|
},
|
|
40
44
|
{
|
|
41
45
|
"path": "registry/shared-chat/messages.tsx",
|
|
42
|
-
"content": "'use client';\n\nimport { useEffect, useRef, useState, useSyncExternalStore } from 'react';\nimport type { ComponentProps, ReactNode } from 'react';\nimport { cardActions, channelAvatarStates } from '@hachej/boring-ui-kit/shared-chat';\nimport type { Card as Request, ChannelMessage, SharedChatController, Member, SharedChatState } from '@hachej/boring-ui-kit/shared-chat';\nimport { BotIcon, CircleIcon, ReplyIcon, Trash2Icon, XIcon } from 'lucide-react';\nimport { Avatar, AvatarFallback, AvatarGroup, AvatarGroupCount, AvatarImage } from '@/components/ui/avatar';\nimport { Badge } from '@/components/ui/badge';\nimport { Button } from '@/components/ui/button';\nimport { Drawer, DrawerContent, DrawerTitle } from '@/components/ui/drawer';\nimport { Item, ItemActions, ItemContent, ItemDescription, ItemFooter, ItemTitle } from '@/components/ui/item';\nimport { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';\nimport { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';\nimport { relativeTime } from '../pi-chat/history';\nimport { useChatText } from './text';\n\n// Text shares one treatment across headings and muted bubbles; only AA-qualified default palette entries colour it.\nconst agentTones = [\n { token: 'chart-1', icon: 'text-chart-1', text: 'text-foreground' },\n { token: 'chart-2', icon: 'text-chart-2', text: 'text-foreground dark:text-chart-2' },\n { token: 'chart-3', icon: 'text-chart-3', text: 'text-chart-3' },\n { token: 'chart-4', icon: 'text-chart-4', text: 'text-foreground' },\n { token: 'chart-5', icon: 'text-chart-5', text: 'text-foreground' },\n];\nexport function agentTone(member: Pick<Member, 'id' | 'kind'>) {\n if (member.kind !== 'agent') return undefined;\n let hash = 0;\n for (const character of member.id) hash = (Math.imul(hash, 31) + character.charCodeAt(0)) >>> 0;\n return agentTones[hash % agentTones.length];\n}\n\nfunction usePhone() {\n const [phone, setPhone] = useState(() => globalThis.matchMedia?.('(max-width: 640px)').matches === true);\n useEffect(() => { const media = globalThis.matchMedia?.('(max-width: 640px)'); if (!media) return; const change = () => setPhone(media.matches); media.addEventListener('change', change); return () => media.removeEventListener('change', change); }, []);\n return phone;\n}\nconst latestCard = (shared: SharedChatState, id: string) => shared.messages.findLast(message => message.author === id && message.card)?.card;\nconst memberOf = (shared: SharedChatState, id: string): Member => shared.members.find(member => member.id === id) ?? { id, name: id, kind: 'person' };\nexport function sharedQuote(shared: SharedChatState, id: string | undefined) {\n const message = shared.messages.find(message => message.id === id);\n return message ? { message, author: memberOf(shared, message.author) } : undefined;\n}\n\n/** Only https, `data:image/`, `blob:` and same-origin relative addresses load; anything else falls back to the initials. */\nexport function safeAvatarUrl(url: string | undefined): string | undefined {\n if (!url) return undefined;\n return /^(?:https:\\/\\/|data:image\\/|blob:)/i.test(url) || /^\\/(?![/\\\\])/.test(url) ? url : undefined;\n}\n\nexport function MemberAvatar({ member, card, interactive = true, size = 'default', compact = false }: { readonly member: Member; readonly card?: Request | undefined; readonly interactive?: boolean; readonly size?: ComponentProps<typeof Avatar>['size']; readonly compact?: boolean }) {\n const { labels } = useChatText();\n const phone = usePhone();\n const [open, setOpen] = useState(false);\n useEffect(() => {\n if (!open) return;\n const close = () => setOpen(false);\n document.addEventListener('scroll', close, true);\n return () => document.removeEventListener('scroll', close, true);\n }, [open]);\n const state = member.kind === 'agent' && card?.state && Object.hasOwn(channelAvatarStates, card.state) ? channelAvatarStates[card.state] ?? 'neutral' : 'neutral';\n const tone = state === 'blocked' ? 'text-destructive' : state === 'waiting' ? 'text-muted-foreground' : 'text-primary';\n const agent = agentTone(member);\n const avatarSrc = safeAvatarUrl(member.avatarUrl);\n const avatar = <Avatar size={compact ? 'sm' : size} data-agent-color={agent?.token} data-testid=\"member-avatar\" role=\"img\" aria-label={member.kind === 'agent' ? `${member.name}, ${labels.sharedAgent}` : member.name} data-state={state}>\n {avatarSrc && <AvatarImage src={avatarSrc} alt={member.name} />}\n <AvatarFallback>{member.kind === 'agent' ? <BotIcon data-lucide=\"bot\" className={`${compact ? 'size-3' : 'size-4'} ${agent?.icon ?? ''}`} aria-hidden=\"true\" /> : member.name.replace(/[()]/g, '').split(/\\s+/).slice(0, 2).map(word => word[0]).join('').toUpperCase()}</AvatarFallback>\n {state !== 'neutral' && <span data-testid=\"avatar-indicator\" data-state={state} className={`absolute right-0 bottom-0 ${tone}`}>\n <CircleIcon className=\"size-2 fill-current\" aria-hidden=\"true\" />\n </span>}\n </Avatar>;\n if (member.kind !== 'agent' || !card || !interactive) return avatar;\n const trigger = <span role=\"button\" tabIndex={0} className=\"inline-flex shrink-0\" data-testid={`agent-avatar-${member.id}`} data-state={state} aria-label={`${member.name}: ${card.line}`}>{avatar}</span>;\n return phone\n ? <Popover open={open} onOpenChange={setOpen}><PopoverTrigger nativeButton={false} render={trigger} /><PopoverContent data-testid=\"agent-state-popover\" side=\"bottom\" align=\"start\" className=\"max-w-[calc(100vw-1rem)]\">{card.line}</PopoverContent></Popover>\n : <Tooltip><TooltipTrigger render={trigger} /><TooltipContent>{card.line}</TooltipContent></Tooltip>;\n}\nfunction MemberBadge({ member }: { readonly member: Member }) {\n const { labels } = useChatText();\n return member.kind === 'admin' ? <Badge data-testid=\"author-badge\" variant=\"secondary\">{labels.sharedAdmin}</Badge> : null;\n}\nfunction Dot({ value }: { readonly value: SharedChatState['status'] | Member['presence'] }) {\n return <CircleIcon aria-hidden=\"true\" className={`size-2 fill-current ${value === 'live' || value === 'here' ? 'text-primary' : value === 'offline' ? 'text-destructive' : 'text-muted-foreground'}`} />;\n}\n\n/** Mounted in the existing bar. The source owns membership and visibility, including the admin. */\nexport function SharedMembers({ controller }: { readonly controller: SharedChatController }) {\n const state = useSyncExternalStore(controller.subscribe, controller.getSnapshot, controller.getSnapshot);\n const { labels } = useChatText();\n const [open, setOpen] = useState(false);\n const phone = usePhone();\n const shared = state.shared;\n if (!shared) return null;\n const name = shared.status === 'live' ? labels.live : shared.status === 'reconnecting' ? labels.reconnecting : labels.sharedOffline;\n const content = <ul data-testid=\"member-list\" className=\"m-0 flex min-w-0 list-none flex-col gap-3 p-4\">\n {shared.members.map(member => <li key={member.id} data-member-id={member.id} className=\"flex min-w-0 items-center gap-2\">\n <MemberAvatar member={member} card={latestCard(shared, member.id)} /><span className=\"min-w-0 flex-1 truncate\">{member.name}</span>\n <Badge variant=\"secondary\">{member.role ?? member.kind}</Badge>\n <span data-testid=\"presence\" data-presence={member.presence ?? 'away'} role=\"img\" aria-label={member.presence === 'here' ? labels.sharedHere : labels.sharedAway}><Dot value={member.presence} /></span>\n </li>)}\n </ul>;\n const stack = <AvatarGroup>{shared.members.slice(0, phone ? 1 : 3).map(member => <MemberAvatar key={member.id} member={member} card={latestCard(shared, member.id)} interactive={false} />)}<AvatarGroupCount>{shared.members.length}</AvatarGroupCount></AvatarGroup>;\n const trigger = <Button variant=\"ghost\" data-testid=\"shared-members\" aria-label={labels.sharedMemberCount(shared.members.length)} aria-haspopup=\"dialog\" aria-expanded={open}>{stack}</Button>;\n return <div className=\"flex shrink-0 items-center gap-2\">\n <span data-testid=\"shared-connection-announcement\" className=\"sr-only\" aria-live=\"polite\" aria-atomic=\"true\">{name}</span>\n {shared.status !== 'live' && <Tooltip><TooltipTrigger render={<span tabIndex={0} data-testid=\"shared-status\" data-status={shared.status} role=\"img\" aria-label={name}><Dot value={shared.status} /></span>} /><TooltipContent>{name}</TooltipContent></Tooltip>}\n {phone ? <><Button variant=\"ghost\" data-testid=\"shared-members\" aria-label={labels.sharedMemberCount(shared.members.length)} aria-haspopup=\"dialog\" aria-expanded={open} onClick={() => setOpen(true)}>{stack}</Button>\n {open && <Drawer open onOpenChange={setOpen} showSwipeHandle><DrawerContent className=\"pi-chat max-h-[80dvh]\"><div className=\"flex shrink-0 items-center justify-between p-4\"><DrawerTitle>{labels.sharedMembers}</DrawerTitle><Button variant=\"ghost\" size=\"icon\" data-testid=\"shared-members-close\" aria-label={labels.close} onClick={() => setOpen(false)}><XIcon aria-hidden=\"true\" /></Button></div><div className=\"min-h-0 overflow-y-auto\">{content}</div></DrawerContent></Drawer>}</>\n : <Popover open={open} onOpenChange={setOpen}><PopoverTrigger render={trigger} /><PopoverContent role=\"dialog\" aria-label={labels.sharedMembers} align=\"end\" className=\"max-w-[calc(100vw-1rem)]\">{content}</PopoverContent></Popover>}\n </div>;\n}\n\n/** The member's role line: the host's own `roleLabel`, else the labels' derivation from kind and role. */\nexport function useRoleLine(member: Member): string | undefined {\n const { labels } = useChatText();\n return member.roleLabel?.trim() || labels.sharedRole(member);\n}\n\n/** An outline Item, like the other conversation cards, so its border follows the theme's border colour and width. */\nexport function RequestCard({ card, action }: { readonly card: Request; readonly action: (card: Request, actionId?: string) => Promise<void> }) {\n const { labels } = useChatText();\n const [busy, setBusy] = useState<string>(), [error, setError] = useState<string>();\n const actions = cardActions(card);\n const run = (id: string) => {\n setBusy(id); setError(undefined);\n void action(card, id).catch(error => setError(error instanceof Error ? error.message : labels.actionFailedShort)).finally(() => setBusy(undefined));\n };\n return <Item variant=\"outline\" render={<section />} data-testid=\"request-card\" aria-label={card.title} className=\"text-start\">\n <ItemContent className=\"min-w-0 basis-0\">\n <ItemTitle className=\"line-clamp-none\">{card.title}</ItemTitle>\n <ItemDescription className=\"line-clamp-none\">{card.line}</ItemDescription>\n {card.since && <ItemDescription><time dateTime={card.since}>{labels.sharedSince(relativeTime(card.since, Date.now(), labels))}</time></ItemDescription>}\n </ItemContent>\n {actions.length > 0 && <ItemFooter><ItemActions role=\"group\" aria-label={card.title} className=\"flex-wrap\">\n {actions.map((choice, index) => {\n const primary = (choice.variant ?? (index === 0 ? 'primary' : 'secondary')) === 'primary';\n return <Button key={choice.id} data-testid=\"request-action\" data-action={choice.id} data-action-variant={primary ? 'primary' : 'secondary'} variant={primary ? 'default' : 'secondary'} disabled={busy !== undefined} aria-busy={busy === choice.id || undefined} onClick={() => run(choice.id)}>{choice.label}</Button>;\n })}\n </ItemActions></ItemFooter>}\n {error && <ItemFooter><p role=\"alert\" className=\"m-0 text-sm text-destructive\">{error}</p></ItemFooter>}\n </Item>;\n}\n\n/** Both the composer chip and the timeline quote use this one parent reference. No thread or routing policy. */\nexport function ReplyQuote({ targetId, quote, onRemove, onReveal }: { readonly targetId: string; readonly quote: ReturnType<typeof sharedQuote>; readonly onRemove?: () => void; readonly onReveal?: ((messageId: string) => void) | undefined }) {\n const { labels } = useChatText();\n const text = quote?.message.removed ? labels.sharedRemoved : quote?.message.card?.title ?? quote?.message.text?.split(/\\r?\\n/)[0] ?? labels.sharedEarlierMessage;\n return <div data-testid={onRemove ? 'composer-reply' : 'message-quote'} className=\"flex w-full min-w-0 items-center gap-1\">\n <Button variant=\"ghost\" className=\"min-w-0 max-w-full justify-start\" onClick={event => {\n const chat = event.currentTarget.closest('[data-boring=pi-chat]');\n const parent = [...(chat?.querySelectorAll<HTMLElement>('[data-shared-message]') ?? [])].find(element => element.dataset.sharedMessage === targetId);\n if (parent) parent.scrollIntoView({ block: 'center', behavior: 'instant' }); else onReveal?.(targetId);\n }}><ReplyIcon aria-hidden=\"true\" /><span className=\"truncate text-muted-foreground\">{quote ? `${quote.author.name}: ${text}` : text}</span></Button>\n {onRemove && <Tooltip><TooltipTrigger render={<Button variant=\"ghost\" size=\"icon\" data-testid=\"composer-reply-remove\" aria-label={labels.sharedCancelReply} onClick={onRemove}><XIcon aria-hidden=\"true\" /></Button>} /><TooltipContent>{labels.sharedCancelReply}</TooltipContent></Tooltip>}\n </div>;\n}\n\n/** Participant metadata surrounds the existing bubbles, Markdown, cards and tool trace. */\nexport function SharedMessage({ shared, children, remove, canRemove, openFile, onReply, onReveal }: {\n readonly shared: SharedItem; readonly children: ReactNode;\n readonly remove?: ((message: ChannelMessage) => Promise<void>) | undefined;\n readonly canRemove?: ((message: ChannelMessage) => boolean) | undefined;\n readonly openFile?: ((path: string) => void) | undefined;\n readonly onReply?: ((messageId: string) => void) | undefined;\n readonly onReveal?: ((messageId: string) => void) | undefined;\n}) {\n const { labels } = useChatText();\n const [error, setError] = useState<string>();\n const [actionsOpen, setActionsOpen] = useState(false);\n const phone = usePhone();\n const element = useRef<HTMLDivElement>(null);\n const press = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);\n const start = useRef<{ x: number; y: number } | undefined>(undefined);\n const cancelPress = () => { clearTimeout(press.current); start.current = undefined; };\n useEffect(() => cancelPress, []);\n useEffect(() => {\n if (!actionsOpen) return;\n const outside = (event: PointerEvent) => { if (event.target instanceof Node && !element.current?.contains(event.target)) setActionsOpen(false); };\n document.addEventListener('pointerdown', outside);\n return () => document.removeEventListener('pointerdown', outside);\n }, [actionsOpen]);\n const { message, author, first, own, lastItem } = shared;\n const role = useRoleLine(author);\n const replyable = Boolean(onReply && ((author.kind === 'agent' && !message.via) || message.card));\n const removable = Boolean(remove && canRemove?.(message));\n const actionable = !message.removed && (replyable || removable);\n const heading = first && !own && <div data-testid=\"message-author-row\" className=\"ml-8 flex min-w-0 items-center gap-2\">\n <div className=\"flex min-w-0 items-center gap-2\">\n <span data-testid=\"message-author\" data-agent-color={agentTone(author)?.token} className={`min-w-0 truncate text-sm ${agentTone(author)?.text ?? 'text-muted-foreground'}`}>{author.name}</span>\n {message.via && <span data-testid=\"message-via\" className={`min-w-0 truncate text-muted-foreground${own ? '' : ' text-sm'}`}>{labels.sharedVia(message.via)}</span>}\n {role && !(message.via && author.kind === 'agent') && <span data-testid=\"message-role\" className=\"min-w-0 truncate text-sm text-muted-foreground\"><span aria-hidden=\"true\">· </span>{role}</span>}\n {!(message.via && author.kind === 'agent') && <MemberBadge member={author} />}\n </div>\n </div>;\n const body = <>\n {message.replyTo && <ReplyQuote targetId={message.replyTo} quote={shared.quote} onReveal={onReveal} />}\n {message.removed ? <p className=\"text-muted-foreground\">{labels.sharedRemoved}</p> : children}\n </>;\n return <div ref={element} data-shared-message={message.id} className=\"group/shared-message relative flex min-w-0 flex-col gap-1\"\n tabIndex={phone && actionable ? 0 : undefined} aria-keyshortcuts={phone && actionable ? 'Shift+F10' : undefined}\n onKeyDown={event => {\n if (!phone || !actionable) return;\n if (event.key === 'ContextMenu' || event.key === 'F10' && event.shiftKey) { event.preventDefault(); setActionsOpen(true); }\n if (event.key === 'Escape') { setActionsOpen(false); element.current?.focus(); }\n }}\n onPointerDown={event => {\n if (!phone || !actionable || event.pointerType !== 'touch' || event.target instanceof Element && event.target.closest('button, a, input, textarea, [role=button], [role=link]')) return;\n cancelPress(); start.current = { x: event.clientX, y: event.clientY };\n press.current = setTimeout(() => setActionsOpen(true), 400);\n }}\n onPointerMove={event => { if (start.current && (Math.abs(event.clientX - start.current.x) > 8 || Math.abs(event.clientY - start.current.y) > 8)) cancelPress(); }}\n onPointerUp={cancelPress} onPointerCancel={cancelPress}\n onContextMenu={event => { if (phone && actionable && event.target instanceof Element && !event.target.closest('button, a, input, textarea, [role=button], [role=link]')) { event.preventDefault(); setActionsOpen(true); } }}>\n {heading}\n <div className=\"flex min-w-0 items-end gap-2\">\n {!own && <div data-testid=\"message-avatar\" className=\"flex w-6 shrink-0 items-end\">{lastItem && <MemberAvatar member={author} card={message.via ? undefined : shared.latestCard} size=\"sm\" />}</div>}\n <div data-testid=\"shared-message-content\" className=\"flex min-w-0 flex-1 flex-col gap-1\">\n {body}\n {!message.removed && message.attachments?.map(file => <Button key={file.path} variant=\"outline\" disabled={!openFile} onClick={() => openFile?.(file.path)} className=\"max-w-full justify-start\"><span className=\"truncate\">{file.title ?? file.path}</span></Button>)}\n {error && <p role=\"alert\">{error}</p>}\n </div></div>\n {actionable && <div data-testid=\"shared-message-actions\" hidden={phone && !actionsOpen} className={`absolute right-0 top-0 ${phone && !actionsOpen ? 'hidden' : 'flex'} items-center gap-1${phone ? '' : ' opacity-0 group-hover/shared-message:opacity-100 group-focus-within/shared-message:opacity-100'}`}>\n {replyable && onReply && <Tooltip><TooltipTrigger render={<Button variant=\"ghost\" size={phone ? 'icon' : 'icon-xs'} data-testid=\"shared-reply\" aria-label={labels.sharedReply} onClick={() => { setActionsOpen(false); onReply(message.id); }}><ReplyIcon aria-hidden=\"true\" /></Button>} /><TooltipContent>{labels.sharedReply}</TooltipContent></Tooltip>}\n {removable && remove && <Tooltip><TooltipTrigger render={<Button variant=\"ghost\" size={phone ? 'icon' : 'icon-xs'} aria-label={labels.delete} onClick={() => { void remove(message).catch(error => setError(error instanceof Error ? error.message : labels.actionFailedShort)); }}><Trash2Icon aria-hidden=\"true\" /></Button>} /><TooltipContent>{labels.delete}</TooltipContent></Tooltip>}\n </div>}\n </div>;\n}\n\nexport interface SharedItem {\n readonly key: string; readonly message: ChannelMessage; readonly author: Member; readonly own: boolean; readonly first: boolean; readonly lastItem: boolean; readonly separator: boolean;\n readonly showSpinner: boolean; readonly latestCard: Request | undefined; readonly quote: ReturnType<typeof sharedQuote>;\n}\nexport function sharedItems(shared: SharedChatState): SharedItem[] {\n const working = new Map(shared.messages.filter(message => message.streaming).map(message => [message.author, message.id]));\n const groups: ChannelMessage[][] = [];\n for (const message of shared.messages) {\n const group = groups.at(-1), previous = group?.at(-1);\n const elapsed = previous ? Date.parse(message.at) - Date.parse(previous.at) : NaN;\n if (group && previous?.author === message.author && previous.via === message.via && elapsed >= 0 && elapsed <= 15 * 60_000) group.push(message);\n else groups.push([message]);\n }\n return groups.flatMap((group, groupIndex) => {\n const lastItem = group.at(-1);\n return group.map((message, index): SharedItem => {\n const previous = index > 0 ? group[index - 1] : groups[groupIndex - 1]?.at(-1);\n return { key: `shared:${message.id}`, message, author: memberOf(shared, message.author), own: message.author === shared.me.id,\n first: index === 0, lastItem: message === lastItem,\n separator: !previous || Date.parse(message.at) - Date.parse(previous.at) > 15 * 60_000,\n showSpinner: working.get(message.author) === message.id, latestCard: latestCard(shared, message.author), quote: sharedQuote(shared, message.replyTo) };\n });\n });\n}\n",
|
|
46
|
+
"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 { CircleIcon, 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 { useChatText } from './text';\n\n// Text shares one treatment across headings and muted bubbles; only AA-qualified default palette entries colour it.\nconst agentTones = [\n { token: 'chart-1', icon: 'text-chart-1', text: 'text-foreground' },\n { token: 'chart-2', icon: 'text-chart-2', text: 'text-foreground dark:text-chart-2' },\n { token: 'chart-3', icon: 'text-chart-3', text: 'text-chart-3' },\n { token: 'chart-4', icon: 'text-chart-4', text: 'text-foreground' },\n { token: 'chart-5', icon: 'text-chart-5', text: 'text-foreground' },\n];\nexport function agentTone(member: Pick<Member, 'id' | 'kind'>) {\n if (member.kind !== 'agent') return undefined;\n let hash = 0;\n for (const character of member.id) hash = (Math.imul(hash, 31) + character.charCodeAt(0)) >>> 0;\n return agentTones[hash % agentTones.length];\n}\n\nfunction usePhone() {\n const [phone, setPhone] = useState(() => globalThis.matchMedia?.('(max-width: 640px)').matches === true);\n useEffect(() => { const media = globalThis.matchMedia?.('(max-width: 640px)'); if (!media) return; const change = () => setPhone(media.matches); media.addEventListener('change', change); return () => media.removeEventListener('change', change); }, []);\n return phone;\n}\nconst latestCard = (shared: SharedChatState, id: string) => shared.messages.findLast(message => message.author === id && message.card)?.card;\nconst memberOf = (shared: SharedChatState, id: string): Member => shared.members.find(member => member.id === id) ?? { id, name: id, kind: 'person' };\nexport function sharedQuote(shared: SharedChatState, id: string | undefined) {\n const message = shared.messages.find(message => message.id === id);\n return message ? { message, author: memberOf(shared, message.author) } : undefined;\n}\n\n/** Only https, `data:image/`, `blob:` and same-origin relative addresses load; anything else falls back to the initials. */\nexport function safeAvatarUrl(url: string | undefined): string | undefined {\n if (!url) return undefined;\n return /^(?:https:\\/\\/|data:image\\/|blob:)/i.test(url) || /^\\/(?![/\\\\])/.test(url) ? url : undefined;\n}\n\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 }: { 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 avatar = <Avatar size={compact ? 'sm' : size} data-agent-color={agent?.token} data-testid=\"member-avatar\" role=\"img\" aria-label={member.kind === 'agent' ? `${member.name}, ${labels.sharedAgent}` : member.name} data-state={state}>\n {avatarSrc && <AvatarImage src={avatarSrc} alt={member.name} />}\n <AvatarFallback>{member.kind === 'agent' ? <SparkleIcon data-lucide=\"sparkle\" className={`${compact ? 'size-3' : 'size-3.5'} fill-current ${agent?.icon ?? ''}`} 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 => <li key={member.id} data-member-id={member.id} className=\"flex min-w-0 items-center gap-2\">\n <MemberAvatar member={member} card={latestCard(shared, member.id)} /><span className=\"min-w-0 flex-1 truncate\">{member.name}</span>\n <Badge variant=\"secondary\">{member.role ?? member.kind}</Badge>\n <span data-testid=\"presence\" data-presence={member.presence ?? 'away'} role=\"img\" aria-label={member.presence === 'here' ? labels.sharedHere : labels.sharedAway}><Dot value={member.presence} /></span>\n </li>)}\n </ul>;\n const stack = <AvatarGroup>{shared.members.slice(0, phone ? 1 : 3).map(member => <MemberAvatar key={member.id} member={member} card={latestCard(shared, member.id)} interactive={false} />)}<AvatarGroupCount>{shared.members.length}</AvatarGroupCount></AvatarGroup>;\n const trigger = <Button variant=\"ghost\" data-testid=\"shared-members\" aria-label={labels.sharedMemberCount(shared.members.length)} aria-haspopup=\"dialog\" aria-expanded={open}>{stack}</Button>;\n return <div className=\"flex shrink-0 items-center gap-2\">\n <span data-testid=\"shared-connection-announcement\" className=\"sr-only\" aria-live=\"polite\" aria-atomic=\"true\">{name}</span>\n {shared.status !== 'live' && <Tooltip><TooltipTrigger render={<span tabIndex={0} data-testid=\"shared-status\" data-status={shared.status} role=\"img\" aria-label={name}><Dot value={shared.status} /></span>} /><TooltipContent>{name}</TooltipContent></Tooltip>}\n {phone ? <><Button variant=\"ghost\" data-testid=\"shared-members\" aria-label={labels.sharedMemberCount(shared.members.length)} aria-haspopup=\"dialog\" aria-expanded={open} onClick={() => setOpen(true)}>{stack}</Button>\n {open && <Drawer open onOpenChange={setOpen} showSwipeHandle><DrawerContent className=\"pi-chat max-h-[80dvh]\"><div className=\"flex shrink-0 items-center justify-between p-4\"><DrawerTitle>{labels.sharedMembers}</DrawerTitle><Button variant=\"ghost\" size=\"icon\" data-testid=\"shared-members-close\" aria-label={labels.close} onClick={() => setOpen(false)}><XIcon aria-hidden=\"true\" /></Button></div><div className=\"min-h-0 overflow-y-auto\">{content}</div></DrawerContent></Drawer>}</>\n : <Popover open={open} onOpenChange={setOpen}><PopoverTrigger render={trigger} /><PopoverContent role=\"dialog\" aria-label={labels.sharedMembers} align=\"end\" className=\"max-w-[calc(100vw-1rem)]\">{content}</PopoverContent></Popover>}\n </div>;\n}\n\n/** WhatsApp's tail on the first bubble of a group. Its square part fills the bubble's top corner under the (positioned) content. */\nexport function BubbleTail({ side }: { readonly side: 'start' | 'end' }) {\n return <svg data-testid=\"bubble-tail\" data-side={side} aria-hidden=\"true\" viewBox=\"0 0 22 14\" width=\"22\" height=\"14\"\n className={`pointer-events-none absolute top-0 ${side === 'end' ? '-end-2 -scale-x-100 fill-primary' : '-start-2 fill-muted'}`}><path d=\"M0 0H22V14H8V9C8 5 5 2 0 0Z\" /></svg>;\n}\n\n/** Text whose last line leaves room for the time at the bottom right of the bubble (the spacer rides at the end of the last paragraph). */\nexport const clockRoom = \"[&_:is(p,li):last-child]:after:inline-block [&_:is(p,li):last-child]:after:w-14 [&_:is(p,li):last-child]:after:content-['']\";\n\n/** The time inside a bubble (« 14:02 »). `floating` pins it bottom right over the room the text left; otherwise it sits in the line. */\nexport function MessageClock({ at, tone, floating = true }: { readonly at: string; readonly tone: 'muted' | 'primary'; readonly floating?: boolean }) {\n const { labels } = useChatText();\n return <time data-testid=\"message-time\" dateTime={at} title={labels.sharedTimestamp(at)}\n className={`${floating ? 'absolute end-3 bottom-1.5' : 'shrink-0 self-end'} text-[0.6875rem] leading-none whitespace-nowrap tabular-nums ${tone === 'primary' ? 'opacity-80' : 'text-muted-foreground'}`}>{labels.sharedClock(at)}</time>;\n}\n/** Room for a floating clock after plain inline text. */\nexport const ClockSpacer = () => <span aria-hidden=\"true\" className=\"inline-block w-14\" />;\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/** The author's name on the first bubble of a group in a multi-person channel. Its role shows only on tap (or keyboard), never as a line. */\nexport function AuthorName({ member, via }: { readonly member: Member; readonly via?: string | undefined }) {\n const { labels } = useChatText();\n const role = useRoleLine(member);\n const tone = agentTone(member);\n const name = <span data-testid=\"message-author\" data-agent-color={tone?.token} className={`min-w-0 truncate text-xs font-medium ${tone?.text ?? 'text-muted-foreground'}`}>{member.name}</span>;\n return <div data-testid=\"message-author-row\" className=\"flex min-w-0 items-center gap-1.5 pb-0.5\">\n {role && !(via && member.kind === 'agent')\n ? <Popover><PopoverTrigger nativeButton={false} render={<span role=\"button\" tabIndex={0} data-testid=\"message-author-toggle\" aria-label={`${member.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)]\">{member.name} · {role}</PopoverContent></Popover>\n : name}\n {via && <span data-testid=\"message-via\" className=\"min-w-0 truncate text-xs text-muted-foreground\">{labels.sharedVia(via)}</span>}\n {!(via && member.kind === 'agent') && <MemberBadge member={member} />}\n </div>;\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/** A comment pinned on a spot: a compact quoted reply, a pin and where it is, then the comment itself. */\nexport function PinnedComment({ card, own, at }: { readonly card: Request; readonly own: boolean; readonly at?: string | undefined }) {\n const { labels } = useChatText();\n return <section data-testid=\"pinned-comment\" aria-label={`${labels.sharedPinned}: ${card.title}`} className=\"flex min-w-0 flex-col gap-1\">\n <div data-testid=\"pin-quote\" className=\"flex min-w-0 items-stretch gap-2\">\n <span aria-hidden=\"true\" className=\"w-0.5 shrink-0 bg-current opacity-50\" />\n <span className={`flex min-w-0 items-center gap-1 text-xs ${own ? 'opacity-80' : 'text-muted-foreground'}`}><PinIcon aria-hidden=\"true\" className=\"size-3 shrink-0\" /><span className=\"truncate\">{card.line}</span></span>\n </div>\n <p className=\"m-0 whitespace-pre-wrap\">{card.title}{at && <ClockSpacer />}</p>\n {at && <MessageClock at={at} tone={own ? 'primary' : 'muted'} />}\n </section>;\n}\n\n/** Both the composer chip and the in-bubble quote use this one parent reference. No thread or routing policy. */\nexport function ReplyQuote({ targetId, quote, onRemove, onReveal }: { readonly targetId: string; readonly quote: ReturnType<typeof sharedQuote>; readonly onRemove?: () => void; readonly onReveal?: ((messageId: string) => void) | undefined }) {\n const { labels } = useChatText();\n const text = quote?.message.removed ? labels.sharedRemoved : quote?.message.card?.title ?? quote?.message.text?.split(/\\r?\\n/)[0] ?? labels.sharedEarlierMessage;\n const reveal = (event: { readonly currentTarget: Element }) => {\n const chat = event.currentTarget.closest('[data-boring=pi-chat]');\n const parent = [...(chat?.querySelectorAll<HTMLElement>('[data-shared-message]') ?? [])].find(element => element.dataset.sharedMessage === targetId);\n if (parent) parent.scrollIntoView({ block: 'center', behavior: 'instant' }); else onReveal?.(targetId);\n };\n if (!onRemove) return <div data-testid=\"message-quote\" className=\"-ms-1 flex min-w-0 items-stretch gap-1 pb-1\">\n <span aria-hidden=\"true\" className=\"ms-1 w-0.5 shrink-0 bg-current opacity-50\" />\n <Button variant=\"ghost\" size=\"sm\" className=\"min-w-0 max-w-full justify-start\" onClick={reveal}><span className=\"truncate opacity-80\">{quote ? `${quote.author.name}: ${text}` : text}</span></Button>\n </div>;\n return <div data-testid=\"composer-reply\" className=\"flex w-full min-w-0 items-center gap-1\">\n <Button variant=\"ghost\" className=\"min-w-0 max-w-full justify-start\" onClick={reveal}><ReplyIcon aria-hidden=\"true\" /><span className=\"truncate text-muted-foreground\">{quote ? `${quote.author.name}: ${text}` : text}</span></Button>\n <Tooltip><TooltipTrigger render={<Button variant=\"ghost\" size=\"icon\" data-testid=\"composer-reply-remove\" aria-label={labels.sharedCancelReply} onClick={onRemove}><XIcon aria-hidden=\"true\" /></Button>} /><TooltipContent>{labels.sharedCancelReply}</TooltipContent></Tooltip>\n </div>;\n}\n\n/** Host notices in a row: the latest line, small and centred; the earlier ones open on demand. */\nexport function SystemLines({ lines }: { readonly lines: readonly ChannelMessage[] }) {\n const { labels } = useChatText();\n const [open, setOpen] = useState(false);\n const shown = open ? lines : lines.slice(-1);\n return <div data-testid=\"system-lines\" data-count={lines.length} role=\"group\" className=\"flex min-w-0 flex-col items-center gap-0.5 text-center text-xs text-muted-foreground\">\n {shown.map(line => <p key={line.id} data-shared-message={line.id} data-testid=\"system-line\" className=\"m-0 max-w-[85%] [overflow-wrap:anywhere]\">{line.removed ? labels.sharedRemoved : line.text}</p>)}\n {lines.length > 1 && <Button variant=\"ghost\" size=\"sm\" data-testid=\"system-lines-toggle\" aria-expanded={open} className=\"max-sm:min-h-11 pointer-coarse:min-h-11\" onClick={() => setOpen(value => !value)}>\n {open ? labels.sharedFewerUpdates : labels.sharedMoreUpdates(lines.length - 1)}</Button>}\n </div>;\n}\n\n/** The row around one bubble: the avatar slot in multi-person channels, Reply and Delete on hover, focus or long-press. */\nexport function SharedMessage({ shared, children, avatars = true, remove, canRemove, openFile, onReply }: {\n readonly shared: SharedItem; readonly children: ReactNode;\n /** Multi-person channel: an avatar beside the last bubble of each group (and a slot keeping the others aligned). */\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 [error, setError] = useState<string>();\n const [actionsOpen, setActionsOpen] = useState(false);\n const phone = usePhone();\n const element = useRef<HTMLDivElement>(null);\n const press = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);\n const start = useRef<{ x: number; y: number } | undefined>(undefined);\n const cancelPress = () => { clearTimeout(press.current); start.current = undefined; };\n useEffect(() => cancelPress, []);\n useEffect(() => {\n if (!actionsOpen) return;\n const outside = (event: PointerEvent) => { if (event.target instanceof Node && !element.current?.contains(event.target)) setActionsOpen(false); };\n document.addEventListener('pointerdown', outside);\n return () => document.removeEventListener('pointerdown', outside);\n }, [actionsOpen]);\n const { message, author, own, lastItem } = shared;\n const replyable = Boolean(onReply && ((author.kind === 'agent' && !message.via) || message.card));\n const removable = Boolean(remove && canRemove?.(message));\n const actionable = !message.removed && (replyable || removable);\n 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 {!own && avatars && <div data-testid=\"message-avatar\" className=\"flex w-6 shrink-0 items-end\">{lastItem && <MemberAvatar member={author} size=\"sm\" interactive={false} indicator={false} />}</div>}\n <div data-testid=\"shared-message-content\" className={`flex min-w-0 flex-1 flex-col gap-1${own ? ' [&>button]:self-end' : ''}`}>\n {message.removed ? <p className=\"m-0 text-sm text-muted-foreground italic\">{labels.sharedRemoved}</p> : children}\n {!message.removed && message.attachments?.map(file => <Button key={file.path} variant=\"outline\" disabled={!openFile} onClick={() => openFile?.(file.path)} className=\"max-w-full justify-start\"><span className=\"truncate\">{file.title ?? file.path}</span></Button>)}\n {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 {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): SharedItem[] {\n const working = new Map(shared.messages.filter(message => message.streaming).map(message => [message.author, message.id]));\n const groups: ChannelMessage[][] = [];\n for (const message of shared.messages) {\n const group = groups.at(-1), previous = group?.at(-1);\n const elapsed = previous ? Date.parse(message.at) - Date.parse(previous.at) : NaN;\n const sameDay = previous !== undefined && dayOf(previous.at) === dayOf(message.at);\n const system = message.kind === 'system';\n if (group && previous && sameDay && (system ? previous.kind === 'system'\n : previous.kind !== 'system' && previous.author === message.author && previous.via === message.via && elapsed >= 0 && elapsed <= 15 * 60_000)) group.push(message);\n else groups.push([message]);\n }\n 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), 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), 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: latestCard(shared, message.author), quote: sharedQuote(shared, message.replyTo) }));\n });\n}\n",
|
|
43
47
|
"type": "registry:component",
|
|
44
48
|
"target": "components/shared-chat/messages.tsx"
|
|
45
49
|
},
|
|
46
50
|
{
|
|
47
51
|
"path": "registry/shared-chat/timeline.tsx",
|
|
48
|
-
"content": "'use client';\nimport { Children, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';\nimport type { ReactNode, RefObject } from 'react';\nimport type { SharedChatController } from '@hachej/boring-ui-kit/shared-chat';\nimport type { MentionsConfig } from '../pi-chat/config';\nimport { MessageText } from '../pi-chat/message';\nimport { Markdown } from '../pi-chat/markdown';\nimport { useTranscript } from '../pi-chat/session';\nimport { TranscriptHistory } from '../pi-chat/history';\nimport { Message, MessageContent } from '@/components/ui/message';\nimport { Bubble, BubbleContent } from '@/components/ui/bubble';\nimport { MessageScroller, MessageScrollerViewport, MessageScrollerContent, MessageScrollerItem, MessageScrollerButton } from '@/components/ui/message-scroller';\nimport { SharedMessage, RequestCard, sharedItems } from './messages';\nimport { HostTrace } from './host-trace';\nimport { useChatText } from './text';\n\nexport interface TimelineApi { readonly reset: () => void; readonly reveal: (id: string) => void }\nexport function SharedTimeline({ controller, api, focusComposer, openFile, mentions, pendingVoice }: {\n readonly controller: SharedChatController; readonly api: RefObject<TimelineApi | undefined>;\n readonly focusComposer: () => void; readonly openFile?: ((path: string) => void) | undefined; readonly mentions: MentionsConfig; readonly pendingVoice?: ReactNode;\n}) {\n const state = useSyncExternalStore(controller.subscribe, controller.getSnapshot, controller.getSnapshot);\n const { labels, icons } = useChatText();\n const items = useMemo(() => sharedItems(state.shared), [state.shared]);\n const connected = state.connection.kind === 'connected';\n const transcript = useTranscript(items, 60, 40, { state: state.history, connected: !state.disposed && connected, load: controller.loadEarlier });\n const { stick, hidden } = transcript;\n const root = useRef<HTMLDivElement>(null);\n const [revealing, setRevealing] = useState<string>();\n useEffect(() => {\n const value = { reset: () => { transcript.reset(); void stick.scrollToBottom('instant'); }, reveal: setRevealing };\n api.current = value;\n return () => { if (api.current === value) api.current = undefined; };\n });\n useEffect(() => {\n if (!revealing) return;\n const key = `shared:${revealing}`;\n if (items.some(item => item.key === key)) {\n transcript.revealTo(key);\n const frame = requestAnimationFrame(() => {\n const parent = [...(root.current?.querySelectorAll<HTMLElement>('[data-shared-message]') ?? [])].find(element => element.dataset.sharedMessage === revealing);\n if (parent) { parent.scrollIntoView({ block: 'center', behavior: 'instant' }); setRevealing(undefined); }\n });\n return () => cancelAnimationFrame(frame);\n }\n if (state.history.kind === 'loading') return;\n if (state.history.kind === 'ready' && state.history.hasMore) void controller.loadEarlier().catch(() => setRevealing(undefined));\n else setRevealing(undefined);\n }, [revealing, items, hidden, state.history]);\n const hasContent = items.length > 0 || Children.count(pendingVoice) > 0;\n const context = { pieces: { mentions: true, skills: [], renderMention: mentions.renderSent }, openFile };\n const renderMention = (path: string, text: string) => mentions.renderSent?.(path) ?? text;\n return <MessageScroller>\n <MessageScrollerViewport ref={node => { root.current = node; stick.scrollRef(node); }} data-testid=\"transcript-scroll\" aria-label={labels.messages} className=\"min-h-0 flex-1 scrollbar-thumb-border scrollbar-track-transparent\">\n {items.length > 0 && (hidden > 0 || state.history.kind === 'loading' || state.history.kind === 'error' || state.history.kind === 'ready' && state.history.hasMore) && <TranscriptHistory history={state.history} connected={!state.disposed && connected} onLoad={transcript.reveal} sentinelRef={transcript.sentinelRef} hidden={hidden} loaded={transcript.loaded} />}\n <div className=\"mx-auto w-full max-w-[720px] px-4 pt-6 pb-8 sm:px-6\">\n <MessageScrollerContent aria-label={labels.messages} aria-live=\"polite\" data-testid=\"transcript\" className=\"gap-0 [&>[data-local-voice]]:mt-6\">\n {transcript.visible(items).map((item, index) => {\n const { message, author, own } = item;\n const person = own || author.kind !== 'agent' || message.via !== undefined;\n return <MessageScrollerItem key={item.key} messageId={item.key} className={`[content-visibility:visible]${index > 0 ? item.first ? ' mt-6' : ' mt-2' : ''}`}>\n {item.separator && <div data-testid=\"time-separator\" className=\"mb-6 flex justify-center text-xs text-muted-foreground\"><time dateTime={message.at}>{labels.sharedTimestamp(message.at)}</time></div>}\n <SharedMessage shared={item} remove={controller.remove} canRemove={controller.canRemove} openFile={openFile} onReply={id => { controller.setReplyTo(id); focusComposer(); }} onReveal={setRevealing}>\n {message.card ? <article data-row-id={item.key} data-role=\"event\" className=\"max-w-[80%]\"><RequestCard card={message.card} action={controller.action} /></article>\n : <article data-row-id={item.key} data-role={person ? 'user' : 'assistant'} data-streaming={message.streaming ? 'true' : undefined} className=\"group/message\"><Message align={own ? 'end' : 'start'}><MessageContent>\n {own ? <div data-testid=\"own-message\" className=\"contents [&_[data-testid=message-mention]]:text-inherit\"><MessageText text={message.text ?? ''} context={context} variant=\"default\" /></div> : <Bubble variant=\"muted\"><BubbleContent>\n {message.text && <Markdown text={message.text} streaming={Boolean(message.streaming)} renderMention={renderMention} />}\n {!message.via && author.kind === 'agent' && (message.streaming || message.steps?.length) && <HostTrace participant={author.name} steps={message.steps ?? []} streaming={Boolean(message.streaming)} active={item.showSpinner} />}\n </BubbleContent></Bubble>}\n </MessageContent></Message></article>}\n </SharedMessage>\n </MessageScrollerItem>;\n })}\n {pendingVoice}\n </MessageScrollerContent>\n </div>\n </MessageScrollerViewport>\n {hasContent && <div data-testid=\"transcript-navigation\" className=\"flex h-12 shrink-0 items-center justify-center\">{!stick.isAtBottom && <MessageScrollerButton className=\"static inset-auto translate-x-0\" data-testid=\"jump-latest\" aria-label={labels.jumpToLatest} title={labels.jumpToLatest} onClick={() => api.current?.reset()}><icons.jumpToLatest aria-hidden=\"true\" /></MessageScrollerButton>}</div>}\n </MessageScroller>;\n}\n",
|
|
52
|
+
"content": "'use client';\nimport { Children, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';\nimport type { ReactNode, RefObject } from 'react';\nimport type { SharedChatController } from '@hachej/boring-ui-kit/shared-chat';\nimport type { MentionsConfig } from '../pi-chat/config';\nimport { MessageText } from '../pi-chat/message';\nimport { Markdown } from '../pi-chat-markdown/markdown';\nimport { useTranscript } from '../pi-chat/session';\nimport { TranscriptHistory } from '../pi-chat/history';\nimport { Message, MessageContent } from '@/components/ui/message';\nimport { Bubble, BubbleContent } from '@/components/ui/bubble';\nimport { Badge } from '@/components/ui/badge';\nimport { Button } from '@/components/ui/button';\nimport { MessageScroller, MessageScrollerViewport, MessageScrollerContent, MessageScrollerItem } from '@/components/ui/message-scroller';\nimport { AuthorName, BubbleTail, ClockSpacer, MessageClock, PinnedComment, ReplyQuote, RequestCard, SharedMessage, SystemLines, clockRoom, sharedItems } from './messages';\nimport type { SharedItem } from './messages';\nimport { HostTrace } from './host-trace';\nimport { useChatText } from './text';\n\nexport interface TimelineApi { readonly reset: () => void; readonly reveal: (id: string) => void }\n/**\n * WhatsApp's room: bubbles hug the bottom, a day pill starts each day, host notices collapse into one small line, and a\n * « ↓ 3 new messages » pill appears below the list (never over it) while the reader is scrolled up.\n */\nexport function SharedTimeline({ controller, api, focusComposer, openFile, mentions, pendingVoice }: {\n readonly controller: SharedChatController; readonly api: RefObject<TimelineApi | undefined>;\n readonly focusComposer: () => void; readonly openFile?: ((path: string) => void) | undefined; readonly mentions: MentionsConfig; readonly pendingVoice?: ReactNode;\n}) {\n const state = useSyncExternalStore(controller.subscribe, controller.getSnapshot, controller.getSnapshot);\n const { labels, icons } = useChatText();\n const items = useMemo(() => sharedItems(state.shared), [state.shared]);\n const connected = state.connection.kind === 'connected';\n const transcript = useTranscript(items, 60, 40, { state: state.history, connected: !state.disposed && connected, load: controller.loadEarlier });\n const { stick, hidden } = transcript;\n const root = useRef<HTMLDivElement>(null);\n const [revealing, setRevealing] = useState<string>();\n // The last message the reader has seen at the bottom; what others posted after it while scrolled up is « new ».\n const latest = state.shared.messages.at(-1)?.id;\n const [seen, setSeen] = useState(latest);\n useEffect(() => { if (stick.isAtBottom && seen !== latest) setSeen(latest); }, [stick.isAtBottom, latest, seen]);\n // The pill's row shows only once the reader scrolls up, so loading a room (which scrolls down) never shifts the list under a tap.\n const lastTop = useRef(0);\n const [scrolledUp, setScrolledUp] = useState(false);\n useEffect(() => { if (stick.isAtBottom) setScrolledUp(false); }, [stick.isAtBottom]);\n const seenAt = state.shared.messages.findIndex(message => message.id === seen);\n const unseen = stick.isAtBottom ? 0 : state.shared.messages.slice(seenAt + 1).filter(message => message.author !== state.shared.me.id).length;\n useEffect(() => {\n const value = { reset: () => { transcript.reset(); void stick.scrollToBottom('instant'); }, reveal: setRevealing };\n api.current = value;\n return () => { if (api.current === value) api.current = undefined; };\n });\n useEffect(() => {\n if (!revealing) return;\n const key = items.find(item => item.message.id === revealing || item.system?.some(line => line.id === revealing))?.key;\n if (key) {\n transcript.revealTo(key);\n const frame = requestAnimationFrame(() => {\n const parent = [...(root.current?.querySelectorAll<HTMLElement>('[data-shared-message]') ?? [])].find(element => element.dataset.sharedMessage === revealing);\n if (parent) { parent.scrollIntoView({ block: 'center', behavior: 'instant' }); setRevealing(undefined); }\n });\n return () => cancelAnimationFrame(frame);\n }\n if (state.history.kind === 'loading') return;\n if (state.history.kind === 'ready' && state.history.hasMore) void controller.loadEarlier().catch(() => setRevealing(undefined));\n else setRevealing(undefined);\n }, [revealing, items, hidden, state.history]);\n const hasContent = items.length > 0 || Children.count(pendingVoice) > 0;\n // Avatars and names only where they tell people apart: more than two members.\n const multi = state.shared.members.length > 2;\n const context = { pieces: { mentions: true, skills: [], renderMention: mentions.renderSent }, openFile };\n const renderMention = (path: string, text: string) => mentions.renderSent?.(path) ?? text;\n const reply = (id: string) => { controller.setReplyTo(id); focusComposer(); };\n const bubble = (item: SharedItem) => {\n const { message, author, own, first } = item;\n const person = own || author.kind !== 'agent' || message.via !== undefined;\n const head = first && !own && multi ? <AuthorName member={author} via={message.via} /> : undefined;\n const quote = message.replyTo ? <ReplyQuote targetId={message.replyTo} quote={item.quote} onReveal={setRevealing} /> : undefined;\n if (message.card) return <article data-row-id={item.key} data-role=\"event\" className=\"flex w-full min-w-0 flex-col\">\n <Bubble variant={own ? 'default' : 'muted'} align={own ? 'end' : 'start'} data-testid=\"card-bubble\">\n {first && <BubbleTail side={own ? 'end' : 'start'} />}\n <BubbleContent className=\"relative min-w-52\">{head}{quote}\n {message.card.kind === 'pin' ? <PinnedComment card={message.card} own={own} at={message.at} /> : <RequestCard card={message.card} action={controller.action} at={message.at} own={own} />}\n </BubbleContent>\n </Bubble></article>;\n return <article data-row-id={item.key} data-role={person ? 'user' : 'assistant'} data-streaming={message.streaming ? 'true' : undefined} className=\"group/message\"><Message align={own ? 'end' : 'start'}><MessageContent>\n {own ? <div data-testid=\"own-message\" className=\"contents [&_[data-testid=message-mention]]:text-inherit\">\n <MessageText text={message.text ?? ''} context={context} variant=\"default\" decoration={first ? <BubbleTail side=\"end\" /> : undefined} header={quote} footer={<><ClockSpacer /><MessageClock at={message.at} tone=\"primary\" /></>} /></div>\n : <Bubble variant=\"muted\">{first && <BubbleTail side=\"start\" />}<BubbleContent className=\"relative\">\n {head}{quote}\n {message.text && <div className={message.streaming || message.steps?.length ? undefined : clockRoom}><Markdown text={message.text} streaming={Boolean(message.streaming)} renderMention={renderMention} /></div>}\n {!message.via && author.kind === 'agent' && (message.streaming || message.steps?.length) ? <>\n <HostTrace participant={author.name} steps={message.steps ?? []} streaming={Boolean(message.streaming)} active={item.showSpinner} />\n <div className=\"flex justify-end\"><MessageClock at={message.at} tone=\"muted\" floating={false} /></div>\n </> : <MessageClock at={message.at} tone=\"muted\" />}\n </BubbleContent></Bubble>}\n </MessageContent></Message></article>;\n };\n return <MessageScroller>\n <MessageScrollerViewport ref={node => { root.current = node; stick.scrollRef(node); }} data-testid=\"transcript-scroll\" aria-label={labels.messages} className=\"min-h-0 flex-1 scrollbar-thumb-border scrollbar-track-transparent\"\n onScroll={event => { const top = event.currentTarget.scrollTop; if (top < lastTop.current - 4) setScrolledUp(true); lastTop.current = top; }}>\n {items.length > 0 && (hidden > 0 || state.history.kind === 'loading' || state.history.kind === 'error' || state.history.kind === 'ready' && state.history.hasMore) && <TranscriptHistory history={state.history} connected={!state.disposed && connected} onLoad={transcript.reveal} sentinelRef={transcript.sentinelRef} hidden={hidden} loaded={transcript.loaded} />}\n <div data-testid=\"transcript-bottom\" className=\"mx-auto flex min-h-full w-full max-w-[720px] flex-col justify-end px-4 pt-4 pb-3 sm:px-6\">\n <MessageScrollerContent aria-label={labels.messages} aria-live=\"polite\" data-testid=\"transcript\" className=\"h-auto min-h-0 gap-0 [&>[data-local-voice]]:mt-3\">\n {transcript.visible(items).map((item, index) => {\n const gap = index === 0 || item.separator ? '' : item.system || item.first ? ' mt-3' : ' mt-0.5';\n return <MessageScrollerItem key={item.key} messageId={item.key} data-group-start={item.first ? 'true' : undefined} className={`[content-visibility:visible]${gap}`}>\n {item.separator && <div data-testid=\"day-separator\" className={`flex justify-center pb-3${index > 0 ? ' pt-4' : ''}`}><Badge variant=\"secondary\"><time dateTime={item.message.at} title={labels.sharedTimestamp(item.message.at)}>{labels.sharedDay(item.message.at)}</time></Badge></div>}\n {item.system ? <SystemLines lines={item.system} />\n : <SharedMessage shared={item} avatars={multi} remove={controller.remove} canRemove={controller.canRemove} openFile={openFile} onReply={reply} onReveal={setRevealing}>{bubble(item)}</SharedMessage>}\n </MessageScrollerItem>;\n })}\n {pendingVoice}\n </MessageScrollerContent>\n </div>\n </MessageScrollerViewport>\n {hasContent && !stick.isAtBottom && scrolledUp && <div data-testid=\"transcript-navigation\" className=\"flex shrink-0 items-center justify-center py-1.5\">\n <Button variant=\"secondary\" size=\"sm\" data-testid=\"jump-latest\" data-unseen={unseen || undefined} aria-label={unseen ? labels.sharedNewMessages(unseen) : labels.jumpToLatest} title={labels.jumpToLatest} className=\"max-sm:min-h-11 pointer-coarse:min-h-11\" onClick={() => api.current?.reset()}>\n <icons.jumpToLatest data-icon=\"inline-start\" aria-hidden=\"true\" />{unseen ? labels.sharedNewMessages(unseen) : labels.jumpToLatest}</Button>\n </div>}\n </MessageScroller>;\n}\n",
|
|
49
53
|
"type": "registry:component",
|
|
50
54
|
"target": "components/shared-chat/timeline.tsx"
|
|
51
55
|
},
|
|
@@ -63,13 +67,13 @@
|
|
|
63
67
|
},
|
|
64
68
|
{
|
|
65
69
|
"path": "registry/shared-chat/shared-labels.ts",
|
|
66
|
-
"content": "export function formatTimestamp(at: string, locale: string) {\n const date = new Date(at);\n if (!Number.isFinite(date.getTime())) return at;\n return `${new Intl.DateTimeFormat(locale, { weekday: 'short', day: 'numeric', month: 'short' }).format(date)}, ${new Intl.DateTimeFormat(locale, { hour: '2-digit', minute: '2-digit' }).format(date)}`;\n}\ntype RoleOf = { readonly kind: 'person' | 'agent' | 'admin'; readonly role?: string | undefined };\n/** owner, validator and member read as themselves; every agent is the app's agent; the admin keeps its badge instead. */\nexport function roleLine(member: RoleOf, words: { readonly owner: string; readonly validator: string; readonly member: string; readonly agent: string }): string | undefined {\n if (member.kind === 'admin' || member.role === 'admin') return undefined;\n if (member.kind === 'agent' || member.role?.startsWith('agent:')) return words.agent;\n return member.role === 'owner' || member.role === 'validator' || member.role === 'member' ? words[member.role] : undefined;\n}\nexport const defaultSharedLabels = {\n sharedTimestamp: (at: string) => formatTimestamp(at, 'en'),\n sharedMembers: 'Members', sharedMemberCount: (count: number) => `${count} members`,\n sharedAgent: 'Agent', sharedVia: (name: string) => `via ${name}`,\n sharedWorking: (name: string) => `${name} is working…`,\n sharedReply: 'Reply', sharedCancelReply: 'Cancel reply', sharedEarlierMessage: 'Earlier message',\n sharedAdmin: 'Admin', sharedHere: 'Here', sharedAway: 'Away', sharedOffline: 'Offline',\n sharedRemoved: 'Message removed', sharedSince: (time: string) => `Since ${time}`,\n /** The line beside an author (« Léa · member »). Return undefined for none; a member's own `roleLabel` wins. */\n sharedRole: (member: RoleOf): string | undefined => roleLine(member, { owner: 'owner', validator: 'validator', member: 'member', agent: 'the app\\u2019s agent' }),\n};\nexport type SharedLabels = typeof defaultSharedLabels;\n",
|
|
70
|
+
"content": "export function formatTimestamp(at: string, locale: string) {\n const date = new Date(at);\n if (!Number.isFinite(date.getTime())) return at;\n return `${new Intl.DateTimeFormat(locale, { weekday: 'short', day: 'numeric', month: 'short' }).format(date)}, ${new Intl.DateTimeFormat(locale, { hour: '2-digit', minute: '2-digit' }).format(date)}`;\n}\n/** The time inside a bubble (« 14:02 », « 2:02 PM »). */\nexport function formatClock(at: string, locale: string) {\n const date = new Date(at);\n return Number.isFinite(date.getTime()) ? new Intl.DateTimeFormat(locale, { hour: 'numeric', minute: '2-digit' }).format(date) : '';\n}\n/** A day separator: today and yesterday by name, otherwise « Tue 22 Oct » (with the year when it is not this year). */\nexport function formatDay(at: string, locale: string, words: { readonly today: string; readonly yesterday: string }, now = Date.now()) {\n const date = new Date(at);\n if (!Number.isFinite(date.getTime())) return at;\n const start = (time: number) => { const day = new Date(time); day.setHours(0, 0, 0, 0); return day.getTime(); };\n const days = Math.round((start(now) - start(date.getTime())) / 86_400_000);\n if (days === 0) return words.today;\n if (days === 1) return words.yesterday;\n const sameYear = new Date(now).getFullYear() === date.getFullYear();\n return new Intl.DateTimeFormat(locale, { weekday: 'short', day: 'numeric', month: 'short', ...(sameYear ? {} : { year: 'numeric' }) }).format(date).replace(/,/g, '');\n}\ntype RoleOf = { readonly kind: 'person' | 'agent' | 'admin'; readonly role?: string | undefined };\n/** owner, validator and member read as themselves; every agent is the app's agent; the admin keeps its badge instead. */\nexport function roleLine(member: RoleOf, words: { readonly owner: string; readonly validator: string; readonly member: string; readonly agent: string }): string | undefined {\n if (member.kind === 'admin' || member.role === 'admin') return undefined;\n if (member.kind === 'agent' || member.role?.startsWith('agent:')) return words.agent;\n return member.role === 'owner' || member.role === 'validator' || member.role === 'member' ? words[member.role] : undefined;\n}\nexport const defaultSharedLabels = {\n /** The full date and time, as the tooltip of a bubble's time. */\n sharedTimestamp: (at: string) => formatTimestamp(at, 'en'),\n /** The time inside a bubble, bottom right. */\n sharedClock: (at: string) => formatClock(at, 'en'),\n /** The centred day pill before the first message of each day. */\n sharedDay: (at: string) => formatDay(at, 'en-GB', { today: 'Today', yesterday: 'Yesterday' }),\n sharedNewMessages: (count: number) => count === 1 ? '1 new message' : `${count} new messages`,\n sharedMoreUpdates: (count: number) => count === 1 ? '1 more update' : `${count} more updates`, sharedFewerUpdates: 'Show less',\n sharedPinned: 'Pinned comment',\n sharedMembers: 'Members', sharedMemberCount: (count: number) => `${count} members`,\n sharedAgent: 'Agent', sharedVia: (name: string) => `via ${name}`,\n sharedWorking: (name: string) => `${name} is working…`,\n sharedReply: 'Reply', sharedCancelReply: 'Cancel reply', sharedEarlierMessage: 'Earlier message',\n sharedAdmin: 'Admin', sharedHere: 'Here', sharedAway: 'Away', sharedOffline: 'Offline',\n sharedRemoved: 'Message removed', sharedSince: (time: string) => `Since ${time}`,\n /** The line beside an author (« Léa · member »). Return undefined for none; a member's own `roleLabel` wins. */\n sharedRole: (member: RoleOf): string | undefined => roleLine(member, { owner: 'owner', validator: 'validator', member: 'member', agent: 'the app\\u2019s agent' }),\n};\nexport type SharedLabels = typeof defaultSharedLabels;\n",
|
|
67
71
|
"type": "registry:lib",
|
|
68
72
|
"target": "components/shared-chat/shared-labels.ts"
|
|
69
73
|
},
|
|
70
74
|
{
|
|
71
75
|
"path": "registry/shared-chat/shared-labels-fr.ts",
|
|
72
|
-
"content": "import type { SharedLabels } from './shared-labels';\nimport { formatTimestamp, roleLine } from './shared-labels';\nexport const frenchSharedLabels: SharedLabels = {\n sharedTimestamp: (at: string) => formatTimestamp(at, 'fr'),\n sharedMembers: 'Membres', sharedMemberCount: (count: number) => `${count} membres`,\n sharedAgent: 'Agent', sharedVia: (name: string) => `via ${name}`,\n sharedWorking: (name: string) => `${name} travaille…`,\n sharedReply: 'Répondre', sharedCancelReply: 'Annuler la réponse', sharedEarlierMessage: 'Message précédent',\n sharedAdmin: 'Admin', sharedHere: 'Présent', sharedAway: 'Absent', sharedOffline: 'Hors ligne',\n sharedRemoved: 'Message supprimé', sharedSince: (time: string) => `Depuis ${time}`,\n sharedRole: member => roleLine(member, { owner: 'propriétaire', validator: 'validateur', member: 'membre', agent: 'l\\u2019agent de l\\u2019app' }),\n};\n",
|
|
76
|
+
"content": "import type { SharedLabels } from './shared-labels';\nimport { formatClock, formatDay, formatTimestamp, roleLine } from './shared-labels';\nexport const frenchSharedLabels: SharedLabels = {\n sharedTimestamp: (at: string) => formatTimestamp(at, 'fr'),\n sharedClock: (at: string) => formatClock(at, 'fr'),\n sharedDay: (at: string) => formatDay(at, 'fr', { today: 'Aujourd\\u2019hui', yesterday: 'Hier' }),\n sharedNewMessages: (count: number) => count === 1 ? '1 nouveau message' : `${count} nouveaux messages`,\n sharedMoreUpdates: (count: number) => count === 1 ? '1 autre nouvelle' : `${count} autres nouvelles`, sharedFewerUpdates: 'Réduire',\n sharedPinned: 'Commentaire épinglé',\n sharedMembers: 'Membres', sharedMemberCount: (count: number) => `${count} membres`,\n sharedAgent: 'Agent', sharedVia: (name: string) => `via ${name}`,\n sharedWorking: (name: string) => `${name} travaille…`,\n sharedReply: 'Répondre', sharedCancelReply: 'Annuler la réponse', sharedEarlierMessage: 'Message précédent',\n sharedAdmin: 'Admin', sharedHere: 'Présent', sharedAway: 'Absent', sharedOffline: 'Hors ligne',\n sharedRemoved: 'Message supprimé', sharedSince: (time: string) => `Depuis ${time}`,\n sharedRole: member => roleLine(member, { owner: 'propriétaire', validator: 'validateur', member: 'membre', agent: 'l\\u2019agent de l\\u2019app' }),\n};\n",
|
|
73
77
|
"type": "registry:lib",
|
|
74
78
|
"target": "components/shared-chat/shared-labels-fr.ts"
|
|
75
79
|
}
|
package/r/task-list-viewer.json
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
"title": "Consumer task-list viewer",
|
|
5
5
|
"description": "A consumer-defined task-list schema and controller over the shared text buffer, with a restylable borrowed React renderer.",
|
|
6
6
|
"dependencies": [
|
|
7
|
-
"@hachej/boring-ui-kit@^0.4.
|
|
8
|
-
"@hachej/boring-files@^0.4.
|
|
7
|
+
"@hachej/boring-ui-kit@^0.4.12",
|
|
8
|
+
"@hachej/boring-files@^0.4.12",
|
|
9
9
|
"react@^19.0.0",
|
|
10
10
|
"react-dom@^19.0.0",
|
|
11
11
|
"zod@^4.6.5"
|
package/r/version-history.json
CHANGED
|
@@ -4,12 +4,12 @@
|
|
|
4
4
|
"title": "Version history rows",
|
|
5
5
|
"description": "Shared view-only version rows and menu metadata with relative time, provenance and Current. No file reads, publication or restoration.",
|
|
6
6
|
"dependencies": [
|
|
7
|
-
"@hachej/boring-files@^0.4.
|
|
7
|
+
"@hachej/boring-files@^0.4.12",
|
|
8
8
|
"react@^19.0.0"
|
|
9
9
|
],
|
|
10
10
|
"registryDependencies": [
|
|
11
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
12
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
11
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.12/r/theme.json",
|
|
12
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.12/r/button.json",
|
|
13
13
|
"button"
|
|
14
14
|
],
|
|
15
15
|
"files": [
|
package/r/viewers.json
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
"title": "Viewers",
|
|
5
5
|
"description": "The standard viewer top bar (title, status, viewer controls, Refresh, Share, Copy, Download, Open in new tab, Close, overflow menu) with Tailwind panes over it: the Markdown editor with a formatting toolbar and prose styling, the passive HTML preview and source editor, an image viewer with zoom and pan, a PDF viewer, and LocalFileViewer for browser-only files. Owned, editable source over the borrowed controllers; no AI SDK.",
|
|
6
6
|
"dependencies": [
|
|
7
|
-
"@hachej/boring-ui-kit@^0.4.
|
|
8
|
-
"@hachej/boring-files@^0.4.
|
|
7
|
+
"@hachej/boring-ui-kit@^0.4.12",
|
|
8
|
+
"@hachej/boring-files@^0.4.12",
|
|
9
9
|
"react@^19.0.0",
|
|
10
10
|
"react-dom@^19.0.0",
|
|
11
11
|
"marked@^17.0.6",
|
|
@@ -25,18 +25,18 @@
|
|
|
25
25
|
"pdfjs-dist@6.3.289"
|
|
26
26
|
],
|
|
27
27
|
"registryDependencies": [
|
|
28
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
29
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
30
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
28
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.12/r/theme.json",
|
|
29
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.12/r/utils.json",
|
|
30
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.12/r/button.json",
|
|
31
31
|
"button",
|
|
32
32
|
"toggle",
|
|
33
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
33
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.12/r/version-history.json",
|
|
34
34
|
"toggle-group",
|
|
35
35
|
"dialog",
|
|
36
36
|
"textarea",
|
|
37
37
|
"badge",
|
|
38
38
|
"dropdown-menu",
|
|
39
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
39
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.12/r/markdown-editor.json"
|
|
40
40
|
],
|
|
41
41
|
"files": [
|
|
42
42
|
{
|
package/r/windows.json
CHANGED