@hachej/boring-ui-kit 0.4.38 → 0.4.40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/dist/native-chat.js +11 -1
  2. package/dist/task-board.d.ts +4 -0
  3. package/package.json +2 -2
  4. package/r/agent-target.json +4 -4
  5. package/r/app-shell.json +10 -10
  6. package/r/board-viewer.json +6 -6
  7. package/r/button.json +1 -1
  8. package/r/dock.json +1 -1
  9. package/r/documents.json +12 -12
  10. package/r/feedback-preview.json +1 -1
  11. package/r/feedback.json +6 -6
  12. package/r/file-tree.json +4 -4
  13. package/r/highlights.json +3 -3
  14. package/r/html-viewer.json +3 -3
  15. package/r/looks-viewer.json +2 -2
  16. package/r/maquette-viewer.json +1 -1
  17. package/r/markdown-editor.json +3 -3
  18. package/r/mockup-viewer.json +1 -1
  19. package/r/pi-ambient.json +8 -8
  20. package/r/pi-app-kinds.json +10 -10
  21. package/r/pi-app.json +10 -10
  22. package/r/pi-artifacts.json +2 -2
  23. package/r/pi-chat-approval.json +2 -2
  24. package/r/pi-chat-attachments.json +1 -1
  25. package/r/pi-chat-markdown.json +2 -2
  26. package/r/pi-chat-mentions.json +1 -1
  27. package/r/pi-chat-pickers.json +1 -1
  28. package/r/pi-chat-plugins.json +11 -11
  29. package/r/pi-chat-question.json +2 -2
  30. package/r/pi-chat-slash.json +2 -2
  31. package/r/pi-chat-subagent.json +1 -1
  32. package/r/pi-chat-voice.json +3 -3
  33. package/r/pi-chat.json +7 -7
  34. package/r/pi-workspace.json +3 -3
  35. package/r/placed-agent.json +4 -4
  36. package/r/pm-panel.json +18 -18
  37. package/r/provider-setup.json +1 -1
  38. package/r/registry.json +188 -183
  39. package/r/requests-board.json +1 -1
  40. package/r/shared-chat.json +8 -8
  41. package/r/task-board.json +10 -4
  42. package/r/task-list-viewer.json +2 -2
  43. package/r/task-mentions.json +4 -4
  44. package/r/version-history.json +2 -2
  45. package/r/viewer-code.json +2 -2
  46. package/r/viewer-frame.json +4 -4
  47. package/r/viewer-html.json +4 -4
  48. package/r/viewer-image.json +2 -2
  49. package/r/viewer-markdown.json +5 -5
  50. package/r/viewer-pdf.json +3 -3
  51. package/r/viewers.json +6 -6
  52. package/r/windows.json +1 -1
@@ -7,7 +7,7 @@
7
7
  "react@^19.0.0"
8
8
  ],
9
9
  "registryDependencies": [
10
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.38/r/utils.json"
10
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.40/r/utils.json"
11
11
  ],
12
12
  "files": [
13
13
  {
@@ -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.38",
7
+ "@hachej/boring-ui-kit@^0.4.40",
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.38/r/pi-chat.json",
13
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.38/r/pi-chat-plugins.json",
14
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.38/r/pi-chat-markdown.json",
15
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.38/r/pi-chat-mentions.json",
16
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.38/r/utils.json"
12
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.40/r/pi-chat.json",
13
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.40/r/pi-chat-plugins.json",
14
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.40/r/pi-chat-markdown.json",
15
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.40/r/pi-chat-mentions.json",
16
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.40/r/utils.json"
17
17
  ],
18
18
  "files": [
19
19
  {
@@ -30,13 +30,13 @@
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 return <section data-testid=\"question-card\" data-answered={question.answered ? 'true' : undefined} aria-label={question.prompt} className=\"flex min-w-0 flex-col text-start\">\n <div className=\"flex min-w-0 items-baseline gap-3\">\n <p data-testid=\"question-prompt\" className=\"m-0 min-w-0 flex-1 whitespace-pre-wrap\">{question.prompt}</p>\n <MessageClock at={message.at} tone={own ? 'primary' : 'muted'} floating={false} />\n </div>\n {error && <p role=\"alert\" className=\"m-0 text-destructive\">{error}</p>}\n {!open && <p data-testid=\"question-answered\" className=\"m-0 flex items-center gap-1 text-xs text-muted-foreground\"><CheckIcon aria-hidden=\"true\" className=\"size-3\" />{labels.sharedAnswered}</p>}\n {open && (question.options.length > 0 || question.allowFreeText) && <div role=\"group\" aria-label={question.prompt} className=\"mt-2 flex min-w-0 flex-col gap-1.5\">\n {question.options.map((option, index) => <Button key={`${index}:${option}`} data-testid=\"question-option\" variant=\"outline\" className=\"min-h-11 min-w-0 justify-start\" disabled={busy !== undefined} aria-busy={busy === option || undefined} onClick={() => run(option)}><span className=\"truncate\">{option}</span></Button>)}\n {question.allowFreeText && <Button data-testid=\"question-reply\" variant=\"ghost\" className=\"min-h-11 min-w-0 justify-start\" onClick={() => onReply(message.id)}><ReplyIcon data-icon=\"inline-start\" aria-hidden=\"true\" /><span className=\"truncate\">{labels.sharedReply}</span></Button>}\n </div>}\n </section>;\n}\n\n/** A comment pinned on a spot: a compact quoted reply, a pin and where it is, then the comment itself. */\nexport function PinnedComment({ card, own, at }: { readonly card: Request; readonly own: boolean; readonly at?: string | undefined }) {\n const { labels } = useChatText();\n return <section data-testid=\"pinned-comment\" aria-label={`${labels.sharedPinned}: ${card.title}`} className=\"flex min-w-0 flex-col gap-1\">\n <div data-testid=\"pin-quote\" className=\"flex min-w-0 items-stretch gap-2\">\n <span aria-hidden=\"true\" className=\"w-0.5 shrink-0 bg-current opacity-50\" />\n <span className={`flex min-w-0 items-center gap-1 text-xs ${own ? 'opacity-80' : 'text-muted-foreground'}`}><PinIcon aria-hidden=\"true\" className=\"size-3 shrink-0\" /><span className=\"truncate\">{card.line}</span></span>\n </div>\n <p className=\"m-0 whitespace-pre-wrap\">{card.title}{at && <ClockSpacer />}</p>\n {at && <MessageClock at={at} tone={own ? 'primary' : 'muted'} />}\n </section>;\n}\n\n/** Both the composer chip and the in-bubble quote use this one parent reference. No thread or routing policy. */\nexport function ReplyQuote({ targetId, quote, onRemove, onReveal }: { readonly targetId: string; readonly quote: ReturnType<typeof sharedQuote>; readonly onRemove?: () => void; readonly onReveal?: ((messageId: string) => void) | undefined }) {\n const { labels } = useChatText();\n const text = quote?.message.removed ? labels.sharedRemoved : quote?.message.card?.title ?? quote?.message.text?.split(/\\r?\\n/)[0] ?? labels.sharedEarlierMessage;\n const reveal = (event: { readonly currentTarget: Element }) => {\n const chat = event.currentTarget.closest('[data-boring=pi-chat]');\n const parent = [...(chat?.querySelectorAll<HTMLElement>('[data-shared-message]') ?? [])].find(element => element.dataset.sharedMessage === targetId);\n if (parent) parent.scrollIntoView({ block: 'center', behavior: 'instant' }); else onReveal?.(targetId);\n };\n if (!onRemove) return <div data-testid=\"message-quote\" className=\"-ms-1 flex min-w-0 items-stretch gap-1 pb-1\">\n <span aria-hidden=\"true\" className=\"ms-1 w-0.5 shrink-0 bg-current opacity-50\" />\n <Button variant=\"ghost\" size=\"sm\" className=\"min-w-0 max-w-full justify-start\" onClick={reveal}><span className=\"truncate opacity-80\">{quote ? `${quote.author.name}: ${text}` : text}</span></Button>\n </div>;\n return <div data-testid=\"composer-reply\" className=\"flex w-full min-w-0 items-center gap-1\">\n <Button variant=\"ghost\" className=\"min-w-0 max-w-full justify-start\" onClick={reveal}><ReplyIcon aria-hidden=\"true\" /><span className=\"truncate text-muted-foreground\">{quote ? `${quote.author.name}: ${text}` : text}</span></Button>\n <Tooltip><TooltipTrigger render={<Button variant=\"ghost\" size=\"icon\" data-testid=\"composer-reply-remove\" aria-label={labels.sharedCancelReply} onClick={onRemove}><XIcon aria-hidden=\"true\" /></Button>} /><TooltipContent>{labels.sharedCancelReply}</TooltipContent></Tooltip>\n </div>;\n}\n\n/** Host notices in a row: the latest line, small and centred; the earlier ones open on demand. */\nexport function SystemLines({ lines }: { readonly lines: readonly ChannelMessage[] }) {\n const { labels } = useChatText();\n const [open, setOpen] = useState(false);\n const shown = open ? lines : lines.slice(-1);\n return <div data-testid=\"system-lines\" data-count={lines.length} role=\"group\" className=\"flex min-w-0 flex-col items-center gap-0.5 text-center text-xs text-muted-foreground\">\n {shown.map(line => <p key={line.id} data-shared-message={line.id} data-testid=\"system-line\" className=\"m-0 max-w-[85%] [overflow-wrap:anywhere]\">{line.removed ? labels.sharedRemoved : line.text}</p>)}\n {lines.length > 1 && <Button variant=\"ghost\" size=\"sm\" data-testid=\"system-lines-toggle\" aria-expanded={open} className=\"max-md:min-h-11 pointer-coarse:min-h-11\" onClick={() => setOpen(value => !value)}>\n {open ? labels.sharedFewerUpdates : labels.sharedMoreUpdates(lines.length - 1)}</Button>}\n </div>;\n}\n\n/** The row around one bubble: the avatar slot in multi-person channels, Reply and Delete on hover, focus or long-press. */\nexport function SharedMessage({ shared, children, remove, canRemove, openFile, onReply }: {\n readonly shared: SharedItem; readonly children: ReactNode;\n /** Multi-person channel (kept for hosts passing it): the avatar now rides in the author row above a group's first bubble (AuthorName). */\n readonly avatars?: boolean;\n readonly remove?: ((message: ChannelMessage) => Promise<void>) | undefined;\n readonly canRemove?: ((message: ChannelMessage) => boolean) | undefined;\n readonly openFile?: ((path: string) => void) | undefined;\n readonly onReply?: ((messageId: string) => void) | undefined;\n readonly onReveal?: ((messageId: string) => void) | undefined;\n}) {\n const { labels } = useChatText();\n const view = useSharedView();\n const [error, setError] = useState<string>();\n const [copied, setCopied] = useState(false);\n const copiedTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);\n useEffect(() => () => clearTimeout(copiedTimer.current), []);\n const [actionsOpen, setActionsOpen] = useState(false);\n const phone = usePhone();\n const element = useRef<HTMLDivElement>(null);\n const press = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);\n const start = useRef<{ x: number; y: number } | undefined>(undefined);\n const cancelPress = () => { clearTimeout(press.current); start.current = undefined; };\n useEffect(() => cancelPress, []);\n useEffect(() => {\n if (!actionsOpen) return;\n const outside = (event: PointerEvent) => { if (event.target instanceof Node && !element.current?.contains(event.target)) setActionsOpen(false); };\n document.addEventListener('pointerdown', outside);\n return () => document.removeEventListener('pointerdown', outside);\n }, [actionsOpen]);\n const { message, author, own } = shared;\n const replyable = Boolean(onReply && ((author.kind === 'agent' && !message.via) || message.card));\n const removable = Boolean(remove && canRemove?.(message));\n // An answer: the app agent's own message (not a person's, not a card, not a host notice).\n const answer = !own && author.kind === 'agent' && !message.via && !message.card && message.kind !== 'system' && !message.removed;\n const plain = view.answerStyle === 'plain' && answer;\n const copyable = answer && Boolean(message.text) && view.answerActions?.includes('copy') === true;\n const copy = () => { void copyText(message.text ?? '').then(() => { setCopied(true); clearTimeout(copiedTimer.current); copiedTimer.current = setTimeout(() => setCopied(false), 1500); }, () => setError(labels.copyFailed)); };\n const footer = answer ? view.footer?.(message) : undefined;\n // In a plain answer the copy action is a visible text button under it; in a bubble it is an icon on hover with Reply and Delete.\n const copyInline = copyable && plain, copyOnHover = copyable && !plain;\n const actionable = !message.removed && (replyable || removable || copyOnHover);\n return <div ref={element} data-shared-message={message.id} data-own={own ? 'true' : undefined} className=\"group/shared-message relative flex min-w-0 flex-col\"\n tabIndex={phone && actionable ? 0 : undefined} aria-keyshortcuts={phone && actionable ? 'Shift+F10' : undefined}\n onKeyDown={event => {\n if (!phone || !actionable) return;\n if (event.key === 'ContextMenu' || event.key === 'F10' && event.shiftKey) { event.preventDefault(); setActionsOpen(true); }\n if (event.key === 'Escape') { setActionsOpen(false); element.current?.focus(); }\n }}\n onPointerDown={event => {\n if (!phone || !actionable || event.pointerType !== 'touch' || event.target instanceof Element && event.target.closest('button, a, input, textarea, [role=button], [role=link]')) return;\n cancelPress(); start.current = { x: event.clientX, y: event.clientY };\n press.current = setTimeout(() => setActionsOpen(true), 400);\n }}\n onPointerMove={event => { if (start.current && (Math.abs(event.clientX - start.current.x) > 8 || Math.abs(event.clientY - start.current.y) > 8)) cancelPress(); }}\n onPointerUp={cancelPress} onPointerCancel={cancelPress}\n onContextMenu={event => { if (phone && actionable && event.target instanceof Element && !event.target.closest('button, a, input, textarea, [role=button], [role=link]')) { event.preventDefault(); setActionsOpen(true); } }}>\n <div className=\"flex min-w-0 items-end gap-2.5\">\n <div data-testid=\"shared-message-content\" className={`flex min-w-0 flex-1 flex-col gap-1${own ? ' [&>button]:self-end' : ''}`}>\n {message.removed ? <p className=\"m-0 text-sm text-muted-foreground italic\">{labels.sharedRemoved}</p> : children}\n {!message.removed && view.artifacts?.Card && message.artifacts?.map((value, index) => {\n const descriptor = parsePresentedArtifact(value);\n if (!descriptor) return null;\n const config = view.artifacts!, Card = config.Card!;\n return <Card key={index} artifact={{ state: 'ready', id: artifactKey(descriptor), title: descriptor.title, path: descriptor.type === 'task' ? undefined : descriptor.target.resource.path, own: true, descriptor }}\n open={config.isOpen?.(descriptor as never) ?? false} onOpen={() => config.open(descriptor as never)} kinds={config.kinds} />;\n })}\n {!message.removed && message.attachments?.map(file => <Button key={file.path} variant=\"outline\" disabled={!openFile} onClick={() => openFile?.(file.path)} className=\"max-w-full justify-start\"><span className=\"truncate\">{file.title ?? file.path}</span></Button>)}\n {(copyInline || footer) && <div data-testid=\"message-footer\" className=\"flex min-w-0 flex-wrap items-center gap-x-3 text-xs text-muted-foreground\">\n {copyInline && <Button variant=\"ghost\" size=\"sm\" data-testid=\"message-copy\" onClick={copy}>{copied ? <CheckIcon data-icon=\"inline-start\" aria-hidden=\"true\" /> : <CopyIcon data-icon=\"inline-start\" aria-hidden=\"true\" />}{copied ? labels.copied : labels.copyResponse}</Button>}\n {footer}\n </div>}\n {error && <p role=\"alert\">{error}</p>}\n </div></div>\n {actionable && <div data-testid=\"shared-message-actions\" hidden={phone && !actionsOpen} className={`absolute ${own ? 'left-0' : 'right-0'} top-0 ${phone && !actionsOpen ? 'hidden' : 'flex'} items-center gap-1${phone ? '' : ' opacity-0 group-hover/shared-message:opacity-100 group-focus-within/shared-message:opacity-100'}`}>\n {copyOnHover && <Tooltip><TooltipTrigger render={<Button variant=\"ghost\" size={phone ? 'icon' : 'icon-xs'} data-testid=\"message-copy\" aria-label={copied ? labels.copied : labels.copyResponse} onClick={copy}>{copied ? <CheckIcon aria-hidden=\"true\" /> : <CopyIcon aria-hidden=\"true\" />}</Button>} /><TooltipContent>{copied ? labels.copied : labels.copyResponse}</TooltipContent></Tooltip>}\n {replyable && onReply && <Tooltip><TooltipTrigger render={<Button variant=\"ghost\" size={phone ? 'icon' : 'icon-xs'} data-testid=\"shared-reply\" aria-label={labels.sharedReply} onClick={() => { setActionsOpen(false); onReply(message.id); }}><ReplyIcon aria-hidden=\"true\" /></Button>} /><TooltipContent>{labels.sharedReply}</TooltipContent></Tooltip>}\n {removable && remove && <Tooltip><TooltipTrigger render={<Button variant=\"ghost\" size={phone ? 'icon' : 'icon-xs'} aria-label={labels.delete} onClick={() => { void remove(message).catch(error => setError(error instanceof Error ? error.message : labels.actionFailedShort)); }}><Trash2Icon aria-hidden=\"true\" /></Button>} /><TooltipContent>{labels.delete}</TooltipContent></Tooltip>}\n </div>}\n </div>;\n}\n\nexport interface SharedItem {\n readonly key: string; readonly message: ChannelMessage; readonly author: Member; readonly own: boolean; readonly first: boolean; readonly lastItem: boolean;\n /** The first message of a calendar day: a day pill goes before it. */\n readonly separator: boolean;\n /** A run of consecutive host notices (`kind: 'system'`), shown as one collapsed item; `message` is its latest line. */\n readonly system?: readonly ChannelMessage[] | undefined;\n readonly showSpinner: boolean; readonly latestCard: Request | undefined; readonly quote: ReturnType<typeof sharedQuote>;\n}\nconst dayOf = (at: string) => { const date = new Date(at); return Number.isFinite(date.getTime()) ? date.toDateString() : at; };\n/**\n * WhatsApp's grouping: one author (and `via`) within fifteen minutes on the same day is a group; host notices end a group and\n * collapse together. Each day starts with a day pill.\n */\nexport function sharedItems(shared: SharedChatState, nameOf: NameOf = defaultSharedLabels.sharedName): SharedItem[] {\n // A question retains cards while waiting; the final reply takes their place without drawing a second copy.\n const latestArtifact = new Map<string, string>();\n const keyOf = (value: unknown) => {\n const artifact = parsePresentedArtifact(value);\n return artifact ? JSON.stringify(artifact.type === 'task' ? artifact.subject : [artifact.target, artifact.revision]) : undefined;\n };\n for (const message of shared.messages) if (!message.removed) for (const artifact of message.artifacts ?? []) {\n const key = keyOf(artifact); if (key) latestArtifact.set(key, message.id);\n }\n const working = new Map(shared.messages.filter(message => message.streaming).map(message => [message.author, message.id]));\n const groups: ChannelMessage[][] = [];\n for (const original of shared.messages) {\n const seen = new Set<string>();\n const artifacts = original.artifacts?.filter(artifact => {\n const key = keyOf(artifact);\n if (!key || seen.has(key) || latestArtifact.get(key) !== original.id) return false;\n seen.add(key); return true;\n });\n const message = artifacts && artifacts.length !== original.artifacts?.length ? { ...original, artifacts } : original;\n const group = groups.at(-1), previous = group?.at(-1);\n const elapsed = previous ? Date.parse(message.at) - Date.parse(previous.at) : NaN;\n const sameDay = previous !== undefined && dayOf(previous.at) === dayOf(message.at);\n const system = message.kind === 'system';\n if (group && previous && sameDay && (system ? previous.kind === 'system'\n : previous.kind !== 'system' && previous.author === message.author && previous.via === message.via && elapsed >= 0 && elapsed <= 15 * 60_000)) group.push(message);\n else groups.push([message]);\n }\n // One pass for what every item reads, instead of a search through the whole thread per message.\n const cards = new Map<string, Request>(), byId = new Map<string, ChannelMessage>();\n for (const message of shared.messages) { byId.set(message.id, message); if (message.card) cards.set(message.author, message.card); }\n const quoteOf = (replyTo: string | undefined) => { const message = replyTo === undefined ? undefined : byId.get(replyTo); return message ? { message, author: memberOf(shared, message.author, nameOf) } : undefined; };\n return groups.flatMap((group, groupIndex): SharedItem[] => {\n const before = groups[groupIndex - 1]?.at(-1);\n const separator = !before || dayOf(before.at) !== dayOf(group[0]!.at);\n if (group[0]!.kind === 'system') {\n const message = group.at(-1)!;\n return [{ key: `shared:${group[0]!.id}`, message, author: memberOf(shared, message.author, nameOf), own: false, first: true, lastItem: true, separator, system: group,\n showSpinner: false, latestCard: undefined, quote: undefined }];\n }\n const lastItem = group.at(-1);\n return group.map((message, index): SharedItem => ({ key: `shared:${message.id}`, message, author: memberOf(shared, message.author, nameOf), own: message.author === shared.me.id,\n first: index === 0, lastItem: message === lastItem, separator: index === 0 && separator,\n showSpinner: working.get(message.author) === message.id, latestCard: cards.get(message.author), quote: quoteOf(message.replyTo) }));\n });\n}\n",
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",
34
34
  "type": "registry:component",
35
35
  "target": "components/shared-chat/messages.tsx"
36
36
  },
37
37
  {
38
38
  "path": "registry/shared-chat/timeline.tsx",
39
- "content": "'use client';\nimport { Children, memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';\nimport type { ReactNode, RefObject } from 'react';\nimport type { SharedChatController } from '@hachej/boring-ui-kit/shared-chat';\nimport type { ChatFeatureProps } from '../pi-chat/session';\nimport type { MentionsConfig } from '../pi-chat/config';\nimport { MessageText } from '../pi-chat/message';\nimport { Markdown } from '../pi-chat-markdown/markdown';\nimport { PAGE_FIRST, PAGE_MORE, useRowChunks, useTranscript } from '../pi-chat/session';\nimport type { ChatPaging } from '../pi-chat/session';\nimport { TranscriptHistory } from '../pi-chat/history';\nimport { Message, MessageContent } from '@/components/ui/message';\nimport { Bubble, BubbleContent } from '@/components/ui/bubble';\nimport { Badge } from '@/components/ui/badge';\nimport { Button } from '@/components/ui/button';\nimport { MessageScroller, MessageScrollerViewport, MessageScrollerContent, MessageScrollerItem } from '@/components/ui/message-scroller';\nimport { showsAuthors, runHead } from './names';\nimport { AuthorName, BubbleTail, ClockSpacer, MessageClock, PinnedComment, QuestionCard, ReplyQuote, RequestCard, SharedMessage, SystemLines, ViaLine, clockRoom, sharedItems } from './messages';\nimport type { SharedItem } from './messages';\nimport { HostTrace } from './host-trace';\nimport { useChatText, useSharedView } from './text';\n\nexport interface TimelineApi { readonly reset: () => void; readonly reveal: (id: string) => void }\n/**\n * WhatsApp's room: bubbles hug the bottom, a day pill starts each day, host notices collapse into one small line, and a\n * « ↓ 3 new messages » pill appears below the list (never over it) while the reader is scrolled up.\n */\nexport function SharedTimeline({ controller, api, focusComposer, openFile, mentions, pendingVoice, paging, targetLabel }: {\n readonly controller: SharedChatController; readonly api: RefObject<TimelineApi | undefined>;\n readonly focusComposer: () => void; readonly openFile?: ((path: string) => void) | undefined; readonly mentions: MentionsConfig; readonly pendingVoice?: ReactNode; readonly paging?: ChatPaging | undefined;\n readonly targetLabel?: ChatFeatureProps['targetLabel'];\n}) {\n const state = useSyncExternalStore(controller.subscribe, controller.getSnapshot, controller.getSnapshot);\n const { labels, icons } = useChatText();\n const view = useSharedView();\n const items = useStableItems(useMemo(() => sharedItems(state.shared, labels.sharedName), [state.shared, labels.sharedName]));\n const connected = state.connection.kind === 'connected';\n const transcript = useTranscript(items, paging?.first ?? PAGE_FIRST, paging?.more ?? PAGE_MORE, { state: state.history, connected: !state.disposed && connected, load: controller.loadEarlier });\n const { stick, hidden } = transcript;\n const chunks = useRowChunks(useMemo(() => hidden ? items.slice(hidden) : items, [items, hidden]));\n const root = useRef<HTMLDivElement>(null);\n const [revealing, setRevealing] = useState<string>();\n // The last message the reader has seen at the bottom; what others posted after it while scrolled up is « new ».\n const latest = state.shared.messages.at(-1)?.id;\n const [seen, setSeen] = useState(latest);\n // eslint-disable-next-line react-hooks/set-state-in-effect -- marks the latest message seen once the reader is at the bottom, after commit; settles when equal\n useEffect(() => { if (stick.isAtBottom && seen !== latest) setSeen(latest); }, [stick.isAtBottom, latest, seen]);\n // The pill's row shows only once the reader scrolls up, so loading a room (which scrolls down) never shifts the list under a tap.\n const lastTop = useRef(0);\n const [scrolledUp, setScrolledUp] = useState(false);\n // eslint-disable-next-line react-hooks/set-state-in-effect -- reaching the bottom hides the pill's row once, after commit\n useEffect(() => { if (stick.isAtBottom) setScrolledUp(false); }, [stick.isAtBottom]);\n const seenAt = state.shared.messages.findIndex(message => message.id === seen);\n const unseen = stick.isAtBottom ? 0 : state.shared.messages.slice(seenAt + 1).filter(message => message.author !== state.shared.me.id).length;\n // The reader's own new message lands in view, wherever they were: on Send it is drawn at once (pending), and Send's own scroll\n // runs before that render. Its confirmation (same clientId) is the same message and does not scroll again.\n const mine = state.shared.messages.findLast(message => message.author === state.shared.me.id);\n const mineKey = mine ? mine.clientId ?? mine.id : undefined;\n const lastMine = useRef(mineKey);\n useLayoutEffect(() => {\n if (mineKey === lastMine.current) return;\n const fresh = lastMine.current !== undefined || mine?.pending;\n lastMine.current = mineKey;\n if (fresh && mine === state.shared.messages.at(-1)) { transcript.reset(); void stick.scrollToBottom('instant'); }\n // eslint-disable-next-line react-hooks/exhaustive-deps -- scrolls only when the reader's own latest message changes, not on every message or transcript object\n }, [mineKey]);\n useEffect(() => bindApi(api, { reset: () => { transcript.reset(); void stick.scrollToBottom('instant'); }, reveal: setRevealing }), [api, transcript, stick]);\n useEffect(() => {\n if (!revealing) return;\n const key = items.find(item => item.message.id === revealing || item.system?.some(line => line.id === revealing))?.key;\n if (key) {\n transcript.revealTo(key);\n const frame = requestAnimationFrame(() => {\n const parent = [...(root.current?.querySelectorAll<HTMLElement>('[data-shared-message]') ?? [])].find(element => element.dataset.sharedMessage === revealing);\n if (parent) { parent.scrollIntoView({ block: 'center', behavior: 'instant' }); setRevealing(undefined); }\n });\n return () => cancelAnimationFrame(frame);\n }\n if (state.history.kind === 'loading') return;\n if (state.history.kind === 'ready' && state.history.hasMore) void controller.loadEarlier().catch(() => setRevealing(undefined));\n // A message that is neither loaded nor further back in history cannot be revealed.\n // eslint-disable-next-line react-hooks/set-state-in-effect -- an unreachable target is dropped once, after commit\n else setRevealing(undefined);\n // eslint-disable-next-line react-hooks/exhaustive-deps -- runs per target, rows and history page; the transcript object and controller are rebuilt or stable and must not re-trigger a reveal\n }, [revealing, items, hidden, state.history]);\n const hasContent = items.length > 0 || Children.count(pendingVoice) > 0;\n // Names and avatars only where they tell people apart: two or more members besides the reader.\n const multi = showsAuthors(state.shared.members, state.shared.me.id, view.presence);\n const focus = useRef(focusComposer);\n useLayoutEffect(() => { focus.current = focusComposer; });\n const reply = useCallback((id: string) => { controller.setReplyTo(id); focus.current(); }, [controller]);\n const open = useRef(openFile);\n useLayoutEffect(() => { open.current = openFile; });\n const hasOpen = Boolean(openFile);\n const stableOpen = useMemo(() => hasOpen ? (path: string) => open.current?.(path) : undefined, [hasOpen]);\n return <MessageScroller>\n <MessageScrollerViewport ref={node => { root.current = node; stick.scrollRef(node); }} data-testid=\"transcript-scroll\" aria-label={labels.messages} className=\"min-h-0 flex-1 scrollbar-thumb-border scrollbar-track-transparent\"\n onScroll={event => { const top = event.currentTarget.scrollTop; if (top < lastTop.current - 4) setScrolledUp(true); lastTop.current = top; }}>\n {items.length > 0 && (hidden > 0 || state.history.kind === 'loading' || state.history.kind === 'error' || state.history.kind === 'ready' && state.history.hasMore) && <TranscriptHistory history={state.history} connected={!state.disposed && connected} onLoad={transcript.reveal} sentinelRef={transcript.sentinelRef} hidden={hidden} loaded={transcript.loaded} />}\n <div data-testid=\"transcript-bottom\" className=\"mx-auto flex min-h-full w-full max-w-[720px] flex-col justify-end px-4 pt-4 pb-3 md:px-6\">\n <MessageScrollerContent aria-label={labels.messages} aria-live=\"polite\" data-testid=\"transcript\" className=\"h-auto min-h-0 gap-0 [&>[data-local-voice]]:mt-3\">\n {chunks.map((run, index) => <TimelineChunk key={run[0]!.key} items={run} top={index === 0} multi={multi} controller={controller} mentions={mentions} openFile={stableOpen} targetLabel={targetLabel} onReply={reply} onReveal={setRevealing} />)}\n {pendingVoice}\n </MessageScrollerContent>\n </div>\n </MessageScrollerViewport>\n {hasContent && !stick.isAtBottom && scrolledUp && <div data-testid=\"transcript-navigation\" className=\"flex shrink-0 items-center justify-center py-1.5\">\n <Button variant=\"secondary\" size=\"sm\" data-testid=\"jump-latest\" data-unseen={unseen || undefined} aria-label={unseen ? labels.sharedNewMessages(unseen) : labels.jumpToLatest} title={labels.jumpToLatest} className=\"max-md:min-h-11 pointer-coarse:min-h-11\" onClick={() => api.current?.reset()}>\n <icons.jumpToLatest data-icon=\"inline-start\" aria-hidden=\"true\" />{unseen ? labels.sharedNewMessages(unseen) : labels.jumpToLatest}</Button>\n </div>}\n </MessageScroller>;\n}\n\n/** Same item, field by field (the quote is rebuilt each pass, so it compares by what it points at). */\nconst sameItem = (a: SharedItem, b: SharedItem) => a.key === b.key && a.message === b.message && a.author === b.author && a.own === b.own && a.first === b.first\n && a.lastItem === b.lastItem && a.separator === b.separator && a.showSpinner === b.showSpinner && a.latestCard === b.latestCard\n && a.quote?.message === b.quote?.message && a.quote?.author === b.quote?.author\n && (a.system === b.system || (a.system?.length === b.system?.length && a.system!.every((line, index) => line === b.system![index])));\n/**\n * Keeps the previous object of every item that did not change, so a page loaded above, a delta or a new message renders only the\n * rows it touches (rows and their runs are memoized by identity).\n */\nfunction useStableItems(next: readonly SharedItem[]): readonly SharedItem[] {\n // A derived identity cache, read and replaced inside the memo only: the same items always give the same objects.\n const previous = useRef(new Map<string, SharedItem>());\n return useMemo(() => {\n // eslint-disable-next-line react-hooks/refs -- reuses the cached object of an unchanged item; no setState, no render loop\n const items = next.map(item => { const before = previous.current.get(item.key); return before && sameItem(before, item) ? before : item; });\n // eslint-disable-next-line react-hooks/refs -- stores the items for the next pass; a cache write, not state, so it triggers no render\n previous.current = new Map(items.map(item => [item.key, item]));\n return items;\n }, [next]);\n}\n\n/** Hands the timeline's controls to the host's ref until this timeline unmounts or rebinds. */\nfunction bindApi(api: RefObject<TimelineApi | undefined>, value: TimelineApi) {\n api.current = value;\n return () => { if (api.current === value) api.current = undefined; };\n}\n\ntype RowProps = {\n readonly multi: boolean; readonly controller: SharedChatController; readonly mentions: MentionsConfig;\n readonly openFile: ((path: string) => void) | undefined; readonly targetLabel: ChatFeatureProps['targetLabel']; readonly onReply: (id: string) => void; readonly onReveal: (id: string) => void;\n};\n/** A run of timeline rows as one scroller item (`useRowChunks`). Memoized, like each row: it renders again only when its own items change. */\nconst TimelineChunk = memo(function TimelineChunk({ items, top, ...props }: RowProps & { readonly items: readonly SharedItem[]; readonly top: boolean }) {\n return <MessageScrollerItem messageId={items[0]!.key} data-transcript-chunk=\"\" className=\"flex flex-col [content-visibility:visible] [gap:inherit]\">\n {items.map((item, index) => <TimelineRow key={item.key} item={item} top={top && index === 0} {...props} />)}\n </MessageScrollerItem>;\n});\nconst TimelineRow = memo(function TimelineRow({ item, top, multi, controller, mentions, openFile, targetLabel, onReply, onReveal }: RowProps & { readonly item: SharedItem; readonly top: boolean }) {\n const { labels } = useChatText();\n const view = useSharedView();\n const context = useMemo(() => ({ pieces: { mentions: true, skills: [], renderMention: mentions.renderSent }, openFile, targetLabel }), [mentions.renderSent, openFile, targetLabel]);\n const renderMention = (path: string, text: string) => mentions.renderSent?.(path) ?? text;\n const bubble = () => {\n const { message, author, own, first } = item;\n const person = own || author.kind !== 'agent' || message.via !== undefined;\n const headKind = runHead({ first, own, authors: multi, via: message.via });\n const head = headKind === 'author' ? <AuthorName member={author} via={message.via} /> : undefined;\n const traced = view.steps !== 'hidden' && !message.via && author.kind === 'agent' && Boolean(message.streaming || message.steps?.length);\n // Who spoke (and via what) sits above the group's first bubble, outside it: the bubble keeps its width. My own messages carry no name;\n // one posted through an outside agent says so on my side.\n const via = headKind === 'via' && message.via ? <ViaLine via={message.via} className=\"ms-auto block w-fit max-w-full pb-0.5 pe-1\" /> : undefined;\n const live = traced && Boolean(message.streaming);\n const trace = traced ? <HostTrace participant={author.name} steps={message.steps ?? []} streaming={Boolean(message.streaming)} active={item.showSpinner} /> : undefined;\n const quote = message.replyTo ? <ReplyQuote targetId={message.replyTo} quote={item.quote} onReveal={onReveal} /> : undefined;\n if (message.question) return <article data-row-id={item.key} data-role=\"event\" className=\"flex w-full min-w-0 flex-col\">\n {head}{via}\n <Bubble variant=\"muted\" align=\"start\" data-testid=\"question-bubble\">\n {first && <BubbleTail side=\"start\" />}\n <BubbleContent className=\"relative min-w-52\">{quote}\n <QuestionCard message={message} answer={controller.answer} onReply={onReply} />\n </BubbleContent>\n </Bubble></article>;\n if (message.card) return <article data-row-id={item.key} data-role=\"event\" className=\"flex w-full min-w-0 flex-col\">\n {head}{via}\n <Bubble variant={own ? 'default' : 'muted'} align={own ? 'end' : 'start'} data-testid=\"card-bubble\">\n {first && <BubbleTail side={own ? 'end' : 'start'} />}\n <BubbleContent className=\"relative min-w-52\">{quote}\n {message.card.kind === 'pin' ? <PinnedComment card={message.card} own={own} at={message.at} /> : <RequestCard card={message.card} action={controller.action} at={message.at} own={own} />}\n </BubbleContent>\n </Bubble></article>;\n return <article data-row-id={item.key} data-role={person ? 'user' : 'assistant'} data-streaming={message.streaming ? 'true' : undefined} data-pending={message.pending ? 'true' : undefined} className=\"group/message\">{head}{via}<Message align={own ? 'end' : 'start'}><MessageContent>\n {own ? <div data-testid=\"own-message\" className=\"contents [&_[data-testid=message-mention]]:text-inherit\">\n <MessageText text={message.text ?? ''} context={context} variant=\"default\" decoration={first ? <BubbleTail side=\"end\" /> : undefined} header={quote} footer={<><ClockSpacer /><MessageClock at={message.at} tone=\"primary\" pending={message.pending} /></>} /></div>\n : view.answerStyle === 'plain' && !person ? <div data-testid=\"plain-answer\" className=\"min-w-0\">\n {quote}\n {message.text && <Markdown text={message.text} streaming={Boolean(message.streaming)} renderMention={renderMention} />}\n {traced && <HostTrace participant={author.name} steps={message.steps ?? []} streaming={Boolean(message.streaming)} active={item.showSpinner} />}\n </div>\n : <Bubble variant=\"muted\">{first && <BubbleTail side=\"start\" />}<BubbleContent className=\"relative\">\n {quote}\n {/* While the agent writes, the working line sits above the growing text and the time waits for the answer: nothing under the text moves as it grows (B5). */}\n {live && trace}\n {message.text && <div className={traced ? undefined : clockRoom}><Markdown text={message.text} streaming={Boolean(message.streaming)} renderMention={renderMention} /></div>}\n {traced ? <>\n {!live && trace}\n {!live && <div className=\"flex justify-end\"><MessageClock at={message.at} tone=\"muted\" floating={false} /></div>}\n </> : <MessageClock at={message.at} tone=\"muted\" />}\n </BubbleContent></Bubble>}\n </MessageContent></Message></article>;\n };\n const gap = top || item.separator ? '' : item.system || item.first ? ' mt-3' : ' mt-0.5';\n return <div data-row-key={item.key} data-group-start={item.first ? 'true' : undefined} className={`min-w-0 shrink-0${gap}`}>\n {item.separator && <div data-testid=\"day-separator\" className={`flex justify-center pb-3${top ? '' : ' pt-4'}`}><Badge variant=\"secondary\"><time dateTime={item.message.at} title={labels.sharedTimestamp(item.message.at)}>{labels.sharedDay(item.message.at)}</time></Badge></div>}\n {item.system ? <SystemLines lines={item.system} />\n : <SharedMessage shared={item} remove={controller.remove} canRemove={controller.canRemove} openFile={openFile} onReply={onReply} onReveal={onReveal}>{bubble()}</SharedMessage>}\n </div>;\n});\n",
39
+ "content": "'use client';\nimport { Children, memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';\nimport type { ReactNode, RefObject } from 'react';\nimport type { SharedChatController } from '@hachej/boring-ui-kit/shared-chat';\nimport type { ChatFeatureProps } from '../pi-chat/session';\nimport type { MentionsConfig } from '../pi-chat/config';\nimport { MessageText } from '../pi-chat/message';\nimport { Markdown } from '../pi-chat-markdown/markdown';\nimport { PAGE_FIRST, PAGE_MORE, useRowChunks, useTranscript } from '../pi-chat/session';\nimport type { ChatPaging } from '../pi-chat/session';\nimport { TranscriptHistory } from '../pi-chat/history';\nimport { Message, MessageContent } from '@/components/ui/message';\nimport { Bubble, BubbleContent } from '@/components/ui/bubble';\nimport { Badge } from '@/components/ui/badge';\nimport { Button } from '@/components/ui/button';\nimport { MessageScroller, MessageScrollerViewport, MessageScrollerContent, MessageScrollerItem } from '@/components/ui/message-scroller';\nimport { showsAuthors, runHead } from './names';\nimport { AuthorName, BubbleTail, ClockSpacer, MessageClock, PinnedComment, QuestionCard, ReplyQuote, RequestCard, SharedMessage, SystemLines, ViaLine, clockRoom, sharedItems } from './messages';\nimport type { SharedItem } from './messages';\nimport { HostTrace } from './host-trace';\nimport { useChatText, useSharedView } from './text';\n\nexport interface TimelineApi { readonly reset: () => void; readonly reveal: (id: string) => void }\n/**\n * WhatsApp's room: bubbles hug the bottom, a day pill starts each day, host notices collapse into one small line, and a\n * « ↓ 3 new messages » pill appears below the list (never over it) while the reader is scrolled up.\n */\nexport function SharedTimeline({ controller, api, focusComposer, openFile, mentions, pendingVoice, paging, targetLabel }: {\n readonly controller: SharedChatController; readonly api: RefObject<TimelineApi | undefined>;\n readonly focusComposer: () => void; readonly openFile?: ((path: string) => void) | undefined; readonly mentions: MentionsConfig; readonly pendingVoice?: ReactNode; readonly paging?: ChatPaging | undefined;\n readonly targetLabel?: ChatFeatureProps['targetLabel'];\n}) {\n const state = useSyncExternalStore(controller.subscribe, controller.getSnapshot, controller.getSnapshot);\n const { labels, icons } = useChatText();\n const view = useSharedView();\n const items = useStableItems(useMemo(() => sharedItems(state.shared, labels.sharedName), [state.shared, labels.sharedName]));\n const connected = state.connection.kind === 'connected';\n const transcript = useTranscript(items, paging?.first ?? PAGE_FIRST, paging?.more ?? PAGE_MORE, { state: state.history, connected: !state.disposed && connected, load: controller.loadEarlier });\n const { stick, hidden } = transcript;\n const chunks = useRowChunks(useMemo(() => hidden ? items.slice(hidden) : items, [items, hidden]));\n const root = useRef<HTMLDivElement>(null);\n const [revealing, setRevealing] = useState<string>();\n // The last message the reader has seen at the bottom; what others posted after it while scrolled up is « new ».\n const latest = state.shared.messages.at(-1)?.id;\n const [seen, setSeen] = useState(latest);\n // eslint-disable-next-line react-hooks/set-state-in-effect -- marks the latest message seen once the reader is at the bottom, after commit; settles when equal\n useEffect(() => { if (stick.isAtBottom && seen !== latest) setSeen(latest); }, [stick.isAtBottom, latest, seen]);\n // The pill's row shows only once the reader scrolls up, so loading a room (which scrolls down) never shifts the list under a tap.\n const lastTop = useRef(0);\n const [scrolledUp, setScrolledUp] = useState(false);\n // eslint-disable-next-line react-hooks/set-state-in-effect -- reaching the bottom hides the pill's row once, after commit\n useEffect(() => { if (stick.isAtBottom) setScrolledUp(false); }, [stick.isAtBottom]);\n const seenAt = state.shared.messages.findIndex(message => message.id === seen);\n const unseen = stick.isAtBottom ? 0 : state.shared.messages.slice(seenAt + 1).filter(message => message.author !== state.shared.me.id).length;\n // The reader's own new message lands in view, wherever they were: on Send it is drawn at once (pending), and Send's own scroll\n // runs before that render. Its confirmation (same clientId) is the same message and does not scroll again.\n const mine = state.shared.messages.findLast(message => message.author === state.shared.me.id);\n const mineKey = mine ? mine.clientId ?? mine.id : undefined;\n const lastMine = useRef(mineKey);\n useLayoutEffect(() => {\n if (mineKey === lastMine.current) return;\n const fresh = lastMine.current !== undefined || mine?.pending;\n lastMine.current = mineKey;\n if (fresh && mine === state.shared.messages.at(-1)) { transcript.reset(); void stick.scrollToBottom('instant'); }\n // eslint-disable-next-line react-hooks/exhaustive-deps -- scrolls only when the reader's own latest message changes, not on every message or transcript object\n }, [mineKey]);\n useEffect(() => bindApi(api, { reset: () => { transcript.reset(); void stick.scrollToBottom('instant'); }, reveal: setRevealing }), [api, transcript, stick]);\n useEffect(() => {\n if (!revealing) return;\n const key = items.find(item => item.message.id === revealing || item.system?.some(line => line.id === revealing))?.key;\n if (key) {\n transcript.revealTo(key);\n const frame = requestAnimationFrame(() => {\n const parent = [...(root.current?.querySelectorAll<HTMLElement>('[data-shared-message]') ?? [])].find(element => element.dataset.sharedMessage === revealing);\n if (parent) { parent.scrollIntoView({ block: 'center', behavior: 'instant' }); setRevealing(undefined); }\n });\n return () => cancelAnimationFrame(frame);\n }\n if (state.history.kind === 'loading') return;\n if (state.history.kind === 'ready' && state.history.hasMore) void controller.loadEarlier().catch(() => setRevealing(undefined));\n // A message that is neither loaded nor further back in history cannot be revealed.\n // eslint-disable-next-line react-hooks/set-state-in-effect -- an unreachable target is dropped once, after commit\n else setRevealing(undefined);\n // eslint-disable-next-line react-hooks/exhaustive-deps -- runs per target, rows and history page; the transcript object and controller are rebuilt or stable and must not re-trigger a reveal\n }, [revealing, items, hidden, state.history]);\n const hasContent = items.length > 0 || Children.count(pendingVoice) > 0;\n // Names and avatars only where they tell people apart: two or more members besides the reader.\n const multi = showsAuthors(state.shared.members, state.shared.me.id, view.presence);\n const focus = useRef(focusComposer);\n useLayoutEffect(() => { focus.current = focusComposer; });\n const reply = useCallback((id: string) => { controller.setReplyTo(id); focus.current(); }, [controller]);\n const open = useRef(openFile);\n useLayoutEffect(() => { open.current = openFile; });\n const hasOpen = Boolean(openFile);\n const stableOpen = useMemo(() => hasOpen ? (path: string) => open.current?.(path) : undefined, [hasOpen]);\n return <MessageScroller>\n <MessageScrollerViewport ref={node => { root.current = node; stick.scrollRef(node); }} data-testid=\"transcript-scroll\" aria-label={labels.messages} className=\"min-h-0 flex-1 scrollbar-thumb-border scrollbar-track-transparent\"\n onScroll={event => { const top = event.currentTarget.scrollTop; if (top < lastTop.current - 4) setScrolledUp(true); lastTop.current = top; }}>\n {items.length > 0 && (hidden > 0 || state.history.kind === 'loading' || state.history.kind === 'error' || state.history.kind === 'ready' && state.history.hasMore) && <TranscriptHistory history={state.history} connected={!state.disposed && connected} onLoad={transcript.reveal} sentinelRef={transcript.sentinelRef} hidden={hidden} loaded={transcript.loaded} />}\n <div data-testid=\"transcript-bottom\" className=\"mx-auto flex min-h-full w-full max-w-[720px] flex-col justify-end px-4 pt-4 pb-3 md:px-6\">\n <MessageScrollerContent aria-label={labels.messages} aria-live=\"polite\" data-testid=\"transcript\" className=\"h-auto min-h-0 gap-0 [&>[data-local-voice]]:mt-3\">\n {chunks.map((run, index) => <TimelineChunk key={run[0]!.key} items={run} top={index === 0} multi={multi} controller={controller} mentions={mentions} openFile={stableOpen} targetLabel={targetLabel} onReply={reply} onReveal={setRevealing} />)}\n {pendingVoice}\n </MessageScrollerContent>\n </div>\n </MessageScrollerViewport>\n {hasContent && !stick.isAtBottom && scrolledUp && <div data-testid=\"transcript-navigation\" className=\"flex shrink-0 items-center justify-center py-1.5\">\n <Button variant=\"secondary\" size=\"sm\" data-testid=\"jump-latest\" data-unseen={unseen || undefined} aria-label={unseen ? labels.sharedNewMessages(unseen) : labels.jumpToLatest} title={labels.jumpToLatest} className=\"max-md:min-h-11 pointer-coarse:min-h-11\" onClick={() => api.current?.reset()}>\n <icons.jumpToLatest data-icon=\"inline-start\" aria-hidden=\"true\" />{unseen ? labels.sharedNewMessages(unseen) : labels.jumpToLatest}</Button>\n </div>}\n </MessageScroller>;\n}\n\n/** Same item, field by field (the quote is rebuilt each pass, so it compares by what it points at). */\nconst sameItem = (a: SharedItem, b: SharedItem) => a.key === b.key && a.message === b.message && a.author === b.author && a.own === b.own && a.first === b.first\n && a.lastItem === b.lastItem && a.separator === b.separator && a.showSpinner === b.showSpinner && a.latestCard === b.latestCard\n && a.quote?.message === b.quote?.message && a.quote?.author === b.quote?.author\n && (a.system === b.system || (a.system?.length === b.system?.length && a.system!.every((line, index) => line === b.system![index])));\n/**\n * Keeps the previous object of every item that did not change, so a page loaded above, a delta or a new message renders only the\n * rows it touches (rows and their runs are memoized by identity).\n */\nfunction useStableItems(next: readonly SharedItem[]): readonly SharedItem[] {\n // A derived identity cache, read and replaced inside the memo only: the same items always give the same objects.\n const previous = useRef(new Map<string, SharedItem>());\n return useMemo(() => {\n // eslint-disable-next-line react-hooks/refs -- reuses the cached object of an unchanged item; no setState, no render loop\n const items = next.map(item => { const before = previous.current.get(item.key); return before && sameItem(before, item) ? before : item; });\n // eslint-disable-next-line react-hooks/refs -- stores the items for the next pass; a cache write, not state, so it triggers no render\n previous.current = new Map(items.map(item => [item.key, item]));\n return items;\n }, [next]);\n}\n\n/** Hands the timeline's controls to the host's ref until this timeline unmounts or rebinds. */\nfunction bindApi(api: RefObject<TimelineApi | undefined>, value: TimelineApi) {\n api.current = value;\n return () => { if (api.current === value) api.current = undefined; };\n}\n\ntype RowProps = {\n readonly multi: boolean; readonly controller: SharedChatController; readonly mentions: MentionsConfig;\n readonly openFile: ((path: string) => void) | undefined; readonly targetLabel: ChatFeatureProps['targetLabel']; readonly onReply: (id: string) => void; readonly onReveal: (id: string) => void;\n};\n/** A run of timeline rows as one scroller item (`useRowChunks`). Memoized, like each row: it renders again only when its own items change. */\nconst TimelineChunk = memo(function TimelineChunk({ items, top, ...props }: RowProps & { readonly items: readonly SharedItem[]; readonly top: boolean }) {\n return <MessageScrollerItem messageId={items[0]!.key} data-transcript-chunk=\"\" className=\"flex flex-col [content-visibility:visible] [gap:inherit]\">\n {items.map((item, index) => <TimelineRow key={item.key} item={item} top={top && index === 0} {...props} />)}\n </MessageScrollerItem>;\n});\nconst TimelineRow = memo(function TimelineRow({ item, top, multi, controller, mentions, openFile, targetLabel, onReply, onReveal }: RowProps & { readonly item: SharedItem; readonly top: boolean }) {\n const { labels } = useChatText();\n const view = useSharedView();\n const context = useMemo(() => ({ pieces: { mentions: true, skills: [], renderMention: mentions.renderSent }, openFile, targetLabel }), [mentions.renderSent, openFile, targetLabel]);\n const renderMention = (path: string, text: string) => mentions.renderSent?.(path) ?? text;\n const bubble = () => {\n const { message, author, own, first } = item;\n const person = own || author.kind !== 'agent' || message.via !== undefined;\n const headKind = runHead({ first, own, authors: multi, via: message.via });\n const head = headKind === 'author' ? <AuthorName member={author} via={message.via} /> : undefined;\n const traced = view.steps !== 'hidden' && !message.via && author.kind === 'agent' && Boolean(message.streaming || message.steps?.length);\n // Who spoke (and via what) sits above the group's first bubble, outside it: the bubble keeps its width. My own messages carry no name;\n // one posted through an outside agent says so on my side.\n const via = headKind === 'via' && message.via ? <ViaLine via={message.via} className=\"ms-auto block w-fit max-w-full pb-0.5 pe-1\" /> : undefined;\n const live = traced && Boolean(message.streaming);\n const trace = traced ? <HostTrace participant={author.name} steps={message.steps ?? []} streaming={Boolean(message.streaming)} active={item.showSpinner} /> : undefined;\n const quote = message.replyTo && !item.quote?.message.question?.answered ? <ReplyQuote targetId={message.replyTo} quote={item.quote} onReveal={onReveal} /> : undefined;\n // The person's reply already records their choice. Keep any attached mockups, not a second system-like answered card.\n if (message.question?.answered) return null;\n if (message.question) return <article data-row-id={item.key} data-role=\"event\" className=\"flex w-full min-w-0 flex-col\">\n {head}{via}\n <Bubble variant=\"muted\" align=\"start\" data-testid=\"question-bubble\">\n {first && <BubbleTail side=\"start\" />}\n <BubbleContent className=\"relative min-w-52\">{quote}\n <QuestionCard message={message} answer={controller.answer} onReply={onReply} />\n </BubbleContent>\n </Bubble></article>;\n if (message.card) return <article data-row-id={item.key} data-role=\"event\" className=\"flex w-full min-w-0 flex-col\">\n {head}{via}\n <Bubble variant={own ? 'default' : 'muted'} align={own ? 'end' : 'start'} data-testid=\"card-bubble\">\n {first && <BubbleTail side={own ? 'end' : 'start'} />}\n <BubbleContent className=\"relative min-w-52\">{quote}\n {message.card.kind === 'pin' ? <PinnedComment card={message.card} own={own} at={message.at} /> : <RequestCard card={message.card} action={controller.action} at={message.at} own={own} />}\n </BubbleContent>\n </Bubble></article>;\n return <article data-row-id={item.key} data-role={person ? 'user' : 'assistant'} data-streaming={message.streaming ? 'true' : undefined} data-pending={message.pending ? 'true' : undefined} className=\"group/message\">{head}{via}<Message align={own ? 'end' : 'start'}><MessageContent>\n {own ? <div data-testid=\"own-message\" className=\"contents [&_[data-testid=message-mention]]:text-inherit\">\n <MessageText text={message.text ?? ''} context={context} variant=\"default\" decoration={first ? <BubbleTail side=\"end\" /> : undefined} header={quote} footer={<><ClockSpacer /><MessageClock at={message.at} tone=\"primary\" pending={message.pending} /></>} /></div>\n : view.answerStyle === 'plain' && !person ? <div data-testid=\"plain-answer\" className=\"min-w-0\">\n {quote}\n {message.text && <Markdown text={message.text} streaming={Boolean(message.streaming)} renderMention={renderMention} />}\n {traced && <HostTrace participant={author.name} steps={message.steps ?? []} streaming={Boolean(message.streaming)} active={item.showSpinner} />}\n </div>\n : <Bubble variant=\"muted\">{first && <BubbleTail side=\"start\" />}<BubbleContent className=\"relative\">\n {quote}\n {/* While the agent writes, the working line sits above the growing text and the time waits for the answer: nothing under the text moves as it grows (B5). */}\n {live && trace}\n {message.text && <div className={traced ? undefined : clockRoom}><Markdown text={message.text} streaming={Boolean(message.streaming)} renderMention={renderMention} /></div>}\n {traced ? <>\n {!live && trace}\n {!live && <div className=\"flex justify-end\"><MessageClock at={message.at} tone=\"muted\" floating={false} /></div>}\n </> : <MessageClock at={message.at} tone=\"muted\" />}\n </BubbleContent></Bubble>}\n </MessageContent></Message></article>;\n };\n const gap = top || item.separator ? '' : item.system || item.first ? ' mt-3' : ' mt-0.5';\n return <div data-row-key={item.key} data-group-start={item.first ? 'true' : undefined} className={`min-w-0 shrink-0${gap}`}>\n {item.separator && <div data-testid=\"day-separator\" className={`flex justify-center pb-3${top ? '' : ' pt-4'}`}><Badge variant=\"secondary\"><time dateTime={item.message.at} title={labels.sharedTimestamp(item.message.at)}>{labels.sharedDay(item.message.at)}</time></Badge></div>}\n {item.system ? <SystemLines lines={item.system} />\n : <SharedMessage shared={item} remove={controller.remove} canRemove={controller.canRemove} openFile={openFile} onReply={onReply} onReveal={onReveal}>{bubble()}</SharedMessage>}\n </div>;\n});\n",
40
40
  "type": "registry:component",
41
41
  "target": "components/shared-chat/timeline.tsx"
42
42
  },
package/r/task-board.json CHANGED
@@ -4,23 +4,29 @@
4
4
  "title": "Task board",
5
5
  "description": "Host-owned business requests: board, stacked task/file overlays and compact own-request rows. No agent or task backend required.",
6
6
  "dependencies": [
7
- "@hachej/boring-ui-kit@^0.4.38",
7
+ "@hachej/boring-ui-kit@^0.4.40",
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.38/r/utils.json"
12
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.40/r/utils.json"
13
13
  ],
14
14
  "files": [
15
+ {
16
+ "path": "registry/task-board/compact-presentation.ts",
17
+ "content": "import type { TaskRecord } from '@hachej/boring-ui-kit/task-board';\n\n/** Exact adapter tokens only; terminal records must never revive stale viewer flags. Presentation is not authorization. */\nexport function compactTaskStatus(task: TaskRecord) {\n if (task.status === 'withdrawn' || task.status === 'abandoned' || task.status === 'closed' || (task.state === 'closed' && task.status !== 'shipped')) return 'closed';\n if (task.state === 'done' || task.status === 'shipped') return 'live';\n if (task.state === 'waiting' && task.waiting_on_you === true) return 'needs-you';\n if (task.status === 'blocked') return 'blocked';\n if (task.status === 'verifying') return 'verifying';\n return 'preparation';\n}\n\n/** A display-only cap: opening the row still passes the untouched record to the host. */\nexport function compactTaskTitle(task: TaskRecord): string {\n const words = (task.display?.trim() || task.title.trim()).split(/\\s+/u);\n return words.length > 6 ? `${words.slice(0, 6).join(' ')}…` : words.join(' ');\n}\n\nexport const compactLabels = {\n en: { 'needs-you': 'Needs you', confirmation: 'To confirm', blocked: 'Blocked', verifying: 'Verification', live: 'Live', closed: 'Closed', withdrawn: 'Withdrawn', preparation: 'Preparing', finished: 'Finished', technical: 'Technical', empty: 'No requests', emptyDescription: 'No tasks match this view.' },\n fr: { 'needs-you': 'À vous', confirmation: 'À confirmer', blocked: 'Bloquée', verifying: 'Vérification', live: 'En ligne', closed: 'Fermée', withdrawn: 'Retirée', preparation: 'En préparation', finished: 'Terminées', technical: 'Technique', empty: 'Aucune demande', emptyDescription: 'Aucune demande dans cette vue.' },\n} as const;\n",
18
+ "type": "registry:lib",
19
+ "target": "components/task-board/compact-presentation.ts"
20
+ },
15
21
  {
16
22
  "path": "registry/task-board/task-board.tsx",
17
- "content": "'use client';\n\nimport { useEffect, useRef, useState, useSyncExternalStore } from 'react';\nimport type { MouseEvent, ReactNode } from 'react';\nimport { selectTasks, taskFromUrl, taskReference, taskUrl, TASK_STATES } from '@hachej/boring-ui-kit/task-board';\nimport type { TaskFile, TaskFilter, TaskRecord, TaskState } from '@hachej/boring-ui-kit/task-board';\nimport { ChevronLeftIcon } from 'lucide-react';\nimport { Badge } from '@/components/ui/badge';\nimport { Button } from '@/components/ui/button';\nimport { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from '@/components/ui/empty';\nimport { Separator } from '@/components/ui/separator';\nimport { cn } from '../utils/utils';\nimport { TaskRequestCard, TaskStatusPill, taskMeta, taskStateLabels, taskTokens } from './task-request-card';\nimport type { TaskLocale, TaskViewerAction } from './task-request-card';\nimport { TaskDetail } from './task-detail';\nimport type { TaskDetailProps } from './task-detail';\n\nexport { TaskRequestCard, TaskRequestCard as TaskCard, TaskStatusPill, TaskStatusChip, TaskAttention, taskMeta, taskTokens } from './task-request-card';\nexport { TaskDetail } from './task-detail';\nexport type { TaskRequestCardProps, TaskRequestCardProps as TaskCardProps, TaskLocale, TaskViewerAction } from './task-request-card';\nexport type { TaskDetailProps } from './task-detail';\nexport type { TaskFile, TaskRecord, TaskFilter, TaskState } from '@hachej/boring-ui-kit/task-board';\n\nconst navigation = 'boring:task-navigation';\nconst subscribe = (changed: () => void) => {\n window.addEventListener('popstate', changed); window.addEventListener(navigation, changed);\n return () => { window.removeEventListener('popstate', changed); window.removeEventListener(navigation, changed); };\n};\nconst readSelection = () => taskFromUrl(window.location.href);\nconst serverSelection = () => null;\nconst BOARD_STATES = TASK_STATES.slice(0, 3);\ntype TaskList = 'open' | 'done' | 'closed';\n\nexport interface TaskFileViewApi {\n readonly fullscreen: boolean;\n readonly onFullscreenChange: (fullscreen: boolean) => void;\n readonly onClose: () => void;\n readonly onBack: () => void;\n /** The host's PM pointing service; absent unless onShowFile was supplied. */\n readonly onShowFile?: (() => void) | undefined;\n}\nexport interface TaskBoardProps {\n readonly tasks: readonly TaskRecord[];\n /** Controlled Mine/All. Uncontrolled, the tasks bar starts on Mine when `currentUser` is known, otherwise All. */\n readonly filter?: TaskFilter;\n readonly onFilterChange?: ((filter: TaskFilter) => void) | undefined;\n readonly currentUser?: string;\n readonly limit?: number;\n readonly variant?: 'board' | 'compact';\n readonly title?: string;\n /** Column names, shown uppercase. */\n readonly labels?: Readonly<Record<TaskState, string>>;\n /** The tasks bar (« 5 open · Done 12 · Closed 1 », Mine/All). Hosts that draw it in their own header pass false. */\n readonly bar?: boolean;\n /** Host content at the start of the tasks bar, e.g. the collapsed Studio header's app name and « Show the app ». */\n readonly barStart?: ReactNode;\n readonly locale?: TaskLocale;\n /** Explicit host context for compact French labels (« À vous » or « À confirmer »). */\n readonly viewerAction?: TaskViewerAction;\n /** Omit to use ?task= with native history. A router may instead supply controlled selection. */\n readonly selectedTask?: string | null;\n readonly onSelectedTaskChange?: ((id: string | null) => void) | undefined;\n /** Optional host navigation/present path, particularly for compact lists inside the PM panel. */\n readonly onOpen?: ((task: TaskRecord) => void) | undefined;\n /** Host content under the detail's title card. */\n readonly actions?: ((task: TaskRecord) => ReactNode) | undefined;\n /** Host-authorized compact card actions (« Modifier », « Confirmer »). */\n readonly cardActions?: ((task: TaskRecord) => ReactNode) | undefined;\n readonly onAnswer?: TaskDetailProps['onAnswer'];\n readonly onOther?: TaskDetailProps['onOther'];\n readonly onDiscussQuestion?: TaskDetailProps['onDiscussQuestion'];\n readonly onAsk?: TaskDetailProps['onAsk'];\n /** Adapter paths only. The host reauthorizes them when opening the regular viewer. */\n readonly onOpenFile?: TaskDetailProps['onOpenFile'];\n readonly onShowFile?: TaskDetailProps['onOpenFile'];\n /** Supply the host's regular FileViewer; it opens 760px wide over the task detail. No second store or composer. */\n readonly renderFile?: ((task: TaskRecord, file: TaskFile, api: TaskFileViewApi) => ReactNode) | undefined;\n /** Host-owned overlay appearance (for example the host's overlay shadow token). */\n readonly overlayClassName?: string;\n readonly className?: string;\n}\n\n/**\n * The tasks panel (spec 3 §3–§5): tasks bar, WAITING · WORKING · OPEN, and panel-scoped overlays. The task detail opens 400px wide\n * from the right over the board, which keeps its columns; a file opens 760px wide over the detail. `variant=\"compact\"` stacks C7 cards.\n */\nexport function TaskBoard({ tasks, filter, onFilterChange, currentUser, limit, variant = 'board', title = 'Tasks', labels = taskStateLabels, bar = true, barStart, locale = 'en', viewerAction,\n selectedTask, onSelectedTaskChange, onOpen, actions, cardActions, onAnswer, onOther, onDiscussQuestion, onAsk, onOpenFile, onShowFile, renderFile, overlayClassName, className }: TaskBoardProps) {\n const urlSelection = useSyncExternalStore(subscribe, readSelection, serverSelection);\n const [ownFilter, setOwnFilter] = useState<TaskFilter>(currentUser === undefined ? 'all' : 'mine');\n const [list, setList] = useState<TaskList>('open');\n const shownFilter = filter ?? ownFilter;\n const selected = selectedTask === undefined ? onOpen ? null : urlSelection : selectedTask;\n const detail = selected === null ? undefined : tasks.find(task => task.id === selected || taskReference(task) === selected);\n const shown = selectTasks(tasks, { filter: shownFilter, ...(currentUser === undefined ? {} : { currentUser }), ...(limit === undefined ? {} : { limit }) });\n const [fileSelection, setFileSelection] = useState<{ readonly taskId: string; readonly path: string } | null>(null);\n const filePath = fileSelection?.path;\n const file = detail?.id === fileSelection?.taskId ? detail?.files?.find(candidate => candidate.path === filePath) : undefined;\n const fileOpen = !!file && !!renderFile;\n const [fileFullscreen, setFileFullscreen] = useState(false);\n const closeFile = () => { setFileSelection(null); setFileFullscreen(false); };\n const openFile = (task: TaskRecord, file: TaskFile) => { setFileSelection({ taskId: task.id, path: file.path }); onOpenFile?.(task, file); };\n const root = useRef<HTMLElement>(null);\n const back = useRef<HTMLButtonElement>(null);\n const previous = useRef<{ readonly id: string; readonly state: TaskState | undefined } | null>(null);\n const pendingFocus = useRef<string | null>(null);\n const cardLink = (id: string) => [...root.current?.querySelectorAll<HTMLAnchorElement>('[data-task-open]') ?? []].find(candidate => candidate.dataset['taskOpen'] === id);\n useEffect(() => {\n if (selected !== null) back.current?.focus();\n else if (previous.current !== null) {\n const { id, state } = previous.current, link = cardLink(id);\n if (link) link.focus();\n else if (state === 'done' || state === 'closed') { pendingFocus.current = id; setList(state); }\n }\n previous.current = selected === null ? null : { id: detail?.id ?? selected, state: detail?.state };\n }, [selected, detail?.id, detail?.state]);\n useEffect(() => { if (pendingFocus.current !== null) { cardLink(pendingFocus.current)?.focus(); pendingFocus.current = null; } }, [list]);\n const select = (id: string | null) => {\n closeFile();\n if (selectedTask === undefined) {\n window.history.pushState(null, '', taskUrl(window.location.href, id));\n window.dispatchEvent(new Event(navigation));\n }\n onSelectedTaskChange?.(id);\n };\n const open = (task: TaskRecord) => { if (onOpen) onOpen(task); else select(task.id); };\n const href = (task: TaskRecord) => typeof window === 'undefined' ? `?task=${encodeURIComponent(task.id)}` : taskUrl(window.location.href, task.id);\n const card = (task: TaskRecord) => <TaskRequestCard key={task.id} task={task} variant={variant === 'compact' ? 'compact' : 'card'} selected={task.id === detail?.id} href={href(task)} onOpen={open}\n locale={locale} viewerAction={viewerAction} actions={variant === 'compact' ? cardActions?.(task) : undefined} />;\n const of = (state: TaskState) => shown.filter(task => task.state === state);\n const empty = <Empty><EmptyHeader><EmptyTitle>No requests</EmptyTitle><EmptyDescription>No tasks match this view.</EmptyDescription></EmptyHeader></Empty>;\n const switcher = (target: TaskList, text: string) => list === target ? <span data-task-list-link={target} aria-current=\"page\">{text}</span>\n : <a href=\"#\" data-task-list-link={target} className=\"text-foreground underline underline-offset-2\" onClick={(event: MouseEvent) => { event.preventDefault(); setList(target); }}>{text}</a>;\n const chooseFilter = (next: TaskFilter) => (event: MouseEvent) => { event.preventDefault(); if (filter === undefined) setOwnFilter(next); onFilterChange?.(next); };\n if (variant === 'compact') return <section ref={root} data-boring=\"task-board\" data-variant=\"compact\" aria-label={title} className={cn('min-w-0', className)}>\n <div data-task-list role=\"list\" aria-label={title} className=\"flex flex-col gap-2\">{shown.length ? shown.map(card) : empty}</div>\n </section>;\n return <section ref={root} data-boring=\"task-board\" data-variant={variant} aria-label={title} className={cn('flex min-h-0 min-w-0 flex-col', className)} onKeyDown={event => {\n if (event.key === 'Escape' && selected !== null && !event.defaultPrevented) { event.preventDefault(); if (fileOpen) closeFile(); else select(null); }\n }}>\n {bar && <nav data-task-bar aria-label=\"Task lists\" className=\"flex h-13 shrink-0 items-center gap-2.5 px-3.5 text-[13px] text-muted-foreground\">\n {barStart}\n {switcher('open', `${BOARD_STATES.reduce((sum, state) => sum + of(state).length, 0)} open`)}<span aria-hidden=\"true\">·</span>\n {switcher('done', `Done ${of('done').length}`)}<span aria-hidden=\"true\">·</span>\n {switcher('closed', `Closed ${of('closed').length}`)}\n {currentUser !== undefined && <span className={cn('ml-auto flex items-center gap-2.5', taskTokens.pill)} data-task-filter={shownFilter}>\n {(['mine', 'all'] as const).map(value => <Badge key={value} variant={shownFilter === value ? 'default' : 'secondary'} render={<a href=\"#\" aria-current={shownFilter === value ? 'true' : undefined} onClick={chooseFilter(value)} />}>{value === 'mine' ? 'Mine' : 'All'}</Badge>)}\n </span>}\n </nav>}\n <div data-task-stage className=\"relative min-h-0 flex-1 overflow-hidden\">\n <div data-task-list className=\"@container/task-rails h-full min-w-0 overflow-auto\">\n {list === 'open' ? <div className=\"grid min-w-0 content-start gap-2.5 p-3.5 @2xl/task-rails:grid-cols-3\">\n {BOARD_STATES.map(state => {\n const column = of(state).sort((a, b) => Number(b.waiting_on_you === true) - Number(a.waiting_on_you === true));\n const onYou = column.filter(task => task.waiting_on_you === true).length;\n return <section key={state} data-task-column={state} aria-label={labels[state]} className={cn('flex min-w-0 flex-col gap-[7px] self-start bg-muted p-2', taskTokens.column)}>\n <h3 className=\"flex min-h-6 items-center justify-between gap-2 px-0.5 pb-0.5 text-[11px] font-semibold tracking-[0.06em] text-muted-foreground\"><span className=\"uppercase\">{labels[state]} {column.length}</span>{onYou > 0 && <span className={cn('contents', taskTokens.pill)}><Badge data-task-on-you=\"\">{onYou} on you</Badge></span>}</h3>\n <div role=\"list\" className=\"flex flex-col gap-[7px]\">{column.map(card)}</div>\n </section>;\n })}\n </div> : <section data-task-column={list} aria-label={labels[list]} className=\"flex max-w-xl min-w-0 flex-col gap-[7px] p-3.5\">\n <div role=\"list\" className=\"flex flex-col gap-[7px]\">{of(list).map(card)}</div>\n {!of(list).length && empty}\n </section>}\n </div>\n {selected !== null && <aside aria-label=\"Task detail\" data-task-side className={cn('absolute inset-y-0 right-0 flex w-[400px] max-w-full flex-col bg-popover text-popover-foreground', overlayClassName)} aria-live=\"polite\">\n <Separator orientation=\"vertical\" className=\"absolute inset-y-0 left-0\" />\n <div className=\"flex h-10 shrink-0 items-center gap-2 pr-1.5 pl-3.5\">\n <Button ref={back} variant=\"ghost\" size=\"icon\" data-task-back=\"\" aria-label=\"Back to the tasks\" title=\"Back to the tasks\" onClick={() => select(null)}><ChevronLeftIcon aria-hidden=\"true\" /></Button>\n {detail && <><span className=\"text-[11px] text-muted-foreground\">{taskMeta(detail)}</span><TaskStatusPill task={detail} locale={locale} viewerAction={viewerAction} /></>}\n </div>\n <Separator />\n <div className=\"min-h-0 flex-1 overflow-auto p-3.5\">\n {detail ? <TaskDetail key={detail.id} task={detail} actions={actions?.(detail)} onAnswer={onAnswer} onOther={onOther} onDiscussQuestion={onDiscussQuestion} onAsk={onAsk} onOpenFile={renderFile || onOpenFile ? openFile : undefined} />\n : <Empty><EmptyHeader><EmptyTitle>Task unavailable</EmptyTitle><EmptyDescription>The host has not supplied this task.</EmptyDescription></EmptyHeader></Empty>}\n </div>\n </aside>}\n {fileOpen && detail && file && <section aria-label=\"Task file viewer\" data-task-file-pane={detail.id} className={cn('flex min-w-0 flex-col bg-background', fileFullscreen ? 'fixed inset-0 z-50' : 'absolute inset-y-0 right-0 w-[760px] max-w-full', overlayClassName)}>\n {renderFile!(detail, file, { fullscreen: fileFullscreen, onFullscreenChange: setFileFullscreen, onClose: closeFile, onBack: closeFile, onShowFile: onShowFile ? () => onShowFile(detail, file) : undefined })}\n </section>}\n </div>\n </section>;\n}\n",
23
+ "content": "'use client';\n\nimport { useEffect, useRef, useState, useSyncExternalStore } from 'react';\nimport type { MouseEvent, ReactNode } from 'react';\nimport { selectTasks, taskFromUrl, taskReference, taskUrl, TASK_STATES } from '@hachej/boring-ui-kit/task-board';\nimport type { TaskFile, TaskFilter, TaskRecord, TaskState } from '@hachej/boring-ui-kit/task-board';\nimport { ChevronLeftIcon } from 'lucide-react';\nimport { Badge } from '@/components/ui/badge';\nimport { Button } from '@/components/ui/button';\nimport { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';\nimport { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from '@/components/ui/empty';\nimport { Separator } from '@/components/ui/separator';\nimport { cn } from '../utils/utils';\nimport { TaskRequestCard, TaskStatusPill, taskMeta, taskStateLabels, taskTokens } from './task-request-card';\nimport type { TaskLocale, TaskViewerAction } from './task-request-card';\nimport { TaskDetail } from './task-detail';\nimport type { TaskDetailProps } from './task-detail';\nimport { compactLabels, compactTaskStatus } from './compact-presentation';\n\nexport { TaskRequestCard, TaskRequestCard as TaskCard, TaskStatusPill, TaskStatusChip, TaskAttention, taskMeta, taskTokens } from './task-request-card';\nexport { TaskDetail } from './task-detail';\nexport type { TaskRequestCardProps, TaskRequestCardProps as TaskCardProps, TaskLocale, TaskViewerAction } from './task-request-card';\nexport type { TaskDetailProps } from './task-detail';\nexport type { TaskFile, TaskRecord, TaskFilter, TaskState } from '@hachej/boring-ui-kit/task-board';\n\nconst navigation = 'boring:task-navigation';\nconst subscribe = (changed: () => void) => {\n window.addEventListener('popstate', changed); window.addEventListener(navigation, changed);\n return () => { window.removeEventListener('popstate', changed); window.removeEventListener(navigation, changed); };\n};\nconst readSelection = () => taskFromUrl(window.location.href);\nconst serverSelection = () => null;\nconst BOARD_STATES = TASK_STATES.slice(0, 3);\ntype TaskList = 'open' | 'done' | 'closed';\n\nexport interface TaskFileViewApi {\n readonly fullscreen: boolean;\n readonly onFullscreenChange: (fullscreen: boolean) => void;\n readonly onClose: () => void;\n readonly onBack: () => void;\n /** The host's PM pointing service; absent unless onShowFile was supplied. */\n readonly onShowFile?: (() => void) | undefined;\n}\nexport interface TaskBoardProps {\n readonly tasks: readonly TaskRecord[];\n /** Controlled Mine/All. Uncontrolled, the tasks bar starts on Mine when `currentUser` is known, otherwise All. */\n readonly filter?: TaskFilter;\n readonly onFilterChange?: ((filter: TaskFilter) => void) | undefined;\n readonly currentUser?: string;\n readonly limit?: number;\n readonly variant?: 'board' | 'compact';\n /** Compact-only presentation, never server authorization. Supply only records the viewer may already read. */\n readonly compactPresentation?: { readonly includeTechnical?: boolean };\n readonly title?: string;\n /** Column names, shown uppercase. */\n readonly labels?: Readonly<Record<TaskState, string>>;\n /** The tasks bar (« 5 open · Done 12 · Closed 1 », Mine/All). Hosts that draw it in their own header pass false. */\n readonly bar?: boolean;\n /** Host content at the start of the tasks bar, e.g. the collapsed Studio header's app name and « Show the app ». */\n readonly barStart?: ReactNode;\n readonly locale?: TaskLocale;\n /** Explicit host context for compact French labels (« À vous » or « À confirmer »). */\n readonly viewerAction?: TaskViewerAction;\n /** Omit to use ?task= with native history. A router may instead supply controlled selection. */\n readonly selectedTask?: string | null;\n readonly onSelectedTaskChange?: ((id: string | null) => void) | undefined;\n /** Optional host navigation/present path, particularly for compact lists inside the PM panel. */\n readonly onOpen?: ((task: TaskRecord) => void) | undefined;\n /** Host content under the detail's title card. */\n readonly actions?: ((task: TaskRecord) => ReactNode) | undefined;\n /** Host-authorized compact card actions (« Modifier », « Confirmer »). */\n readonly cardActions?: ((task: TaskRecord) => ReactNode) | undefined;\n readonly onAnswer?: TaskDetailProps['onAnswer'];\n readonly onOther?: TaskDetailProps['onOther'];\n readonly onDiscussQuestion?: TaskDetailProps['onDiscussQuestion'];\n readonly onAsk?: TaskDetailProps['onAsk'];\n /** Adapter paths only. The host reauthorizes them when opening the regular viewer. */\n readonly onOpenFile?: TaskDetailProps['onOpenFile'];\n readonly onShowFile?: TaskDetailProps['onOpenFile'];\n /** Supply the host's regular FileViewer; it opens 760px wide over the task detail. No second store or composer. */\n readonly renderFile?: ((task: TaskRecord, file: TaskFile, api: TaskFileViewApi) => ReactNode) | undefined;\n /** Host-owned overlay appearance (for example the host's overlay shadow token). */\n readonly overlayClassName?: string;\n readonly className?: string;\n}\n\n/**\n * The tasks panel (spec 3 §3–§5): tasks bar, WAITING · WORKING · OPEN, and panel-scoped overlays. The task detail opens 400px wide\n * from the right over the board, which keeps its columns; a file opens 760px wide over the detail. `variant=\"compact\"` stacks C7 cards.\n */\nexport function TaskBoard({ tasks, filter, onFilterChange, currentUser, limit, variant = 'board', compactPresentation, title = 'Tasks', labels = taskStateLabels, bar = true, barStart, locale = 'en', viewerAction,\n selectedTask, onSelectedTaskChange, onOpen, actions, cardActions, onAnswer, onOther, onDiscussQuestion, onAsk, onOpenFile, onShowFile, renderFile, overlayClassName, className }: TaskBoardProps) {\n const urlSelection = useSyncExternalStore(subscribe, readSelection, serverSelection);\n const [ownFilter, setOwnFilter] = useState<TaskFilter>(currentUser === undefined ? 'all' : 'mine');\n const [list, setList] = useState<TaskList>('open');\n const shownFilter = filter ?? ownFilter;\n const selected = selectedTask === undefined ? onOpen ? null : urlSelection : selectedTask;\n const detail = selected === null ? undefined : tasks.find(task => task.id === selected || taskReference(task) === selected);\n const shown = selectTasks(tasks, { filter: shownFilter, ...(currentUser === undefined ? {} : { currentUser }), ...(limit === undefined ? {} : { limit }) });\n const [fileSelection, setFileSelection] = useState<{ readonly taskId: string; readonly path: string } | null>(null);\n const filePath = fileSelection?.path;\n const file = detail?.id === fileSelection?.taskId ? detail?.files?.find(candidate => candidate.path === filePath) : undefined;\n const fileOpen = !!file && !!renderFile;\n const [fileFullscreen, setFileFullscreen] = useState(false);\n const closeFile = () => { setFileSelection(null); setFileFullscreen(false); };\n const openFile = (task: TaskRecord, file: TaskFile) => { setFileSelection({ taskId: task.id, path: file.path }); onOpenFile?.(task, file); };\n const root = useRef<HTMLElement>(null);\n const back = useRef<HTMLButtonElement>(null);\n const previous = useRef<{ readonly id: string; readonly state: TaskState | undefined } | null>(null);\n const pendingFocus = useRef<string | null>(null);\n const cardLink = (id: string) => [...root.current?.querySelectorAll<HTMLAnchorElement>('[data-task-open]') ?? []].find(candidate => candidate.dataset['taskOpen'] === id);\n useEffect(() => {\n if (selected !== null) back.current?.focus();\n else if (previous.current !== null) {\n const { id, state } = previous.current, link = cardLink(id);\n if (link) link.focus();\n else if (state === 'done' || state === 'closed') { pendingFocus.current = id; setList(state); }\n }\n previous.current = selected === null ? null : { id: detail?.id ?? selected, state: detail?.state };\n }, [selected, detail?.id, detail?.state]);\n useEffect(() => { if (pendingFocus.current !== null) { cardLink(pendingFocus.current)?.focus(); pendingFocus.current = null; } }, [list]);\n const select = (id: string | null) => {\n closeFile();\n if (selectedTask === undefined) {\n window.history.pushState(null, '', taskUrl(window.location.href, id));\n window.dispatchEvent(new Event(navigation));\n }\n onSelectedTaskChange?.(id);\n };\n const open = (task: TaskRecord) => { if (onOpen) onOpen(task); else select(task.id); };\n const href = (task: TaskRecord) => typeof window === 'undefined' ? `?task=${encodeURIComponent(task.id)}` : taskUrl(window.location.href, task.id);\n const card = (task: TaskRecord) => <TaskRequestCard key={task.id} task={task} variant={variant === 'compact' ? 'compact' : 'card'} selected={task.id === detail?.id} href={href(task)} onOpen={open}\n locale={locale} viewerAction={viewerAction} actions={variant === 'compact' ? cardActions?.(task) : undefined} />;\n const of = (state: TaskState) => shown.filter(task => task.state === state);\n const empty = <Empty><EmptyHeader><EmptyTitle>No requests</EmptyTitle><EmptyDescription>No tasks match this view.</EmptyDescription></EmptyHeader></Empty>;\n const switcher = (target: TaskList, text: string) => list === target ? <span data-task-list-link={target} aria-current=\"page\">{text}</span>\n : <a href=\"#\" data-task-list-link={target} className=\"text-foreground underline underline-offset-2\" onClick={(event: MouseEvent) => { event.preventDefault(); setList(target); }}>{text}</a>;\n const chooseFilter = (next: TaskFilter) => (event: MouseEvent) => { event.preventDefault(); if (filter === undefined) setOwnFilter(next); onFilterChange?.(next); };\n if (variant === 'compact') {\n const strings = compactLabels[locale];\n // Filter hidden records and prioritize current viewer needs before applying the visible limit.\n const compact = selectTasks(tasks.filter(task => task.technical !== true || compactPresentation?.includeTechnical === true), { filter: shownFilter, ...(currentUser === undefined ? {} : { currentUser }) })\n .slice().sort((a, b) => Number(compactTaskStatus(b) === 'needs-you') - Number(compactTaskStatus(a) === 'needs-you'))\n .slice(0, limit);\n const ordinary = compact.filter(task => task.technical !== true && compactTaskStatus(task) !== 'closed');\n const closed = compact.filter(task => task.technical !== true && compactTaskStatus(task) === 'closed');\n const technical = compact.filter(task => task.technical === true);\n const group = (kind: 'closed' | 'technical', records: readonly TaskRecord[]) => records.length > 0 && <Collapsible data-task-group={kind}>\n <Separator />\n <CollapsibleTrigger render={<Button variant=\"ghost\" />}>{kind === 'closed' ? strings.finished : strings.technical} · {records.length}</CollapsibleTrigger>\n <CollapsibleContent><div role=\"list\" aria-label={kind === 'closed' ? strings.finished : strings.technical} className=\"flex flex-col gap-2\">{records.map(card)}</div></CollapsibleContent>\n </Collapsible>;\n return <section ref={root} data-boring=\"task-board\" data-variant=\"compact\" aria-label={title} className={cn('flex min-w-0 flex-col gap-2', className)}>\n <div data-task-list role=\"list\" aria-label={title} className=\"flex flex-col gap-2\">{ordinary.map(card)}</div>\n {!compact.length && <Empty><EmptyHeader><EmptyTitle>{strings.empty}</EmptyTitle><EmptyDescription>{strings.emptyDescription}</EmptyDescription></EmptyHeader></Empty>}\n {group('closed', closed)}\n {group('technical', technical)}\n </section>;\n }\n return <section ref={root} data-boring=\"task-board\" data-variant={variant} aria-label={title} className={cn('flex min-h-0 min-w-0 flex-col', className)} onKeyDown={event => {\n if (event.key === 'Escape' && selected !== null && !event.defaultPrevented) { event.preventDefault(); if (fileOpen) closeFile(); else select(null); }\n }}>\n {bar && <nav data-task-bar aria-label=\"Task lists\" className=\"flex h-13 shrink-0 items-center gap-2.5 px-3.5 text-[13px] text-muted-foreground\">\n {barStart}\n {switcher('open', `${BOARD_STATES.reduce((sum, state) => sum + of(state).length, 0)} open`)}<span aria-hidden=\"true\">·</span>\n {switcher('done', `Done ${of('done').length}`)}<span aria-hidden=\"true\">·</span>\n {switcher('closed', `Closed ${of('closed').length}`)}\n {currentUser !== undefined && <span className={cn('ml-auto flex items-center gap-2.5', taskTokens.pill)} data-task-filter={shownFilter}>\n {(['mine', 'all'] as const).map(value => <Badge key={value} variant={shownFilter === value ? 'default' : 'secondary'} render={<a href=\"#\" aria-current={shownFilter === value ? 'true' : undefined} onClick={chooseFilter(value)} />}>{value === 'mine' ? 'Mine' : 'All'}</Badge>)}\n </span>}\n </nav>}\n <div data-task-stage className=\"relative min-h-0 flex-1 overflow-hidden\">\n <div data-task-list className=\"@container/task-rails h-full min-w-0 overflow-auto\">\n {list === 'open' ? <div className=\"grid min-w-0 content-start gap-2.5 p-3.5 @2xl/task-rails:grid-cols-3\">\n {BOARD_STATES.map(state => {\n const column = of(state).sort((a, b) => Number(b.waiting_on_you === true) - Number(a.waiting_on_you === true));\n const onYou = column.filter(task => task.waiting_on_you === true).length;\n return <section key={state} data-task-column={state} aria-label={labels[state]} className={cn('flex min-w-0 flex-col gap-[7px] self-start bg-muted p-2', taskTokens.column)}>\n <h3 className=\"flex min-h-6 items-center justify-between gap-2 px-0.5 pb-0.5 text-[11px] font-semibold tracking-[0.06em] text-muted-foreground\"><span className=\"uppercase\">{labels[state]} {column.length}</span>{onYou > 0 && <span className={cn('contents', taskTokens.pill)}><Badge data-task-on-you=\"\">{onYou} on you</Badge></span>}</h3>\n <div role=\"list\" className=\"flex flex-col gap-[7px]\">{column.map(card)}</div>\n </section>;\n })}\n </div> : <section data-task-column={list} aria-label={labels[list]} className=\"flex max-w-xl min-w-0 flex-col gap-[7px] p-3.5\">\n <div role=\"list\" className=\"flex flex-col gap-[7px]\">{of(list).map(card)}</div>\n {!of(list).length && empty}\n </section>}\n </div>\n {selected !== null && <aside aria-label=\"Task detail\" data-task-side className={cn('absolute inset-y-0 right-0 flex w-[400px] max-w-full flex-col bg-popover text-popover-foreground', overlayClassName)} aria-live=\"polite\">\n <Separator orientation=\"vertical\" className=\"absolute inset-y-0 left-0\" />\n <div className=\"flex h-10 shrink-0 items-center gap-2 pr-1.5 pl-3.5\">\n <Button ref={back} variant=\"ghost\" size=\"icon\" data-task-back=\"\" aria-label=\"Back to the tasks\" title=\"Back to the tasks\" onClick={() => select(null)}><ChevronLeftIcon aria-hidden=\"true\" /></Button>\n {detail && <><span className=\"text-[11px] text-muted-foreground\">{taskMeta(detail)}</span><TaskStatusPill task={detail} locale={locale} viewerAction={viewerAction} /></>}\n </div>\n <Separator />\n <div className=\"min-h-0 flex-1 overflow-auto p-3.5\">\n {detail ? <TaskDetail key={detail.id} task={detail} actions={actions?.(detail)} onAnswer={onAnswer} onOther={onOther} onDiscussQuestion={onDiscussQuestion} onAsk={onAsk} onOpenFile={renderFile || onOpenFile ? openFile : undefined} />\n : <Empty><EmptyHeader><EmptyTitle>Task unavailable</EmptyTitle><EmptyDescription>The host has not supplied this task.</EmptyDescription></EmptyHeader></Empty>}\n </div>\n </aside>}\n {fileOpen && detail && file && <section aria-label=\"Task file viewer\" data-task-file-pane={detail.id} className={cn('flex min-w-0 flex-col bg-background', fileFullscreen ? 'fixed inset-0 z-50' : 'absolute inset-y-0 right-0 w-[760px] max-w-full', overlayClassName)}>\n {renderFile!(detail, file, { fullscreen: fileFullscreen, onFullscreenChange: setFileFullscreen, onClose: closeFile, onBack: closeFile, onShowFile: onShowFile ? () => onShowFile(detail, file) : undefined })}\n </section>}\n </div>\n </section>;\n}\n",
18
24
  "type": "registry:component",
19
25
  "target": "components/task-board/task-board.tsx"
20
26
  },
21
27
  {
22
28
  "path": "registry/task-board/task-request-card.tsx",
23
- "content": "'use client';\n\nimport type { MouseEvent, ReactNode } from 'react';\nimport type { TaskRecord, TaskState } from '@hachej/boring-ui-kit/task-board';\nimport { deriveTaskStatus, taskReference, taskStatusLabel } from '@hachej/boring-ui-kit/task-board';\nimport { Badge } from '@/components/ui/badge';\nimport { Item } from '@/components/ui/item';\nimport { cn } from '../utils/utils';\n\nexport type TaskLocale = 'en' | 'fr';\nexport type TaskViewerAction = 'question' | 'confirmation';\nexport const taskStateLabels: Readonly<Record<TaskState, string>> = { waiting: 'Waiting', working: 'Working', open: 'Open', done: 'Done', closed: 'Closed' };\nconst captures = (count: number) => `${count} capture${count === 1 ? '' : 's'}`;\n/** Compact (C7) strings. French uses a non-breaking space before the colon. */\nconst compactStrings = {\n en: { label: 'REQUEST', screen: (screen: string) => `Screen: ${screen}` },\n fr: { label: 'DEMANDE', screen: (screen: string) => `Écran : ${screen}` },\n} as const;\n\n/**\n * Task-board tokens, set by the host theme. Each falls back to the stock shadcn value, so a host without them looks unchanged.\n * `--task-pill-height` / `--task-pill-font-size` (pills), `--task-control-height` (question controls), `--task-column-radius`,\n * `--task-card-padding-y` / `--task-card-padding-x` (board cards).\n */\nexport const taskTokens = {\n pill: '*:h-[var(--task-pill-height,1.25rem)] *:text-[length:var(--task-pill-font-size,0.75rem)]',\n control: '*:h-[var(--task-control-height,2rem)]',\n card: '[&_[data-slot=item]]:px-[var(--task-card-padding-x,0.75rem)] [&_[data-slot=item]]:py-[var(--task-card-padding-y,0.625rem)]',\n column: 'rounded-[var(--task-column-radius,var(--radius-xl))]',\n} as const;\n\n/** `id · age`, e.g. « D-201 · 12m ». */\nexport const taskMeta = (task: TaskRecord) => [taskReference(task), task.age].filter(Boolean).join(' · ');\n\n/**\n * One pill per task: viewer, explicit builder, then high risk (`deriveTaskStatus`); none renders nothing. Colours are theme roles:\n * primary (waiting on you), secondary (builder, sent), destructive (risk). The French app's « À confirmer / À vous » pill uses the host's\n * warning-soft tokens when it defines them. `sent` opts into « Envoyée » (the compact card only; the board and the detail keep theirs).\n */\nexport function TaskStatusPill({ task, locale = 'en', viewerAction = 'question', sent = false }: { readonly task: Pick<TaskRecord, 'waiting_on_you' | 'waiting_on' | 'risk'> & Partial<Pick<TaskRecord, 'state' | 'confirmed_at'>>; readonly locale?: TaskLocale | undefined; readonly viewerAction?: TaskViewerAction | undefined; readonly sent?: boolean | undefined }) {\n const status = deriveTaskStatus(task, { sent });\n const label = taskStatusLabel(status, locale, viewerAction);\n if (!label) return null;\n const warning = status === 'waiting-on-viewer' && locale === 'fr';\n const badge = <Badge data-task-status={status} variant={status === 'waiting-on-viewer' && !warning ? 'default' : status === 'high-risk' ? 'destructive' : 'secondary'}>{label}</Badge>;\n return <span className={cn('contents', taskTokens.pill, warning && '[--secondary:var(--warning-soft,var(--secondary))] [--secondary-foreground:var(--warning-soft-foreground,var(--secondary-foreground))]')}>{badge}</span>;\n}\n/** @deprecated The pill now follows `deriveTaskStatus`; `labels` is ignored. */\nexport const TaskStatusChip = TaskStatusPill as (props: { readonly task: Pick<TaskRecord, 'waiting_on_you' | 'waiting_on' | 'risk'>; readonly labels?: unknown; readonly locale?: TaskLocale; readonly viewerAction?: TaskViewerAction }) => ReactNode;\n\n/** A card waiting on the viewer carries a 2px foreground edge: the outline border plus a 1px ring, both in the primary role. */\nexport function TaskAttention({ active, children }: { readonly active: boolean; readonly children: ReactNode }) {\n return active ? <div data-task-attention=\"\" className=\"rounded-[var(--radius-lg)] ring-1 ring-primary [--border:var(--primary)]\">{children}</div> : <>{children}</>;\n}\n\nexport interface TaskRequestCardProps {\n readonly task: TaskRecord;\n /** `card`: the board card (D2). `compact`: the app's request card (C7). */\n readonly variant?: 'card' | 'compact';\n readonly selected?: boolean;\n readonly href?: string;\n readonly onOpen?: ((task: TaskRecord) => void) | undefined;\n /** Host-authorized actions of the compact card, e.g. « Modifier » and « Confirmer »; rendered as two equal columns. */\n readonly actions?: ReactNode;\n readonly locale?: TaskLocale | undefined;\n /** Explicit host context for the French viewer label: « À vous » (question) or « À confirmer » (confirmation). */\n readonly viewerAction?: TaskViewerAction | undefined;\n /** Compact only: replaces the derived status pill, e.g. a finished request's muted « En ligne » in « Mes demandes ». */\n readonly pill?: ReactNode;\n /** @deprecated Ignored; kept for source compatibility. */\n readonly labels?: unknown;\n}\n\n/** One record, two renderings: the board card (D2) and the compact request card (C7). Actions remain host-authorized. */\nexport function TaskRequestCard({ task, variant = 'card', selected, href, onOpen, actions, locale = 'en', viewerAction, pill: shownPill }: TaskRequestCardProps) {\n const activate = (event: MouseEvent<HTMLAnchorElement>) => {\n if (!onOpen || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || event.button !== 0) return;\n event.preventDefault(); onOpen(task);\n };\n const anchor = { href, onClick: activate, 'data-task-open': task.id, 'aria-current': selected ? 'true' as const : undefined };\n const link = href ? <a {...anchor} /> : undefined;\n if (variant === 'compact') {\n const pill = shownPill ?? <TaskStatusPill task={task} locale={locale} viewerAction={viewerAction} sent />;\n const strings = compactStrings[locale];\n const meta = [task.screen && strings.screen(task.screen), task.captureCount !== undefined && captures(task.captureCount)].filter(Boolean).join(' · ');\n const title = link && actions ? <a {...anchor} className=\"underline-offset-4 hover:underline\">{task.title}</a> : task.title;\n return <div role=\"listitem\"><Item variant=\"outline\" className=\"flex-col items-stretch gap-2\" data-task-card={task.id} data-variant=\"compact\" {...(link && !actions ? { render: link } : {})}>\n <div className=\"flex min-w-0 items-center justify-between gap-2\"><span className=\"text-xs font-semibold text-muted-foreground\">{strings.label}</span>{pill}</div>\n <div className=\"min-w-0 text-[15px] leading-tight font-semibold break-words\">{title}</div>\n {meta && <div className=\"text-[13px] text-muted-foreground\">{meta}</div>}\n {actions && <div data-task-actions=\"\" className=\"mt-1 grid grid-cols-2 gap-2 *:min-h-11 *:w-full\">{actions}</div>}\n </Item></div>;\n }\n const pill = <TaskStatusPill task={task} locale={locale} viewerAction={viewerAction} />;\n return <div role=\"listitem\" className={cn('rounded-[var(--radius-lg)] bg-card', taskTokens.card)}><TaskAttention active={task.waiting_on_you === true}>\n <Item variant=\"outline\" className=\"flex-col items-stretch gap-1\" data-task-card={task.id} data-selected={selected || undefined} {...(link ? { render: link } : {})}>\n <span className=\"flex min-h-5 min-w-0 items-center justify-between gap-2 text-[11px] text-muted-foreground\"><span>{taskMeta(task)}</span>{pill}</span>\n <span className=\"min-w-0 text-sm break-words text-foreground\">{task.title}</span>\n </Item>\n </TaskAttention></div>;\n}\n",
29
+ "content": "'use client';\n\nimport type { MouseEvent, ReactNode } from 'react';\nimport type { TaskRecord, TaskState } from '@hachej/boring-ui-kit/task-board';\nimport { deriveTaskStatus, taskReference, taskStatusLabel } from '@hachej/boring-ui-kit/task-board';\nimport { Badge } from '@/components/ui/badge';\nimport { Item } from '@/components/ui/item';\nimport { cn } from '../utils/utils';\nimport { compactLabels, compactTaskStatus, compactTaskTitle } from './compact-presentation';\n\nexport type TaskLocale = 'en' | 'fr';\nexport type TaskViewerAction = 'question' | 'confirmation';\nexport const taskStateLabels: Readonly<Record<TaskState, string>> = { waiting: 'Waiting', working: 'Working', open: 'Open', done: 'Done', closed: 'Closed' };\n/**\n * Task-board tokens, set by the host theme. Each falls back to the stock shadcn value, so a host without them looks unchanged.\n * `--task-pill-height` / `--task-pill-font-size` (pills), `--task-control-height` (question controls), `--task-column-radius`,\n * `--task-card-padding-y` / `--task-card-padding-x` (board cards).\n */\nexport const taskTokens = {\n pill: '*:h-[var(--task-pill-height,1.25rem)] *:text-[length:var(--task-pill-font-size,0.75rem)]',\n control: '*:h-[var(--task-control-height,2rem)]',\n card: '[&_[data-slot=item]]:px-[var(--task-card-padding-x,0.75rem)] [&_[data-slot=item]]:py-[var(--task-card-padding-y,0.625rem)]',\n column: 'rounded-[var(--task-column-radius,var(--radius-xl))]',\n} as const;\n\n/** `id · age`, e.g. « D-201 · 12m ». */\nexport const taskMeta = (task: TaskRecord) => [taskReference(task), task.age].filter(Boolean).join(' · ');\n\n/**\n * One pill per task: viewer, explicit builder, then high risk (`deriveTaskStatus`); none renders nothing. Colours are theme roles:\n * primary (waiting on you), secondary (builder, sent), destructive (risk). The French app's « À confirmer / À vous » pill uses the host's\n * warning-soft tokens when it defines them. `sent` lets standalone callers opt into « Envoyée »; compact rows use their separate product status.\n */\nexport function TaskStatusPill({ task, locale = 'en', viewerAction = 'question', sent = false }: { readonly task: Pick<TaskRecord, 'waiting_on_you' | 'waiting_on' | 'risk'> & Partial<Pick<TaskRecord, 'state' | 'confirmed_at'>>; readonly locale?: TaskLocale | undefined; readonly viewerAction?: TaskViewerAction | undefined; readonly sent?: boolean | undefined }) {\n const status = deriveTaskStatus(task, { sent });\n const label = taskStatusLabel(status, locale, viewerAction);\n if (!label) return null;\n const warning = status === 'waiting-on-viewer' && locale === 'fr';\n const badge = <Badge data-task-status={status} variant={status === 'waiting-on-viewer' && !warning ? 'default' : status === 'high-risk' ? 'destructive' : 'secondary'}>{label}</Badge>;\n return <span className={cn('contents', taskTokens.pill, warning && '[--secondary:var(--warning-soft,var(--secondary))] [--secondary-foreground:var(--warning-soft-foreground,var(--secondary-foreground))]')}>{badge}</span>;\n}\n/** @deprecated The pill now follows `deriveTaskStatus`; `labels` is ignored. */\nexport const TaskStatusChip = TaskStatusPill as (props: { readonly task: Pick<TaskRecord, 'waiting_on_you' | 'waiting_on' | 'risk'>; readonly labels?: unknown; readonly locale?: TaskLocale; readonly viewerAction?: TaskViewerAction }) => ReactNode;\n\n/** A card waiting on the viewer carries a 2px foreground edge: the outline border plus a 1px ring, both in the primary role. */\nexport function TaskAttention({ active, children }: { readonly active: boolean; readonly children: ReactNode }) {\n return active ? <div data-task-attention=\"\" className=\"rounded-[var(--radius-lg)] ring-1 ring-primary [--border:var(--primary)]\">{children}</div> : <>{children}</>;\n}\n\nexport interface TaskRequestCardProps {\n readonly task: TaskRecord;\n /** `card`: the board card (D2). `compact`: the app's request card (C7). */\n readonly variant?: 'card' | 'compact';\n readonly selected?: boolean;\n readonly href?: string;\n readonly onOpen?: ((task: TaskRecord) => void) | undefined;\n /** Host-authorized actions of the compact card, e.g. « Modifier » and « Confirmer »; rendered as two equal columns. */\n readonly actions?: ReactNode;\n readonly locale?: TaskLocale | undefined;\n /** Explicit host context for the French viewer label: « À vous » (question) or « À confirmer » (confirmation). */\n readonly viewerAction?: TaskViewerAction | undefined;\n /** Compact only: replaces the derived status pill, e.g. a finished request's muted « En ligne » in « Mes demandes ». */\n readonly pill?: ReactNode;\n /** @deprecated Ignored; kept for source compatibility. */\n readonly labels?: unknown;\n}\n\n/** One record, two renderings: the board card (D2) and the compact request card (C7). Actions remain host-authorized. */\nexport function TaskRequestCard({ task, variant = 'card', selected, href, onOpen, actions, locale = 'en', viewerAction, pill: shownPill }: TaskRequestCardProps) {\n const activate = (event: MouseEvent<HTMLAnchorElement>) => {\n if (!onOpen || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || event.button !== 0) return;\n event.preventDefault(); onOpen(task);\n };\n const anchor = { href, onClick: activate, 'data-task-open': task.id, 'aria-current': selected ? 'true' as const : undefined };\n const link = href ? <a {...anchor} /> : undefined;\n if (variant === 'compact') {\n const status = compactTaskStatus(task);\n const strings = compactLabels[locale];\n const label = status === 'needs-you' && viewerAction === 'confirmation' ? strings.confirmation\n : status === 'closed' && (task.status === 'withdrawn' || task.status === 'abandoned') ? strings.withdrawn : strings[status];\n const pill = shownPill ?? <Badge data-task-status={status} variant={status === 'needs-you' ? 'default' : status === 'blocked' ? 'destructive' : 'secondary'}>{label}</Badge>;\n const shortTitle = compactTaskTitle(task);\n const title = link && actions ? <a {...anchor} className=\"underline-offset-4 hover:underline\">{shortTitle}</a> : shortTitle;\n return <div role=\"listitem\"><Item variant=\"outline\" className=\"flex-col items-stretch gap-2\" data-task-card={task.id} data-variant=\"compact\" {...(link && !actions ? { render: link } : {})}>\n <div className=\"flex min-w-0 items-start justify-between gap-2\">\n <span data-task-title=\"\" className=\"min-w-0 text-sm font-semibold break-words\">{title}</span>\n <span className=\"shrink-0\">{pill}</span>\n </div>\n {actions && <div data-task-actions=\"\" className=\"mt-1 grid grid-cols-2 gap-2 *:min-h-11 *:w-full\">{actions}</div>}\n </Item></div>;\n }\n const pill = <TaskStatusPill task={task} locale={locale} viewerAction={viewerAction} />;\n return <div role=\"listitem\" className={cn('rounded-[var(--radius-lg)] bg-card', taskTokens.card)}><TaskAttention active={task.waiting_on_you === true}>\n <Item variant=\"outline\" className=\"flex-col items-stretch gap-1\" data-task-card={task.id} data-selected={selected || undefined} {...(link ? { render: link } : {})}>\n <span className=\"flex min-h-5 min-w-0 items-center justify-between gap-2 text-[11px] text-muted-foreground\"><span>{taskMeta(task)}</span>{pill}</span>\n <span className=\"min-w-0 text-sm break-words text-foreground\">{task.display ?? task.title}</span>\n </Item>\n </TaskAttention></div>;\n}\n",
24
30
  "type": "registry:component",
25
31
  "target": "components/task-board/task-request-card.tsx"
26
32
  },
@@ -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.38",
8
- "@hachej/boring-files@^0.4.38",
7
+ "@hachej/boring-ui-kit@^0.4.40",
8
+ "@hachej/boring-files@^0.4.40",
9
9
  "react@^19.0.0",
10
10
  "react-dom@^19.0.0",
11
11
  "zod@^4.6.5"
@@ -4,14 +4,14 @@
4
4
  "title": "Task mentions",
5
5
  "description": "Optional authorized task catalog for the existing PM composer, composed with host file search.",
6
6
  "dependencies": [
7
- "@hachej/boring-ui-kit@^0.4.38",
7
+ "@hachej/boring-ui-kit@^0.4.40",
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.38/r/task-board.json",
13
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.38/r/pi-chat.json",
14
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.38/r/pi-chat-mentions.json"
12
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.40/r/task-board.json",
13
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.40/r/pi-chat.json",
14
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.40/r/pi-chat-mentions.json"
15
15
  ],
16
16
  "files": [
17
17
  {
@@ -4,11 +4,11 @@
4
4
  "title": "Version history rows",
5
5
  "description": "Shared view-only version rows and menu metadata with relative time, provenance and Current. No file reads, publication or restoration.",
6
6
  "dependencies": [
7
- "@hachej/boring-files@^0.4.38",
7
+ "@hachej/boring-files@^0.4.40",
8
8
  "react@^19.0.0"
9
9
  ],
10
10
  "registryDependencies": [
11
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.38/r/button.json"
11
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.40/r/button.json"
12
12
  ],
13
13
  "files": [
14
14
  {
@@ -8,8 +8,8 @@
8
8
  "lucide-react@^1.51.0"
9
9
  ],
10
10
  "registryDependencies": [
11
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.38/r/viewer-frame.json",
12
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.38/r/pi-chat.json"
11
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.40/r/viewer-frame.json",
12
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.40/r/pi-chat.json"
13
13
  ],
14
14
  "files": [
15
15
  {
@@ -4,15 +4,15 @@
4
4
  "title": "Viewer frame",
5
5
  "description": "The standard viewer top bar every viewer draws in (title, status, viewer controls, Refresh, Share, Copy, Download, Open in new tab, Close, overflow menu), its window labels, download, share and media helpers and the sandboxed interactive HTML frame. The viewers' styles live here. Each viewer is its own item (viewer-markdown, viewer-html, viewer-image, viewer-pdf, viewer-code) with a cheap kind module and a view loaded on first open.",
6
6
  "dependencies": [
7
- "@hachej/boring-ui-kit@^0.4.38",
7
+ "@hachej/boring-ui-kit@^0.4.40",
8
8
  "react@^19.0.0",
9
9
  "react-dom@^19.0.0",
10
10
  "lucide-react@^1.51.0"
11
11
  ],
12
12
  "registryDependencies": [
13
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.38/r/utils.json",
14
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.38/r/button.json",
15
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.38/r/version-history.json"
13
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.40/r/utils.json",
14
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.40/r/button.json",
15
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.40/r/version-history.json"
16
16
  ],
17
17
  "files": [
18
18
  {
@@ -4,14 +4,14 @@
4
4
  "title": "HTML viewer",
5
5
  "description": "The HTML kind: `htmlKind` (match, loader) and `HtmlPane`, the passive HTML preview (sandboxed when the host opts in) and source editor, in the viewer frame. The pane loads the first time an HTML file opens.",
6
6
  "dependencies": [
7
- "@hachej/boring-ui-kit@^0.4.38",
7
+ "@hachej/boring-ui-kit@^0.4.40",
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.38/r/viewer-frame.json",
13
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.38/r/utils.json",
14
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.38/r/button.json"
12
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.40/r/viewer-frame.json",
13
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.40/r/utils.json",
14
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.40/r/button.json"
15
15
  ],
16
16
  "files": [
17
17
  {