@hachej/boring-ui-kit 0.4.46 → 0.4.48
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/README.md +0 -4
- package/dist/chat-react-session.d.ts +1 -0
- package/dist/chat-react-session.js +12 -3
- package/dist/chat-view.d.ts +2 -1
- package/dist/native-chat.d.ts +39 -2
- package/dist/native-chat.js +135 -9
- package/dist/remote-chat.d.ts +6 -0
- package/dist/remote-chat.js +22 -0
- package/dist/shared-chat/index.d.ts +1 -1
- package/package.json +2 -6
- package/r/agent-target.json +4 -4
- package/r/app-shell.json +12 -12
- 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.json +24 -12
- 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 +12 -12
- 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 +9 -3
- package/r/pi-chat.json +11 -24
- package/r/pi-workspace.json +3 -3
- package/r/placed-agent.json +4 -4
- package/r/provider-setup.json +1 -1
- package/r/registry.json +1211 -1381
- package/r/shared-chat.json +6 -6
- package/r/spaces.json +44 -0
- package/r/task-list-viewer.json +2 -2
- 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/dist/task-board.d.ts +0 -96
- package/dist/task-board.js +0 -48
- package/r/feedback-preview.json +0 -20
- package/r/pm-panel.json +0 -122
- package/r/requests-board.json +0 -33
- package/r/task-board.json +0 -41
- package/r/task-mentions.json +0 -25
package/r/task-board.json
DELETED
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
-
"name": "task-board",
|
|
4
|
-
"title": "Task board",
|
|
5
|
-
"description": "Host-owned business requests: board, stacked task/file overlays and compact own-request rows. No agent or task backend required.",
|
|
6
|
-
"dependencies": [
|
|
7
|
-
"@hachej/boring-ui-kit@^0.4.46",
|
|
8
|
-
"react@^19.0.0",
|
|
9
|
-
"lucide-react@^1.51.0"
|
|
10
|
-
],
|
|
11
|
-
"registryDependencies": [
|
|
12
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.46/r/utils.json"
|
|
13
|
-
],
|
|
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/** Hosts author concise display titles; a legacy need is never shortened by cutting its sentence. */\nexport function compactTaskTitle(task: TaskRecord): string {\n return (task.display?.trim() || task.title.trim()).replace(/\\s+/gu, ' ');\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\nexport const TASK_COLUMNS = ['needs-you', 'blocked', 'progress', 'live'] as const;\nexport type TaskColumn = typeof TASK_COLUMNS[number] | 'finished' | 'technical';\nconst RECENT_MS = 7 * 24 * 3_600_000;\n\n/** Who acts next decides the column; shipped work stays visible for a week, then folds with everything finished. */\nexport function taskColumn(task: TaskRecord, now = Date.now()): TaskColumn {\n if (task.technical === true) return 'technical';\n const status = compactTaskStatus(task);\n if (status === 'closed') return 'finished';\n if (status === 'live') return now - (Date.parse(task.updated_at ?? '') || now) > RECENT_MS ? 'finished' : 'live';\n if (status === 'needs-you') return 'needs-you';\n return status === 'blocked' ? 'blocked' : 'progress';\n}\n\nexport const columnLabels = {\n en: { 'needs-you': 'Needs you', blocked: 'Blocked', progress: 'In progress', live: 'Recently live', finished: 'Finished', technical: 'Technical' },\n fr: { 'needs-you': 'Attend votre avis', blocked: 'Bloquées', progress: 'En cours', live: 'En ligne récemment', finished: 'Terminées', technical: 'Technique' },\n} as const;\n\n/** What the waiting is about, when the host shared it: the viewer's open question first, then the projected one-line reason. */\nexport function taskWaitingText(task: TaskRecord): string | undefined {\n const open = task.waiting_on_you === true ? task.questions?.find(question => question.answer === undefined)?.text : undefined;\n return open ?? (task.state === 'waiting' ? task.waiting_on?.question?.trim() || undefined : undefined);\n}\n\n/** A card's second line: what is wanted or why it is stuck, else one plain status. */\nexport function taskCardLine(task: TaskRecord, locale: 'en' | 'fr' = 'en'): string {\n const status = compactTaskStatus(task), strings = compactLabels[locale];\n const label = status === 'closed' && (task.status === 'withdrawn' || task.status === 'abandoned') ? strings.withdrawn : strings[status];\n const waiting = status === 'needs-you' || status === 'blocked' ? taskWaitingText(task) : undefined;\n return !waiting ? label : status === 'needs-you' ? waiting : `${label}${locale === 'fr' ? ' : ' : ': '}${waiting}`;\n}\n\n/** The host's age when it formats one, else a short age from the last change. */\nexport function taskAge(task: TaskRecord, now = Date.now()): string | undefined {\n if (task.age) return task.age;\n const at = Date.parse(task.updated_at ?? '');\n if (!Number.isFinite(at)) return undefined;\n const minutes = Math.max(0, Math.round((now - at) / 60_000));\n return minutes < 60 ? `${minutes}m` : minutes < 1440 ? `${Math.round(minutes / 60)}h` : `${Math.round(minutes / 1440)}d`;\n}\n",
|
|
18
|
-
"type": "registry:lib",
|
|
19
|
-
"target": "components/task-board/compact-presentation.ts"
|
|
20
|
-
},
|
|
21
|
-
{
|
|
22
|
-
"path": "registry/task-board/task-board.tsx",
|
|
23
|
-
"content": "'use client';\n\nimport { useEffect, useRef, useState, useSyncExternalStore } from 'react';\nimport type { MouseEvent, ReactNode } from 'react';\nimport { selectTasks, taskFromUrl, taskReference, taskUrl } from '@hachej/boring-ui-kit/task-board';\nimport type { TaskFile, TaskFilter, TaskRecord } 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, 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 { columnLabels, compactLabels, TASK_COLUMNS, taskColumn } from './compact-presentation';\nimport type { TaskColumn } 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 FOLDED = ['finished', 'technical'] as const;\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 title?: string;\n /** @deprecated Ignored: columns follow who acts next and `locale`. */\n readonly labels?: unknown;\n /** The tasks bar (host content, app filter, 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 in the detail, after the question and the request. */\n readonly actions?: ((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 one task board: columns by who acts next (needs you, blocked, in progress, recently live), finished and technical folded. The\n * panel's own width decides the layout, so a narrow pane or phone gets the same sections stacked. The detail opens 400px wide over the\n * board, whole-pane when narrower; a file opens 760px wide over the detail.\n */\nexport function TaskBoard({ tasks, filter, onFilterChange, currentUser, limit, title = 'Tasks', bar = true, barStart, locale = 'en', viewerAction,\n selectedTask, onSelectedTaskChange, onOpen, actions, 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 [unfolded, setUnfolded] = useState<ReadonlySet<TaskColumn>>(() => new Set());\n const [app, setApp] = useState<string | null>(null);\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 column: TaskColumn | undefined } | null>(null);\n const detailColumn = detail && taskColumn(detail);\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 const unfold = (column: TaskColumn, open: boolean) => setUnfolded(current => { const next = new Set(current); if (open) next.add(column); else next.delete(column); return next; });\n useEffect(() => {\n if (selected !== null) back.current?.focus();\n else if (previous.current !== null) {\n const { id, column } = previous.current, link = cardLink(id);\n if (link) link.focus();\n else if (column) { pendingFocus.current = id; unfold(column, true); }\n }\n previous.current = selected === null ? null : { id: detail?.id ?? selected, column: detailColumn };\n }, [selected, detail?.id, detailColumn]);\n useEffect(() => { if (pendingFocus.current !== null) { cardLink(pendingFocus.current)?.focus(); pendingFocus.current = null; } }, [unfolded]);\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} selected={task.id === detail?.id} href={href(task)} onOpen={open}\n locale={locale} viewerAction={viewerAction} />;\n const apps = [...new Set(shown.flatMap(task => task.app ? [task.app] : []))].sort();\n const listed = app === null ? shown : shown.filter(task => task.app === app);\n const words = columnLabels[locale];\n const of = (column: TaskColumn) => listed.filter(task => taskColumn(task) === column).sort((a, b) => (Date.parse(b.updated_at ?? '') || 0) - (Date.parse(a.updated_at ?? '') || 0));\n const appChip = (value: string | null, text: string) => <Badge key={text} data-task-app-filter={text} variant={app === value ? 'default' : 'secondary'} render={<a href=\"#\" aria-current={app === value ? 'true' : undefined} onClick={(event: MouseEvent) => { event.preventDefault(); setApp(value); }} />}>{text}</Badge>;\n const chooseFilter = (next: TaskFilter) => (event: MouseEvent) => { event.preventDefault(); if (filter === undefined) setOwnFilter(next); onFilterChange?.(next); };\n return <section ref={root} data-boring=\"task-board\" 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 && (barStart || apps.length > 1 || currentUser !== undefined) && <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 {apps.length > 1 && <span className={cn('flex min-w-0 items-center gap-1.5 overflow-x-auto [scrollbar-width:none] *:shrink-0', taskTokens.pill)} data-task-apps=\"\">{appChip(null, locale === 'fr' ? 'Toutes' : 'All apps')}{apps.map(name => appChip(name, name))}</span>}\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 {!!listed.length && <div className=\"grid min-w-0 content-start gap-2.5 p-3.5 @3xl/task-rails:grid-cols-4\">\n {TASK_COLUMNS.map(column => {\n const rows = of(column);\n // Stacked, an empty section is noise; side by side, a column keeps its place so the board does not shift.\n return <section key={column} data-task-column={column} aria-label={words[column]} className={cn('min-w-0 flex-col gap-[7px] self-start bg-muted p-2', rows.length ? 'flex' : 'hidden @3xl/task-rails:flex', taskTokens.column)}>\n <h3 className=\"flex min-h-6 items-center gap-1.5 px-0.5 pb-0.5 text-xs font-semibold text-muted-foreground\">{words[column]}<span className=\"font-normal\">{rows.length}</span></h3>\n <div role=\"list\" className=\"flex flex-col gap-[7px]\">{rows.map(card)}</div>\n </section>;\n })}\n </div>}\n {!listed.length && <Empty><EmptyHeader><EmptyTitle>{compactLabels[locale].empty}</EmptyTitle><EmptyDescription>{compactLabels[locale].emptyDescription}</EmptyDescription></EmptyHeader></Empty>}\n {FOLDED.map(column => {\n const rows = of(column);\n return rows.length > 0 && <div key={column} className=\"px-3.5 pb-2\"><Collapsible data-task-group={column} open={unfolded.has(column)} onOpenChange={open => unfold(column, open)}>\n <CollapsibleTrigger render={<Button variant=\"ghost\" size=\"sm\" />}>{words[column]} · {rows.length}</CollapsibleTrigger>\n <CollapsibleContent><div role=\"list\" aria-label={words[column]} className=\"grid gap-[7px] pt-1.5 @3xl/task-rails:grid-cols-4\">{rows.map(card)}</div></CollapsibleContent>\n </Collapsible></div>;\n })}\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\">{[detail.app, taskMeta(detail)].filter(Boolean).join(' · ')}</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} locale={locale} 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",
|
|
24
|
-
"type": "registry:component",
|
|
25
|
-
"target": "components/task-board/task-board.tsx"
|
|
26
|
-
},
|
|
27
|
-
{
|
|
28
|
-
"path": "registry/task-board/task-request-card.tsx",
|
|
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, taskAge, taskCardLine } 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 needsYou = compactTaskStatus(task) === 'needs-you';\n const facts = [task.app, taskAge(task)].filter(Boolean).join(' · ');\n return <div role=\"listitem\" className={cn('rounded-[var(--radius-lg)] bg-card', taskTokens.card)}>\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 data-task-title=\"\" className=\"line-clamp-2 min-w-0 text-sm font-medium break-words text-foreground\">{compactTaskTitle(task)}</span>\n <span data-task-line=\"\" className={cn('line-clamp-2 min-w-0 text-[13px] break-words', needsYou ? 'text-foreground' : 'text-muted-foreground')}>{taskCardLine(task, locale)}</span>\n {facts && <span data-task-facts=\"\" className=\"text-[11px] text-muted-foreground\">{facts}</span>}\n </Item>\n </div>;\n}\n",
|
|
30
|
-
"type": "registry:component",
|
|
31
|
-
"target": "components/task-board/task-request-card.tsx"
|
|
32
|
-
},
|
|
33
|
-
{
|
|
34
|
-
"path": "registry/task-board/task-detail.tsx",
|
|
35
|
-
"content": "'use client';\n\nimport { useState } from 'react';\nimport type { ReactNode } from 'react';\nimport { AppWindowIcon, ChevronRightIcon, FileIcon, ImageIcon } from 'lucide-react';\nimport type { TaskFile, TaskQuestion, TaskRecord } from '@hachej/boring-ui-kit/task-board';\nimport { Button } from '@/components/ui/button';\nimport { Input } from '@/components/ui/input';\nimport { Item } from '@/components/ui/item';\nimport { TaskAttention, taskTokens } from './task-request-card';\nimport type { TaskLocale } from './task-request-card';\nimport { compactLabels, compactTaskStatus, compactTaskTitle, taskWaitingText } from './compact-presentation';\n\nexport interface TaskDetailProps {\n readonly task: TaskRecord;\n readonly locale?: TaskLocale | undefined;\n /** Host content after the question and the request (for example a mount report or host-authorized request actions). */\n readonly actions?: ReactNode;\n /** The host reauthorizes and resolves the original question. Rendering never supplies approval. */\n readonly onAnswer?: ((task: TaskRecord, question: TaskQuestion, value: string) => void) | undefined;\n /** « Other… » opens an inline field; Send passes the typed text. No synthetic option value or approval is generated. */\n readonly onOther?: ((task: TaskRecord, question: TaskQuestion, text: string) => void) | undefined;\n /** « Discuss in chat »: the host stages `@ D-201 · <title>` and the question in its one composer, without sending. */\n readonly onDiscussQuestion?: ((task: TaskRecord, question: TaskQuestion) => void) | undefined;\n readonly onOpenFile?: ((task: TaskRecord, file: TaskFile) => void) | undefined;\n /** « Ask in the conversation »: the host focuses its composer with the task mention. */\n readonly onAsk?: ((task: TaskRecord) => void) | undefined;\n /** @deprecated Ignored; kept for source compatibility. */\n readonly labels?: unknown;\n}\nconst detailWords = {\n en: { question: 'QUESTION FOR YOU', waiting: 'WAITING ON YOU', elsewhere: 'Reply in the conversation.', request: 'THE REQUEST', yourAnswer: 'Your answer', send: 'Send', other: 'Other…', discuss: 'Discuss in chat', ask: 'Ask in the conversation' },\n fr: { question: 'QUESTION POUR VOUS', waiting: 'ATTEND VOTRE AVIS', elsewhere: 'Répondez dans la conversation.', request: 'LA DEMANDE', yourAnswer: 'Votre réponse', send: 'Envoyer', other: 'Autre…', discuss: 'En parler dans la conversation', ask: 'Demander dans la conversation' },\n} as const;\nconst preview = (value: string) => value.length > 160 ? `${value.slice(0, 160)}…` : value;\nconst sectionLabel = 'text-[11px] font-semibold tracking-[0.06em] text-muted-foreground';\nconst kinds = { markdown: ['Markdown', FileIcon], mockup: ['Mockup', AppWindowIcon], capture: ['Capture', ImageIcon] } as const;\nconst fileKind = (file: TaskFile) => file.kind ?? (/\\.(?:md|markdown)$/i.test(file.path) ? 'markdown' : /\\.html?$/i.test(file.path) ? 'mockup' : /\\.(?:png|jpe?g|gif|webp|svg)$/i.test(file.path) ? 'capture' : undefined);\n/** ISO times show as local HH:MM; host-formatted strings show as given. */\nconst clock = (value: string) => /^\\d{4}-\\d\\d-\\d\\dT/.test(value) && Number.isFinite(Date.parse(value)) ? new Date(value).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false }) : value;\n\nfunction AnsweredQuestion({ question }: { readonly question: TaskQuestion }) {\n const [expanded, setExpanded] = useState(false);\n return <li data-task-answered={question.id} className=\"min-w-0\">\n <Button variant=\"ghost\" className=\"grid h-auto w-full min-w-0 grid-cols-[minmax(0,1fr)_auto] justify-normal gap-x-2 gap-y-0.5 text-start whitespace-normal\" aria-expanded={expanded} onClick={() => setExpanded(value => !value)}>\n <span className=\"truncate text-[13px] font-normal\">{preview(question.text)}</span>\n <ChevronRightIcon aria-hidden=\"true\" className=\"text-muted-foreground\" />\n <span data-task-answer={question.id} className=\"col-span-full truncate text-[13px] font-normal text-muted-foreground\">{question.answeredBy ? `${preview(question.answeredBy)}: ` : ''}{preview(question.answer ?? '')}</span>\n </Button>\n {expanded && <div data-task-answer-full className=\"flex flex-col gap-1 px-2 pb-2 text-[13px] break-words whitespace-pre-wrap\"><p>{question.text}</p><p className=\"text-muted-foreground\">{question.answeredBy && <>{question.answeredBy}: </>}{question.answer}</p></div>}\n </li>;\n}\n\nfunction OpenQuestion({ task, question, locale = 'en', onAnswer, onOther, onDiscussQuestion }: Pick<TaskDetailProps, 'task' | 'locale' | 'onAnswer' | 'onOther' | 'onDiscussQuestion'> & { readonly question: TaskQuestion }) {\n // Without options typing is the only way to answer, so the field is simply there.\n const typed = !question.options?.length;\n const [other, setOther] = useState<string | null>(typed && onOther ? '' : null);\n const words = detailWords[locale];\n const send = () => { const text = other?.trim(); if (text) { onOther?.(task, question, text); setOther(typed ? '' : null); } };\n const asked = [question.askedBy, question.askedAt && clock(question.askedAt)].filter(Boolean).join(' · ');\n return <TaskAttention active>\n <Item variant=\"outline\" className=\"flex-col items-stretch gap-2\" data-task-question={question.id}>\n <div className=\"flex items-baseline justify-between gap-2\"><span className={sectionLabel}>{words.question}</span>{asked && <span className=\"text-[11px] text-muted-foreground\">{asked}</span>}</div>\n <p className=\"text-sm\">{question.text}</p>\n <div className={`flex items-center gap-2 ${taskTokens.control}`}>\n {question.options?.map((option, index) => {\n const value = typeof option === 'string' ? option : option.value;\n const label = typeof option === 'string' ? option : option.label;\n return <Button key={index} variant={index === 0 ? 'default' : 'outline'} disabled={!onAnswer} onClick={() => onAnswer?.(task, question, value)}>{label}</Button>;\n })}\n {onOther && !typed && <Button variant=\"outline\" data-task-other={question.id} aria-expanded={other !== null} onClick={() => setOther(value => value === null ? '' : null)}>{words.other}</Button>}\n {onDiscussQuestion && <Button variant=\"link\" className=\"h-auto min-w-0 shrink text-center leading-tight whitespace-normal underline\" data-task-discuss={question.id} onClick={() => onDiscussQuestion(task, question)}>{words.discuss}</Button>}\n </div>\n {other !== null && <form className={`flex gap-2 ${taskTokens.control}`} data-task-other-form={question.id} onSubmit={event => { event.preventDefault(); send(); }}>\n <Input autoFocus={!typed} aria-label={words.yourAnswer} maxLength={4096} value={other} onChange={event => setOther(event.target.value)} onKeyDown={event => { if (event.key === 'Escape' && !typed) { event.preventDefault(); setOther(null); } }} />\n <Button type=\"submit\" disabled={!other.trim()}>{words.send}</Button>\n </form>}\n </Item>\n </TaskAttention>;\n}\n\n/** Task detail (spec 3 §4), in order: title, the open question for the viewer, Answered, Files, « Ask in the conversation ». */\nexport function TaskDetail({ task, locale = 'en', actions, onAnswer, onOther, onDiscussQuestion, onOpenFile, onAsk }: TaskDetailProps) {\n const [showAll, setShowAll] = useState(false);\n const open = task.waiting_on_you === true ? task.questions?.find(question => question.answer === undefined) : undefined;\n const words = detailWords[locale];\n const status = compactTaskStatus(task);\n // Nothing openable may say it needs someone without saying what: the projected reason shows when there is no full question.\n const reason = !open && (status === 'needs-you' || status === 'blocked') ? taskWaitingText(task) : undefined;\n const answered = task.questions?.filter(question => question.answer !== undefined).toReversed().sort((a, b) => (Date.parse(b.answeredAt ?? '') || 0) - (Date.parse(a.answeredAt ?? '') || 0)) ?? [];\n const meta = [task.screen && `Screen: ${task.screen}`, task.requester && `asked by ${task.requester}`, task.refiner && `refined by ${task.refiner}`].filter(Boolean).join(' · ');\n const folder = task.folder?.replace(/\\/?$/, '/');\n return <div className=\"flex min-w-0 flex-col gap-2.5\" data-task-detail={task.id}>\n <div className=\"rounded-[var(--radius-lg)] bg-card\"><Item variant=\"outline\" className=\"flex-col items-stretch gap-1\" data-task-title-card=\"\">\n <h2 className=\"text-base font-semibold break-words\">{compactTaskTitle(task)}</h2>\n {compactTaskTitle(task) !== task.title.trim() && <p className=\"text-[13px] break-words text-muted-foreground\">{task.title}</p>}\n {meta && <p className=\"text-xs text-muted-foreground\">{meta}</p>}\n </Item></div>\n {open && <div className=\"rounded-[var(--radius-lg)] bg-card\"><OpenQuestion key={open.id} task={task} question={open} locale={locale} onAnswer={onAnswer} onOther={onOther} onDiscussQuestion={onDiscussQuestion} /></div>}\n {(reason || status === 'needs-you' && !open) && <div className=\"rounded-[var(--radius-lg)] bg-card\" data-task-reason=\"\"><TaskAttention active={status === 'needs-you'}><Item variant=\"outline\" className=\"flex-col items-stretch gap-1\">\n <span className={sectionLabel}>{status === 'needs-you' ? words.waiting : compactLabels[locale].blocked}</span>\n {reason && <p className=\"text-sm break-words\">{reason}</p>}\n {status === 'needs-you' && <p className=\"text-[13px] text-muted-foreground\">{words.elsewhere}</p>}\n </Item></TaskAttention></div>}\n {task.spec && task.spec.trim() !== task.title.trim() && <section aria-label={words.request} className=\"rounded-[var(--radius-lg)] bg-card\" data-task-spec=\"\"><Item variant=\"outline\" className=\"flex-col items-stretch gap-1\">\n <h3 className={sectionLabel}>{words.request}</h3>\n <p className=\"text-sm break-words whitespace-pre-wrap\">{task.spec}</p>\n </Item></section>}\n {actions}\n {!!answered.length && <section aria-label=\"Answered questions\" className=\"rounded-[var(--radius-lg)] bg-card\"><Item variant=\"outline\" className=\"flex-col items-stretch gap-0\">\n <div className=\"flex items-baseline justify-between gap-2 pl-2.5\"><h3 className={sectionLabel}>ANSWERED · {answered.length}</h3>\n {answered.length > 3 && <Button variant=\"link\" size=\"xs\" className=\"underline\" data-task-show-answers aria-expanded={showAll} onClick={() => setShowAll(value => !value)}>{showAll ? 'Show latest' : 'Show all'}</Button>}</div>\n <ul className=\"flex min-w-0 flex-col\">{(showAll ? answered : answered.slice(0, 3)).map(question => <AnsweredQuestion key={question.id} question={question} />)}</ul>\n </Item></section>}\n {!!task.files?.length && <section aria-label=\"Task files\" className=\"rounded-[var(--radius-lg)] bg-card\"><Item variant=\"outline\" className=\"flex-col items-stretch gap-0.5\">\n <h3 className={`${sectionLabel} px-2.5 pb-1`}>FILES{folder && ` · ${folder}`}</h3>\n {task.files.map(file => {\n const kind = fileKind(file), [label, Icon] = kind ? kinds[kind] : ['', FileIcon];\n const name = file.name ?? (folder && file.path.startsWith(folder) ? file.path.slice(folder.length) : file.path.split('/').pop());\n const content = <><Icon aria-hidden=\"true\" /><span className=\"min-w-0 flex-1 truncate text-start text-[13px] font-normal\">{name}</span>{label && <span className=\"text-[11px] font-normal text-muted-foreground\">{label}</span>}</>;\n return onOpenFile ? <Button key={file.path} variant=\"ghost\" className=\"w-full justify-start\" data-task-file={file.path} onClick={() => onOpenFile(task, file)}>{content}</Button> : <p key={file.path} className=\"flex items-center gap-2\">{content}</p>;\n })}\n </Item></section>}\n {!!task.history?.length && <section aria-label=\"Task history\" className=\"rounded-[var(--radius-lg)] bg-card\" data-task-history=\"\"><Item variant=\"outline\" className=\"flex-col items-stretch gap-1\">\n <h3 className={sectionLabel}>HISTORY</h3>\n {task.history.map((item, index) => <p key={item.id ?? index} className=\"text-[13px] break-words text-muted-foreground\">{item.note}</p>)}\n </Item></section>}\n {onAsk && <Button variant=\"link\" className=\"self-start underline\" data-task-ask={task.id} onClick={() => onAsk(task)}>{words.ask}</Button>}\n </div>;\n}\n",
|
|
36
|
-
"type": "registry:component",
|
|
37
|
-
"target": "components/task-board/task-detail.tsx"
|
|
38
|
-
}
|
|
39
|
-
],
|
|
40
|
-
"type": "registry:block"
|
|
41
|
-
}
|
package/r/task-mentions.json
DELETED
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
-
"name": "task-mentions",
|
|
4
|
-
"title": "Task mentions",
|
|
5
|
-
"description": "Optional authorized task catalog for the existing PM composer, composed with host file search.",
|
|
6
|
-
"dependencies": [
|
|
7
|
-
"@hachej/boring-ui-kit@^0.4.46",
|
|
8
|
-
"react@^19.0.0",
|
|
9
|
-
"lucide-react@^1.51.0"
|
|
10
|
-
],
|
|
11
|
-
"registryDependencies": [
|
|
12
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.46/r/task-board.json",
|
|
13
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.46/r/pi-chat.json",
|
|
14
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.46/r/pi-chat-mentions.json"
|
|
15
|
-
],
|
|
16
|
-
"files": [
|
|
17
|
-
{
|
|
18
|
-
"path": "registry/task-mentions/task-mentions.tsx",
|
|
19
|
-
"content": "import { ListTodoIcon } from 'lucide-react';\nimport { taskReference } from '@hachej/boring-ui-kit/task-board';\nimport type { TaskRecord } from '@hachej/boring-ui-kit/task-board';\nimport type { MentionsConfig } from '../pi-chat/config';\nimport { MentionPicker } from '../pi-chat-mentions/mention-menu';\n\n/** Authorized tasks only. Composes, never replaces, the host's file search and other mention kinds. */\nexport function taskMentions({ tasks, onOpen, base }: {\n readonly tasks: readonly TaskRecord[];\n readonly onOpen: (task: TaskRecord) => void;\n readonly base?: MentionsConfig;\n}): MentionsConfig {\n const find = (id: string) => tasks.find(task => task.id === id || taskReference(task) === id);\n const label = (id: string) => { const task = find(id); return task ? `@ ${taskReference(task)} · ${task.title}` : id; };\n return {\n ...base,\n Picker: base?.Picker ?? MentionPicker,\n listLabel: base?.listLabel ?? 'Tasks and files',\n kinds: { ...base?.kinds, task: { label, icon: <ListTodoIcon aria-hidden=\"true\" />, open: id => { const task = find(id); if (task) onOpen(task); } } },\n search: async (query, signal) => {\n const needle = query.toLocaleLowerCase();\n const matches = tasks.filter(task => `${taskReference(task)} ${task.title}`.toLocaleLowerCase().includes(needle))\n .sort((a, b) => Number(b.waiting_on_you === true) - Number(a.waiting_on_you === true))\n .map(task => ({ path: `task:${task.id}`, label: `${taskReference(task)} · ${task.title}${task.waiting_on_you ? ' · Waiting on you' : ''}` }));\n const files = await base?.search(query, signal) ?? [];\n return signal.aborted ? [] : [...matches, ...files];\n },\n };\n}\n",
|
|
20
|
-
"type": "registry:component",
|
|
21
|
-
"target": "components/task-mentions/task-mentions.tsx"
|
|
22
|
-
}
|
|
23
|
-
],
|
|
24
|
-
"type": "registry:component"
|
|
25
|
-
}
|