@hachej/boring-ui-kit 0.4.34 → 0.4.35
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/chat-view.js +2 -1
- package/package.json +2 -2
- package/r/agent-target.json +4 -4
- package/r/app-shell.json +10 -10
- package/r/board-viewer.json +6 -6
- package/r/button.json +1 -1
- package/r/dock.json +1 -1
- package/r/documents.json +12 -12
- package/r/feedback-preview.json +1 -1
- package/r/feedback.json +6 -6
- package/r/file-tree.json +4 -4
- package/r/highlights.json +3 -3
- package/r/html-viewer.json +3 -3
- package/r/looks-viewer.json +2 -2
- package/r/maquette-viewer.json +1 -1
- package/r/markdown-editor.json +3 -3
- package/r/mockup-viewer.json +1 -1
- package/r/pi-ambient.json +9 -9
- package/r/pi-app-kinds.json +10 -10
- package/r/pi-app.json +10 -10
- package/r/pi-artifacts.json +2 -2
- package/r/pi-chat-approval.json +2 -2
- package/r/pi-chat-attachments.json +1 -1
- package/r/pi-chat-markdown.json +2 -2
- package/r/pi-chat-mentions.json +1 -1
- package/r/pi-chat-pickers.json +1 -1
- package/r/pi-chat-plugins.json +11 -11
- package/r/pi-chat-question.json +1 -1
- package/r/pi-chat-slash.json +2 -2
- package/r/pi-chat-subagent.json +1 -1
- package/r/pi-chat-voice.json +3 -3
- package/r/pi-chat.json +10 -10
- package/r/pi-workspace.json +3 -3
- package/r/placed-agent.json +4 -4
- package/r/pm-panel.json +16 -16
- package/r/provider-setup.json +1 -1
- package/r/registry.json +183 -183
- package/r/requests-board.json +1 -1
- package/r/shared-chat.json +7 -7
- package/r/task-board.json +3 -3
- package/r/task-list-viewer.json +2 -2
- package/r/task-mentions.json +4 -4
- package/r/version-history.json +2 -2
- package/r/viewer-code.json +2 -2
- package/r/viewer-frame.json +4 -4
- package/r/viewer-html.json +4 -4
- package/r/viewer-image.json +2 -2
- package/r/viewer-markdown.json +5 -5
- package/r/viewer-pdf.json +3 -3
- package/r/viewers.json +6 -6
- package/r/windows.json +1 -1
package/r/requests-board.json
CHANGED
package/r/shared-chat.json
CHANGED
|
@@ -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.
|
|
7
|
+
"@hachej/boring-ui-kit@^0.4.35",
|
|
8
8
|
"react@^19.0.0",
|
|
9
9
|
"lucide-react@^1.51.0"
|
|
10
10
|
],
|
|
11
11
|
"registryDependencies": [
|
|
12
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
13
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
14
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
15
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
16
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
12
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.35/r/pi-chat.json",
|
|
13
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.35/r/pi-chat-plugins.json",
|
|
14
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.35/r/pi-chat-markdown.json",
|
|
15
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.35/r/pi-chat-mentions.json",
|
|
16
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.35/r/utils.json"
|
|
17
17
|
],
|
|
18
18
|
"files": [
|
|
19
19
|
{
|
|
@@ -36,7 +36,7 @@
|
|
|
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 { 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 }: {\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}) {\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} 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 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, 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 }), [mentions.renderSent, openFile]);\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 ? <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",
|
|
40
40
|
"type": "registry:component",
|
|
41
41
|
"target": "components/shared-chat/timeline.tsx"
|
|
42
42
|
},
|
package/r/task-board.json
CHANGED
|
@@ -4,12 +4,12 @@
|
|
|
4
4
|
"title": "Task board",
|
|
5
5
|
"description": "Host-owned business requests: board, stacked task/file overlays and compact own-request rows. No agent or task backend required.",
|
|
6
6
|
"dependencies": [
|
|
7
|
-
"@hachej/boring-ui-kit@^0.4.
|
|
7
|
+
"@hachej/boring-ui-kit@^0.4.35",
|
|
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.
|
|
12
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.35/r/utils.json"
|
|
13
13
|
],
|
|
14
14
|
"files": [
|
|
15
15
|
{
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
},
|
|
21
21
|
{
|
|
22
22
|
"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 /** @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 }: 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 = <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",
|
|
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",
|
|
24
24
|
"type": "registry:component",
|
|
25
25
|
"target": "components/task-board/task-request-card.tsx"
|
|
26
26
|
},
|
package/r/task-list-viewer.json
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
"title": "Consumer task-list viewer",
|
|
5
5
|
"description": "A consumer-defined task-list schema and controller over the shared text buffer, with a restylable borrowed React renderer.",
|
|
6
6
|
"dependencies": [
|
|
7
|
-
"@hachej/boring-ui-kit@^0.4.
|
|
8
|
-
"@hachej/boring-files@^0.4.
|
|
7
|
+
"@hachej/boring-ui-kit@^0.4.35",
|
|
8
|
+
"@hachej/boring-files@^0.4.35",
|
|
9
9
|
"react@^19.0.0",
|
|
10
10
|
"react-dom@^19.0.0",
|
|
11
11
|
"zod@^4.6.5"
|
package/r/task-mentions.json
CHANGED
|
@@ -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.
|
|
7
|
+
"@hachej/boring-ui-kit@^0.4.35",
|
|
8
8
|
"react@^19.0.0",
|
|
9
9
|
"lucide-react@^1.51.0"
|
|
10
10
|
],
|
|
11
11
|
"registryDependencies": [
|
|
12
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
13
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
14
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
12
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.35/r/task-board.json",
|
|
13
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.35/r/pi-chat.json",
|
|
14
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.35/r/pi-chat-mentions.json"
|
|
15
15
|
],
|
|
16
16
|
"files": [
|
|
17
17
|
{
|
package/r/version-history.json
CHANGED
|
@@ -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.
|
|
7
|
+
"@hachej/boring-files@^0.4.35",
|
|
8
8
|
"react@^19.0.0"
|
|
9
9
|
],
|
|
10
10
|
"registryDependencies": [
|
|
11
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
11
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.35/r/button.json"
|
|
12
12
|
],
|
|
13
13
|
"files": [
|
|
14
14
|
{
|
package/r/viewer-code.json
CHANGED
|
@@ -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.
|
|
12
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
11
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.35/r/viewer-frame.json",
|
|
12
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.35/r/pi-chat.json"
|
|
13
13
|
],
|
|
14
14
|
"files": [
|
|
15
15
|
{
|
package/r/viewer-frame.json
CHANGED
|
@@ -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.
|
|
7
|
+
"@hachej/boring-ui-kit@^0.4.35",
|
|
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.
|
|
14
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
15
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
13
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.35/r/utils.json",
|
|
14
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.35/r/button.json",
|
|
15
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.35/r/version-history.json"
|
|
16
16
|
],
|
|
17
17
|
"files": [
|
|
18
18
|
{
|
package/r/viewer-html.json
CHANGED
|
@@ -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.
|
|
7
|
+
"@hachej/boring-ui-kit@^0.4.35",
|
|
8
8
|
"react@^19.0.0",
|
|
9
9
|
"lucide-react@^1.51.0"
|
|
10
10
|
],
|
|
11
11
|
"registryDependencies": [
|
|
12
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
13
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
14
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
12
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.35/r/viewer-frame.json",
|
|
13
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.35/r/utils.json",
|
|
14
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.35/r/button.json"
|
|
15
15
|
],
|
|
16
16
|
"files": [
|
|
17
17
|
{
|
package/r/viewer-image.json
CHANGED
|
@@ -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.
|
|
12
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
11
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.35/r/viewer-frame.json",
|
|
12
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.35/r/utils.json"
|
|
13
13
|
],
|
|
14
14
|
"files": [
|
|
15
15
|
{
|
package/r/viewer-markdown.json
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"title": "Markdown viewer",
|
|
5
5
|
"description": "The Markdown kind: `markdownKind` (match, loader) and `MarkdownPane`, the tiptap editor with a formatting toolbar and prose styling over the kit's revisioned Markdown controller, in the viewer frame. The pane and tiptap load the first time a Markdown file opens.",
|
|
6
6
|
"dependencies": [
|
|
7
|
-
"@hachej/boring-ui-kit@^0.4.
|
|
7
|
+
"@hachej/boring-ui-kit@^0.4.35",
|
|
8
8
|
"react@^19.0.0",
|
|
9
9
|
"lucide-react@^1.51.0",
|
|
10
10
|
"marked@^17.0.6",
|
|
@@ -22,10 +22,10 @@
|
|
|
22
22
|
"@tiptap/extension-floating-menu@^3.31.4"
|
|
23
23
|
],
|
|
24
24
|
"registryDependencies": [
|
|
25
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
26
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
27
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
28
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
25
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.35/r/viewer-frame.json",
|
|
26
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.35/r/markdown-editor.json",
|
|
27
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.35/r/utils.json",
|
|
28
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.35/r/button.json"
|
|
29
29
|
],
|
|
30
30
|
"files": [
|
|
31
31
|
{
|
package/r/viewer-pdf.json
CHANGED
|
@@ -4,14 +4,14 @@
|
|
|
4
4
|
"title": "PDF viewer",
|
|
5
5
|
"description": "The PDF kind: `pdfKind` (match, loader) and `PdfPane` in the viewer frame (the browser's viewer, or pdf.js pages where there is none), and LocalFileViewer for browser-only files. The pane loads the first time a PDF opens.",
|
|
6
6
|
"dependencies": [
|
|
7
|
-
"@hachej/boring-files@^0.4.
|
|
7
|
+
"@hachej/boring-files@^0.4.35",
|
|
8
8
|
"react@^19.0.0",
|
|
9
9
|
"lucide-react@^1.51.0",
|
|
10
10
|
"pdfjs-dist@6.4.299"
|
|
11
11
|
],
|
|
12
12
|
"registryDependencies": [
|
|
13
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
14
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
13
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.35/r/viewer-frame.json",
|
|
14
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.35/r/viewer-image.json"
|
|
15
15
|
],
|
|
16
16
|
"files": [
|
|
17
17
|
{
|
package/r/viewers.json
CHANGED
|
@@ -4,12 +4,12 @@
|
|
|
4
4
|
"title": "Viewers",
|
|
5
5
|
"description": "Deprecated aggregate: installs every viewer item (viewer-frame, viewer-markdown, viewer-html, viewer-image, viewer-pdf, viewer-code). Add the items you use instead.",
|
|
6
6
|
"registryDependencies": [
|
|
7
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
8
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
9
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
10
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
11
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
12
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
7
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.35/r/viewer-frame.json",
|
|
8
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.35/r/viewer-markdown.json",
|
|
9
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.35/r/viewer-html.json",
|
|
10
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.35/r/viewer-image.json",
|
|
11
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.35/r/viewer-pdf.json",
|
|
12
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.35/r/viewer-code.json"
|
|
13
13
|
],
|
|
14
14
|
"files": [],
|
|
15
15
|
"type": "registry:component"
|
package/r/windows.json
CHANGED