@hachej/boring-ui-kit 0.4.10 → 0.4.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/shared-chat/socket.js +3 -1
- package/dist/shared-chat/source.d.ts +7 -1
- package/package.json +2 -2
- package/r/agent-target.json +4 -4
- package/r/app-shell.json +10 -8
- package/r/board-viewer.json +6 -6
- package/r/button.json +1 -1
- package/r/diagram-viewer.json +2 -2
- package/r/dock.json +1 -1
- package/r/documents.json +11 -11
- package/r/feedback-preview.json +1 -1
- package/r/feedback.json +7 -6
- package/r/file-tree.json +5 -5
- package/r/highlights.json +3 -3
- package/r/html-viewer.json +3 -3
- package/r/looks-viewer.json +32 -0
- package/r/maquette-viewer.json +3 -3
- package/r/markdown-editor.json +3 -3
- package/r/mockup-viewer.json +3 -3
- package/r/pi-ambient.json +11 -8
- package/r/pi-app.json +18 -16
- package/r/pi-artifacts.json +32 -0
- package/r/pi-chat-approval.json +26 -0
- package/r/pi-chat-attachments.json +25 -0
- package/r/pi-chat-markdown.json +26 -0
- package/r/pi-chat-mentions.json +23 -0
- package/r/pi-chat-pickers.json +24 -0
- package/r/pi-chat-plugins.json +31 -0
- package/r/pi-chat-question.json +29 -0
- package/r/pi-chat-slash.json +26 -0
- package/r/pi-chat-subagent.json +34 -0
- package/r/pi-chat-voice.json +39 -0
- package/r/pi-chat.json +15 -97
- package/r/pi-workspace.json +4 -4
- package/r/placed-agent.json +4 -4
- package/r/provider-setup.json +2 -2
- package/r/record-viewer.json +1 -1
- package/r/registry.json +438 -177
- package/r/requests-board.json +1 -1
- package/r/shared-chat.json +13 -9
- package/r/task-list-viewer.json +2 -2
- package/r/version-history.json +3 -3
- package/r/viewers.json +7 -7
- package/r/windows.json +1 -1
package/r/maquette-viewer.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.12/r/theme.json",
|
|
12
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.12/r/diagram-viewer.json",
|
|
13
13
|
"alert",
|
|
14
14
|
"badge",
|
|
15
15
|
"bubble",
|
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
},
|
|
50
50
|
{
|
|
51
51
|
"path": "registry/maquette-viewer/maquette-viewer.tsx",
|
|
52
|
-
"content": "'use client';\n\n// The maquette viewer: a PM agent's `*.maquette.json` mock-up, one screen at a time. The screen is the content; the chrome is one thin bar\n// of icons: Back, ‹ 2 / 5 › (also arrow keys and swipe) and a menu holding the guided tours and \"How it works\" (the workflow, in a dialog).\n// During a tour the bar shows the step dots and ×, with the step's caption under it. `maquetteKind` is the kind the pi-app artifact panel\n// uses by default for `.maquette.json` files; an app's own kind in `artifactKinds` wins. It draws data only: nothing in the file runs.\n// The workflow is drawn by the diagram-viewer item's `DiagramView`, inline or from a linked `*.diagram.json`. With `comments` (the feedback\n// item's `usePointComments`, structurally) one more icon enters point mode on the screen: a tap on a block pins a note that names the\n// maquette path, the screen id and the block id; the pins of the current screen show as small numbers on their blocks.\nimport { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react';\nimport type { ComponentType, KeyboardEvent, ReactNode, TouchEvent } from 'react';\nimport { ArrowLeftIcon, ChevronLeftIcon, ChevronRightIcon, CompassIcon, EllipsisIcon, InfoIcon, LayoutTemplateIcon, MessageSquarePlusIcon, TriangleAlertIcon, WorkflowIcon, XIcon } from 'lucide-react';\nimport { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';\nimport { Button } from '@/components/ui/button';\nimport { Card, CardContent } from '@/components/ui/card';\nimport { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';\nimport { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';\nimport { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';\nimport { Toggle } from '@/components/ui/toggle';\nimport { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';\nimport { parseDiagram } from '../diagram-viewer/diagram';\nimport type { Diagram } from '../diagram-viewer/diagram';\nimport { DiagramView } from '../diagram-viewer/diagram-view';\nimport { MAQUETTE_FORMAT_VERSION, diagramPathOf, isMaquettePath, parseMaquette, screenFromFragment } from './maquette';\nimport type { Maquette, MaquetteBlock, MaquetteCase, MaquetteScreen } from './maquette';\nimport { maquetteLabelsFor } from './maquette-labels';\nimport type { MaquetteLabels, MaquetteLocale } from './maquette-labels';\nimport { MaquetteScreenView } from './maquette-blocks';\nimport type { MaquetteLinks } from './maquette-blocks';\n\nexport { MAQUETTE_FORMAT_VERSION, diagramPathOf, isMaquettePath, linksOf, parseMaquette, screenFromFragment } from './maquette';\nexport type { Maquette, MaquetteBlock, MaquetteBlockV1, MaquetteCase, MaquetteFileV1, MaquetteParse, MaquetteScreen, MaquetteState, MaquetteTransition, MaquetteWorkflow } from './maquette';\nexport { defaultMaquetteLabels, frenchMaquetteLabels, maquetteLabelsFor } from './maquette-labels';\nexport type { MaquetteLabels, MaquetteLocale } from './maquette-labels';\n\n/**\n * Comment pins, structurally the feedback item's `usePointComments(session)`: pass it and the bar shows a comment icon; omit it (no feedback\n * session in the host) and nothing shows.\n */\nexport interface MaquetteComments {\n /** Point mode is on. */\n readonly active: boolean;\n /** Enters point mode (the person taps blocks to pin notes), or ends it when it is on. */\n readonly point: () => void;\n /** The pins taken: each one's number and the `data-feedback-id` its element sits in. */\n readonly pins: readonly { readonly number: number; readonly target: string }[];\n}\n\n/** Reads another file of the workspace as text (the artifact panel passes one): a linked `*.diagram.json`. Undefined when it cannot. */\nexport type MaquetteRead = (path: string) => Promise<string | undefined>;\n\n/** A block's `data-feedback-id`: `maquette:<path>#screen=<screen id>&block=<block id>`. */\nexport const maquetteTarget = (path: string | undefined, screen: string, block: string): string => `maquette:${path ?? ''}#screen=${screen}&block=${block}`;\n\nexport interface MaquetteViewerProps {\n /** The file's text (JSON). */\n readonly text: string;\n /** The file's path: named by comment pins, and the folder a linked diagram is read from. */\n readonly path?: string | undefined;\n /** Comment pins on blocks (the feedback item's `usePointComments`). */\n readonly comments?: MaquetteComments | undefined;\n /** Reads a linked `*.diagram.json`; without it a linked workflow is not offered. */\n readonly read?: MaquetteRead | undefined;\n /** The page's URL fragment: `screen=<id>` opens that screen (and follows later changes). */\n readonly fragment?: string | undefined;\n /** The base words: English by default. */\n readonly locale?: MaquetteLocale | undefined;\n /** Any word over the locale's. */\n readonly labels?: Partial<MaquetteLabels> | undefined;\n}\n\n/** Where the person is: the screens visited (the last is shown) and the guided tour under way. */\ninterface Walk { readonly history: readonly string[]; readonly tour: { readonly name: string; readonly index: number } | null }\n\n/**\n * A host bar that owns the one \"…\" menu (the pi-app artifact panel) provides this: the viewer then publishes its tours and \"How it works\"\n * there instead of drawing a second \"…\" menu beside the screen counter. Without a provider the viewer keeps its own menu.\n */\nexport interface MaquetteMenuEntry { readonly id: string; readonly label: string; readonly icon: ReactNode; readonly testId: string; readonly separatorBefore: boolean; readonly onSelect: () => void }\nexport const MaquetteMenuContext = createContext<((items: readonly MaquetteMenuEntry[]) => void) | undefined>(undefined);\n\nconst TYPING = 'input, select, textarea, [contenteditable=\"true\"]';\nconst ICON = 'pointer-coarse:min-h-11 pointer-coarse:min-w-11';\n\n/** One dot per tour step, the current one in the primary colour. */\nfunction Dots({ count, index, label }: { readonly count: number; readonly index: number; readonly label: string }) {\n return <svg role=\"img\" aria-label={label} data-testid=\"maquette-dots\" width={count * 12} height=\"8\" viewBox={`0 0 ${count * 12} 8`} className=\"shrink-0\">\n {Array.from({ length: count }, (_, i) => <circle key={i} cx={i * 12 + 6} cy=\"4\" r=\"3\" className={i === index ? 'fill-primary' : 'fill-muted-foreground/30'} />)}\n </svg>;\n}\n\n/** The diagram of a linked `*.diagram.json`, read when the dialog opens: undefined while reading, null when it cannot be read. */\nfunction useLinked(read: MaquetteRead | undefined, path: string | undefined, open: boolean): Diagram | null | undefined {\n const [loaded, setLoaded] = useState<{ readonly path: string; readonly diagram: Diagram | null }>();\n useEffect(() => {\n if (!open || !path || !read || loaded?.path === path) return undefined;\n let off = false;\n read(path).then(text => text === undefined ? null : parseDiagram(text), () => null).then(parsed => {\n if (!off) setLoaded({ path, diagram: parsed?.kind === 'ok' ? parsed.diagram : null });\n });\n return () => { off = true; };\n }, [open, path, read, loaded?.path]);\n return loaded && loaded.path === path ? loaded.diagram : undefined;\n}\n\n/** The workflow diagram, its legend and the cases' `path` highlights, in a dialog opened from the menu. */\nfunction HowItWorks({ maquette, source, labels, open, onOpenChange }: {\n readonly maquette: Maquette; readonly source: DiagramSource; readonly labels: MaquetteLabels; readonly open: boolean; readonly onOpenChange: (open: boolean) => void;\n}) {\n const [shown, setShown] = useState<string | null>(null);\n const loaded = useLinked(source.read, source.path, open);\n const diagram = maquette.diagram ?? loaded;\n const highlighted = maquette.cases.filter(item => item.path.length > 0);\n if (!maquette.diagram && !source.path) return null;\n return <Dialog open={open} onOpenChange={onOpenChange}>\n <DialogContent data-testid=\"maquette-how\" className=\"max-h-[85dvh] overflow-y-auto\">\n <DialogHeader><DialogTitle>{labels.howItWorks}</DialogTitle></DialogHeader>\n {diagram ? <DiagramView diagram={diagram} highlight={highlighted.find(item => item.name === shown)?.path ?? diagram.path} labels={labels} />\n : diagram === null ? <Alert variant=\"destructive\" data-testid=\"maquette-how-error\"><TriangleAlertIcon aria-hidden=\"true\" /><AlertTitle>{labels.diagramUnavailable}</AlertTitle></Alert>\n : <p role=\"status\" className=\"m-0 text-sm text-muted-foreground\">{labels.loading}</p>}\n {highlighted.length > 0 && <div role=\"group\" className=\"flex flex-wrap gap-2\">\n {highlighted.map(item => <Button key={item.name} type=\"button\" size=\"sm\" variant={shown === item.name ? 'default' : 'outline'} aria-pressed={shown === item.name}\n data-testid=\"maquette-case\" className=\"pointer-coarse:min-h-11\" onClick={() => setShown(current => current === item.name ? null : item.name)}>{item.name}</Button>)}\n </div>}\n </DialogContent>\n </Dialog>;\n}\n\n/** Where a linked diagram is read from: its resolved path and the reader (both absent for an inline workflow, or with no reader). */\ninterface DiagramSource { readonly path: string | undefined; readonly read: MaquetteRead | undefined }\n\nfunction Walkthrough({ maquette, labels, fragment, path, comments, read }: {\n readonly maquette: Maquette; readonly labels: MaquetteLabels; readonly fragment: string | undefined;\n readonly path: string | undefined; readonly comments: MaquetteComments | undefined; readonly read: MaquetteRead | undefined;\n}) {\n const exists = (id: string | undefined): id is string => !!id && maquette.screens.some(screen => screen.id === id);\n const linked = screenFromFragment(fragment);\n const [walk, setWalk] = useState<Walk>(() => ({ history: [exists(linked) ? linked : maquette.start], tour: null }));\n const [how, setHow] = useState(false);\n const seen = useRef(new Set<string>());\n const heading = useRef<HTMLHeadingElement>(null);\n const moved = useRef(false);\n const touch = useRef<{ x: number; y: number } | null>(null);\n const screens = maquette.screens;\n const current: MaquetteScreen = screens.find(screen => screen.id === walk.history.at(-1)) ?? screens[0]!;\n const position = screens.indexOf(current);\n const tour: MaquetteCase | undefined = walk.tour ? maquette.cases.find(item => item.name === walk.tour!.name) : undefined;\n const tours = useMemo(() => maquette.cases.filter(item => item.steps.length > 0), [maquette]);\n const titleOf = (id: string) => screens.find(screen => screen.id === id)?.title ?? id;\n // Goto blocks pulse once, the first time their screen is shown.\n // Decided once per shown screen, so a re-render of the same screen (the host menu updating) does not end the pulse.\n const shown = useRef<{ readonly id: string; readonly first: boolean } | undefined>(undefined);\n const view = shown.current?.id === current.id ? shown.current : { id: current.id, first: !seen.current.has(current.id) };\n shown.current = view;\n const firstView = view.first;\n useEffect(() => { seen.current.add(current.id); }, [current.id]);\n\n const move = (next: Walk) => { moved.current = true; setWalk(next); };\n const go = (id: string) => { if (exists(id) && id !== current.id) move({ history: [...walk.history, id], tour: null }); };\n const step = (delta: number) => {\n if (tour && walk.tour) {\n const index = walk.tour.index + delta, target = tour.steps[index];\n if (target) move({ history: [...walk.history, target.screen], tour: { name: tour.name, index } });\n return;\n }\n const target = screens[position + delta];\n if (target) move({ history: [...walk.history, target.id], tour: null });\n };\n const back = () => { if (walk.history.length > 1) move({ history: walk.history.slice(0, -1), tour: null }); };\n const startTour = (item: MaquetteCase) => move({ history: [...walk.history, item.steps[0]!.screen], tour: { name: item.name, index: 0 } });\n // A later `#screen=<id>` (the PM's \"see screen 2\" link) opens that screen.\n useEffect(() => { if (exists(linked) && linked !== walk.history.at(-1)) setWalk(previous => ({ history: [...previous.history, linked], tour: null })); }, [linked]); // eslint-disable-line react-hooks/exhaustive-deps\n // After a move, keyboard focus starts on the new screen, so Tab continues inside the screen just opened.\n useEffect(() => { if (moved.current) { moved.current = false; heading.current?.focus(); } }, [walk]);\n\n const index = tour && walk.tour ? walk.tour.index : position, count = tour ? tour.steps.length : screens.length;\n const target = (block: MaquetteBlock) => maquetteTarget(path, current.id, block.id);\n // While pointing, the session draws its own numbered pins over the page; the small numbers on the blocks are for afterwards.\n const pins = (block: MaquetteBlock) => comments && !comments.active ? comments.pins.filter(pin => pin.target === target(block)).map(pin => pin.number) : [];\n const links: MaquetteLinks = { go, hint: id => labels.opens(titleOf(id)), target, pins };\n const source: DiagramSource = { path: maquette.diagramLink && read ? diagramPathOf(path ?? '', maquette.diagramLink) : undefined, read };\n const workflow = !!maquette.diagram || !!source.path;\n // Inside a host bar with its own \"…\" menu, the tours and \"How it works\" go there (one menu, not two).\n const publish = useContext(MaquetteMenuContext);\n const latestTour = useRef(startTour); latestTour.current = startTour;\n useEffect(() => {\n if (!publish) return undefined;\n const items: MaquetteMenuEntry[] = [\n ...(tour ? [] : tours).map((item, i) => ({ id: `tour-${i}`, label: item.name, icon: <CompassIcon aria-hidden=\"true\" />, testId: 'maquette-tour', separatorBefore: i === 0, onSelect: () => latestTour.current(item) })),\n ...(workflow && !tour ? [{ id: 'how-it-works', label: labels.howItWorks, icon: <WorkflowIcon aria-hidden=\"true\" />, testId: 'maquette-how-open', separatorBefore: true, onSelect: () => setHow(true) }] : []),\n ];\n publish(items);\n return () => publish([]);\n }, [publish, tours, workflow, tour, labels.howItWorks]);\n const onKeyDown = (event: KeyboardEvent) => {\n if (event.defaultPrevented || event.ctrlKey || event.metaKey || event.shiftKey || (event.target as Element).closest?.(TYPING)) return;\n if (event.altKey) { if (event.key === 'ArrowLeft') { event.preventDefault(); back(); } return; }\n if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') { event.preventDefault(); step(event.key === 'ArrowLeft' ? -1 : 1); }\n };\n const onTouchStart = (event: TouchEvent) => { const point = event.touches[0]; touch.current = point ? { x: point.clientX, y: point.clientY } : null; };\n const onTouchEnd = (event: TouchEvent) => {\n const start = touch.current, point = event.changedTouches[0];\n touch.current = null;\n if (!start || !point) return;\n const dx = point.clientX - start.x, dy = point.clientY - start.y;\n if (Math.abs(dx) > 56 && Math.abs(dx) > Math.abs(dy) * 1.5) step(dx < 0 ? 1 : -1);\n };\n const caption = tour && walk.tour ? tour.steps[walk.tour.index]?.caption : undefined;\n\n return <div data-testid=\"maquette-walk\" data-screen={current.id} data-tour={tour?.name} className=\"flex min-w-0 flex-col gap-2\" onKeyDown={onKeyDown}>\n {/* The viewer's own chrome is never pinned, and keeps working while pointing: walk to another screen and pin there too. */}\n <nav aria-label={labels.screens} data-testid=\"maquette-stepper\" data-feedback-ignore=\"\" className=\"flex items-center gap-1\">\n {walk.history.length > 1 && <Button type=\"button\" variant=\"ghost\" size=\"icon-sm\" className={ICON} aria-label={labels.back} data-testid=\"maquette-back\" onClick={back}><ArrowLeftIcon aria-hidden=\"true\" /></Button>}\n <div className=\"flex flex-1 items-center justify-center gap-1\">\n <Button type=\"button\" variant=\"ghost\" size=\"icon-sm\" className={ICON} aria-label={labels.previous} data-testid=\"maquette-previous\" disabled={index <= 0} onClick={() => step(-1)}><ChevronLeftIcon aria-hidden=\"true\" /></Button>\n {tour ? <Dots count={count} index={index} label={labels.tourStep(index + 1, count)} />\n : <span data-testid=\"maquette-position\" className=\"min-w-10 text-center text-sm text-muted-foreground tabular-nums\">{index + 1} / {count}</span>}\n <Button type=\"button\" variant=\"ghost\" size=\"icon-sm\" className={ICON} aria-label={labels.next} data-testid=\"maquette-next\" disabled={index >= count - 1} onClick={() => step(1)}><ChevronRightIcon aria-hidden=\"true\" /></Button>\n </div>\n {comments && <Tooltip><TooltipTrigger render={<Toggle className={ICON} aria-label={labels.comment} pressed={comments.active}\n data-testid=\"maquette-comment\" onPressedChange={() => comments.point()} />}><MessageSquarePlusIcon aria-hidden=\"true\" /></TooltipTrigger><TooltipContent>{labels.comment}</TooltipContent></Tooltip>}\n {tour ? <Button type=\"button\" variant=\"ghost\" size=\"icon-sm\" className={ICON} aria-label={labels.endTour} data-testid=\"maquette-end-tour\" onClick={() => setWalk(previous => ({ ...previous, tour: null }))}><XIcon aria-hidden=\"true\" /></Button>\n : !publish && (tours.length > 0 || workflow) && <DropdownMenu>\n <DropdownMenuTrigger render={<Button type=\"button\" variant=\"ghost\" size=\"icon-sm\" className={ICON} aria-label={labels.more} data-testid=\"maquette-menu\" />}><EllipsisIcon aria-hidden=\"true\" /></DropdownMenuTrigger>\n <DropdownMenuContent align=\"end\" data-testid=\"maquette-menu-list\">\n {tours.map(item => <DropdownMenuItem key={item.name} className=\"pointer-coarse:min-h-11\" data-testid=\"maquette-tour\" onClick={() => startTour(item)}><CompassIcon aria-hidden=\"true\" />{item.name}</DropdownMenuItem>)}\n {tours.length > 0 && workflow && <DropdownMenuSeparator />}\n {workflow && <DropdownMenuItem className=\"pointer-coarse:min-h-11\" data-testid=\"maquette-how-open\" onClick={() => setHow(true)}><WorkflowIcon aria-hidden=\"true\" />{labels.howItWorks}</DropdownMenuItem>}\n </DropdownMenuContent>\n </DropdownMenu>}\n </nav>\n {caption && <p data-testid=\"maquette-caption\" className=\"m-0 line-clamp-2 px-1 text-sm text-pretty text-muted-foreground\">{caption}</p>}\n\n <div className={maquette.device === 'phone' ? 'mx-auto flex w-full max-w-sm flex-col gap-2' : 'flex min-w-0 flex-col gap-2'}>\n <div className=\"min-w-0\" data-hint={firstView ? 'pulse' : undefined} onTouchStart={onTouchStart} onTouchEnd={onTouchEnd}>\n <Card data-testid=\"maquette-frame\" data-device={maquette.device ?? 'panel'}><CardContent>\n <MaquetteScreenView key={current.id} maquette={maquette} screen={current} links={links} labels={labels} headingRef={heading} />\n </CardContent></Card>\n </div>\n {current.note && <div className=\"flex min-w-0 items-center gap-1 px-1\">\n <p data-testid=\"maquette-note\" title={current.note} className=\"m-0 min-w-0 flex-1 truncate text-xs text-muted-foreground\">{current.note}</p>\n <Popover>\n <PopoverTrigger render={<Button type=\"button\" variant=\"ghost\" size=\"icon-xs\" className={ICON} aria-label={labels.aboutScreen} data-testid=\"maquette-note-more\" />}><InfoIcon aria-hidden=\"true\" /></PopoverTrigger>\n <PopoverContent align=\"end\" data-testid=\"maquette-note-full\">{current.note}</PopoverContent>\n </Popover>\n </div>}\n </div>\n <p aria-live=\"polite\" className=\"m-0 sr-only\">{labels.showing(current.title)}</p>\n <HowItWorks maquette={maquette} source={source} labels={labels} open={how} onOpenChange={setHow} />\n </div>;\n}\n\n/** A maquette file drawn one screen at a time; a newer format version shows a notice, an unreadable file an inline error. */\nexport function MaquetteViewer({ text, fragment, locale, labels: given, path, comments, read }: MaquetteViewerProps) {\n const labels = useMemo(() => maquetteLabelsFor(locale, given), [locale, given]);\n const parsed = useMemo(() => parseMaquette(text), [text]);\n const newer = parsed.version !== undefined && parsed.version > MAQUETTE_FORMAT_VERSION;\n const notice = newer && <Alert data-testid=\"maquette-newer\"><InfoIcon aria-hidden=\"true\" /><AlertTitle>{labels.newerTitle}</AlertTitle><AlertDescription>{labels.newer(parsed.version!)}</AlertDescription></Alert>;\n return <section data-boring=\"maquette-viewer\" data-state={parsed.kind} aria-label={parsed.kind === 'ok' ? parsed.maquette.title || labels.kind : labels.kind}\n className=\"mx-auto flex w-full max-w-5xl min-w-0 flex-col gap-2 px-3 pt-1 pb-4 sm:px-6\">\n {notice}\n {parsed.kind === 'ok' ? <Walkthrough maquette={parsed.maquette} labels={labels} fragment={fragment} path={path} comments={comments} read={read} />\n : <Alert variant=\"destructive\" data-testid=\"maquette-error\" data-reason={parsed.reason}><TriangleAlertIcon aria-hidden=\"true\" /><AlertTitle>{labels.errorTitle}</AlertTitle>\n <AlertDescription>{parsed.reason === 'json' ? labels.invalidJson : labels.invalidShape}</AlertDescription></Alert>}\n </section>;\n}\n\n/**\n * The artifact kind of a maquette file (structurally an `ArtifactKind` of pi-chat): it matches `*.maquette.json`, names it, gives its icon\n * and draws it with `MaquetteViewer`. Pass it, or your own, in `artifactKinds`; kinds an app passes are consulted before the built-in one.\n */\nexport interface MaquetteKind {\n readonly match: (target: { readonly resource: { readonly path: string } }) => boolean;\n readonly label: string;\n readonly icon: ComponentType<{ readonly className?: string; readonly 'aria-hidden'?: boolean | 'true' }>;\n readonly renderer: (props: {\n readonly text: string; readonly fragment?: string | undefined;\n readonly artifact?: { readonly target: { readonly resource: { readonly path: string } } } | undefined; readonly read?: MaquetteRead | undefined;\n }) => ReactNode;\n}\n\n/**\n * A maquette kind in `locale` (\"Mock-up\" in English, \"Maquette\" in French) with any word overridden. `comments` (the feedback item's\n * `usePointComments(session)`) adds the comment icon; pass a fresh kind when it changes.\n */\nexport function createMaquetteKind({ locale, labels, comments }: {\n readonly locale?: MaquetteLocale | undefined; readonly labels?: Partial<MaquetteLabels> | undefined; readonly comments?: MaquetteComments | undefined;\n} = {}): MaquetteKind {\n return {\n match: target => isMaquettePath(target.resource.path),\n label: maquetteLabelsFor(locale, labels).kind,\n icon: LayoutTemplateIcon,\n renderer: ({ text, fragment, artifact, read }) => <MaquetteViewer text={text} fragment={fragment} locale={locale} labels={labels}\n path={artifact?.target.resource.path} comments={comments} read={read} />,\n };\n}\n\n/** The built-in maquette kind, in English. */\nexport const maquetteKind: MaquetteKind = createMaquetteKind();\n",
|
|
52
|
+
"content": "'use client';\n\n// The maquette viewer: a PM agent's `*.maquette.json` mock-up, one screen at a time. The screen is the content; the chrome is one thin bar\n// of icons: Back, ‹ 2 / 5 › (also arrow keys and swipe) and a menu holding the guided tours and \"How it works\" (the workflow, in a dialog).\n// During a tour the bar shows the step dots and ×, with the step's caption under it. `maquetteKind` is the kind the pi-app artifact panel\n// uses by default for `.maquette.json` files; an app's own kind in `artifactKinds` wins. It draws data only: nothing in the file runs.\n// The workflow is drawn by the diagram-viewer item's `DiagramView`, inline or from a linked `*.diagram.json`. With `comments` (the feedback\n// item's `usePointComments`, structurally) one more icon enters point mode on the screen: a tap on a block pins a note that names the\n// maquette path, the screen id and the block id; the pins of the current screen show as small numbers on their blocks.\nimport { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react';\nimport type { ComponentType, KeyboardEvent, ReactNode, TouchEvent } from 'react';\nimport { ArrowLeftIcon, ChevronLeftIcon, ChevronRightIcon, CompassIcon, EllipsisIcon, InfoIcon, LayoutTemplateIcon, MessageSquarePlusIcon, TriangleAlertIcon, WorkflowIcon, XIcon } from 'lucide-react';\nimport { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';\nimport { Button } from '@/components/ui/button';\nimport { Card, CardContent } from '@/components/ui/card';\nimport { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';\nimport { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';\nimport { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';\nimport { Toggle } from '@/components/ui/toggle';\nimport { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';\nimport { parseDiagram } from '../diagram-viewer/diagram';\nimport type { Diagram } from '../diagram-viewer/diagram';\nimport { DiagramView } from '../diagram-viewer/diagram-view';\nimport { MAQUETTE_FORMAT_VERSION, diagramPathOf, isMaquettePath, parseMaquette, screenFromFragment } from './maquette';\nimport type { Maquette, MaquetteBlock, MaquetteCase, MaquetteScreen } from './maquette';\nimport { maquetteLabelsFor } from './maquette-labels';\nimport type { MaquetteLabels, MaquetteLocale } from './maquette-labels';\nimport { MaquetteScreenView } from './maquette-blocks';\nimport type { MaquetteLinks } from './maquette-blocks';\n\nexport { MAQUETTE_FORMAT_VERSION, diagramPathOf, isMaquettePath, linksOf, parseMaquette, screenFromFragment } from './maquette';\nexport type { Maquette, MaquetteBlock, MaquetteBlockV1, MaquetteCase, MaquetteFileV1, MaquetteParse, MaquetteScreen, MaquetteState, MaquetteTransition, MaquetteWorkflow } from './maquette';\nexport { defaultMaquetteLabels, frenchMaquetteLabels, maquetteLabelsFor } from './maquette-labels';\nexport type { MaquetteLabels, MaquetteLocale } from './maquette-labels';\n\n/**\n * Comment pins, structurally the feedback item's `usePointComments(session)`: pass it and the bar shows a comment icon; omit it (no feedback\n * session in the host) and nothing shows.\n */\nexport interface MaquetteComments {\n /** Point mode is on. */\n readonly active: boolean;\n /** Enters point mode (the person taps blocks to pin notes), or ends it when it is on. */\n readonly point: () => void;\n /** The pins taken: each one's number and the `data-feedback-id` its element sits in. */\n readonly pins: readonly { readonly number: number; readonly target: string }[];\n}\n\n/** Reads another file of the workspace as text (the artifact panel passes one): a linked `*.diagram.json`. Undefined when it cannot. */\nexport type MaquetteRead = (path: string) => Promise<string | undefined>;\n\n/** A block's `data-feedback-id`: `maquette:<path>#screen=<screen id>&block=<block id>`. */\nexport const maquetteTarget = (path: string | undefined, screen: string, block: string): string => `maquette:${path ?? ''}#screen=${screen}&block=${block}`;\n\nexport interface MaquetteViewerProps {\n /** The file's text (JSON). */\n readonly text: string;\n /** The file's path: named by comment pins, and the folder a linked diagram is read from. */\n readonly path?: string | undefined;\n /** Comment pins on blocks (the feedback item's `usePointComments`). */\n readonly comments?: MaquetteComments | undefined;\n /** Reads a linked `*.diagram.json`; without it a linked workflow is not offered. */\n readonly read?: MaquetteRead | undefined;\n /** The page's URL fragment: `screen=<id>` opens that screen (and follows later changes). */\n readonly fragment?: string | undefined;\n /** The base words: English by default. */\n readonly locale?: MaquetteLocale | undefined;\n /** Any word over the locale's. */\n readonly labels?: Partial<MaquetteLabels> | undefined;\n}\n\n/** Where the person is: the screens visited (the last is shown) and the guided tour under way. */\ninterface Walk { readonly history: readonly string[]; readonly tour: { readonly name: string; readonly index: number } | null }\n\n/**\n * A host bar that owns the one \"…\" menu (the pi-app artifact panel) provides this: the viewer then publishes its tours and \"How it works\"\n * there instead of drawing a second \"…\" menu beside the screen counter. Without a provider the viewer keeps its own menu.\n */\nexport interface MaquetteMenuEntry { readonly id: string; readonly label: string; readonly icon: ReactNode; readonly testId: string; readonly separatorBefore: boolean; readonly onSelect: () => void }\nexport const MaquetteMenuContext = createContext<((items: readonly MaquetteMenuEntry[]) => void) | undefined>(undefined);\n\nconst TYPING = 'input, select, textarea, [contenteditable=\"true\"]';\nconst ICON = 'pointer-coarse:min-h-11 pointer-coarse:min-w-11';\n\n/** One dot per tour step, the current one in the primary colour. */\nfunction Dots({ count, index, label }: { readonly count: number; readonly index: number; readonly label: string }) {\n return <svg role=\"img\" aria-label={label} data-testid=\"maquette-dots\" width={count * 12} height=\"8\" viewBox={`0 0 ${count * 12} 8`} className=\"shrink-0\">\n {Array.from({ length: count }, (_, i) => <circle key={i} cx={i * 12 + 6} cy=\"4\" r=\"3\" className={i === index ? 'fill-primary' : 'fill-muted-foreground/30'} />)}\n </svg>;\n}\n\n/** The diagram of a linked `*.diagram.json`, read when the dialog opens: undefined while reading, null when it cannot be read. */\nfunction useLinked(read: MaquetteRead | undefined, path: string | undefined, open: boolean): Diagram | null | undefined {\n const [loaded, setLoaded] = useState<{ readonly path: string; readonly diagram: Diagram | null }>();\n useEffect(() => {\n if (!open || !path || !read || loaded?.path === path) return undefined;\n let off = false;\n read(path).then(text => text === undefined ? null : parseDiagram(text), () => null).then(parsed => {\n if (!off) setLoaded({ path, diagram: parsed?.kind === 'ok' ? parsed.diagram : null });\n });\n return () => { off = true; };\n }, [open, path, read, loaded?.path]);\n return loaded && loaded.path === path ? loaded.diagram : undefined;\n}\n\n/** The workflow diagram, its legend and the cases' `path` highlights, in a dialog opened from the menu. */\nfunction HowItWorks({ maquette, source, labels, open, onOpenChange }: {\n readonly maquette: Maquette; readonly source: DiagramSource; readonly labels: MaquetteLabels; readonly open: boolean; readonly onOpenChange: (open: boolean) => void;\n}) {\n const [shown, setShown] = useState<string | null>(null);\n const loaded = useLinked(source.read, source.path, open);\n const diagram = maquette.diagram ?? loaded;\n const highlighted = maquette.cases.filter(item => item.path.length > 0);\n if (!maquette.diagram && !source.path) return null;\n return <Dialog open={open} onOpenChange={onOpenChange}>\n <DialogContent data-testid=\"maquette-how\" className=\"max-h-[85dvh] overflow-y-auto\">\n <DialogHeader><DialogTitle>{labels.howItWorks}</DialogTitle></DialogHeader>\n {diagram ? <DiagramView diagram={diagram} highlight={highlighted.find(item => item.name === shown)?.path ?? diagram.path} labels={labels} />\n : diagram === null ? <Alert variant=\"destructive\" data-testid=\"maquette-how-error\"><TriangleAlertIcon aria-hidden=\"true\" /><AlertTitle>{labels.diagramUnavailable}</AlertTitle></Alert>\n : <p role=\"status\" className=\"m-0 text-sm text-muted-foreground\">{labels.loading}</p>}\n {highlighted.length > 0 && <div role=\"group\" className=\"flex flex-wrap gap-2\">\n {highlighted.map(item => <Button key={item.name} type=\"button\" size=\"sm\" variant={shown === item.name ? 'default' : 'outline'} aria-pressed={shown === item.name}\n data-testid=\"maquette-case\" className=\"pointer-coarse:min-h-11\" onClick={() => setShown(current => current === item.name ? null : item.name)}>{item.name}</Button>)}\n </div>}\n </DialogContent>\n </Dialog>;\n}\n\n/** Where a linked diagram is read from: its resolved path and the reader (both absent for an inline workflow, or with no reader). */\ninterface DiagramSource { readonly path: string | undefined; readonly read: MaquetteRead | undefined }\n\nfunction Walkthrough({ maquette, labels, fragment, path, comments, read }: {\n readonly maquette: Maquette; readonly labels: MaquetteLabels; readonly fragment: string | undefined;\n readonly path: string | undefined; readonly comments: MaquetteComments | undefined; readonly read: MaquetteRead | undefined;\n}) {\n const exists = (id: string | undefined): id is string => !!id && maquette.screens.some(screen => screen.id === id);\n const linked = screenFromFragment(fragment);\n const [walk, setWalk] = useState<Walk>(() => ({ history: [exists(linked) ? linked : maquette.start], tour: null }));\n const [how, setHow] = useState(false);\n const seen = useRef(new Set<string>());\n const heading = useRef<HTMLHeadingElement>(null);\n const moved = useRef(false);\n const touch = useRef<{ x: number; y: number } | null>(null);\n const screens = maquette.screens;\n const current: MaquetteScreen = screens.find(screen => screen.id === walk.history.at(-1)) ?? screens[0]!;\n const position = screens.indexOf(current);\n const tour: MaquetteCase | undefined = walk.tour ? maquette.cases.find(item => item.name === walk.tour!.name) : undefined;\n const tours = useMemo(() => maquette.cases.filter(item => item.steps.length > 0), [maquette]);\n const titleOf = (id: string) => screens.find(screen => screen.id === id)?.title ?? id;\n // Goto blocks pulse once, the first time their screen is shown.\n // Decided once per shown screen, so a re-render of the same screen (the host menu updating) does not end the pulse.\n const shown = useRef<{ readonly id: string; readonly first: boolean } | undefined>(undefined);\n const view = shown.current?.id === current.id ? shown.current : { id: current.id, first: !seen.current.has(current.id) };\n shown.current = view;\n const firstView = view.first;\n useEffect(() => { seen.current.add(current.id); }, [current.id]);\n\n const move = (next: Walk) => { moved.current = true; setWalk(next); };\n const go = (id: string) => { if (exists(id) && id !== current.id) move({ history: [...walk.history, id], tour: null }); };\n const step = (delta: number) => {\n if (tour && walk.tour) {\n const index = walk.tour.index + delta, target = tour.steps[index];\n if (target) move({ history: [...walk.history, target.screen], tour: { name: tour.name, index } });\n return;\n }\n const target = screens[position + delta];\n if (target) move({ history: [...walk.history, target.id], tour: null });\n };\n const back = () => { if (walk.history.length > 1) move({ history: walk.history.slice(0, -1), tour: null }); };\n const startTour = (item: MaquetteCase) => move({ history: [...walk.history, item.steps[0]!.screen], tour: { name: item.name, index: 0 } });\n // A later `#screen=<id>` (the PM's \"see screen 2\" link) opens that screen.\n useEffect(() => { if (exists(linked) && linked !== walk.history.at(-1)) setWalk(previous => ({ history: [...previous.history, linked], tour: null })); }, [linked]); // eslint-disable-line react-hooks/exhaustive-deps\n // After a move, keyboard focus starts on the new screen, so Tab continues inside the screen just opened.\n useEffect(() => { if (moved.current) { moved.current = false; heading.current?.focus(); } }, [walk]);\n\n const index = tour && walk.tour ? walk.tour.index : position, count = tour ? tour.steps.length : screens.length;\n const target = (block: MaquetteBlock) => maquetteTarget(path, current.id, block.id);\n // While pointing, the session draws its own numbered pins over the page; the small numbers on the blocks are for afterwards.\n const pins = (block: MaquetteBlock) => comments && !comments.active ? comments.pins.filter(pin => pin.target === target(block)).map(pin => pin.number) : [];\n const links: MaquetteLinks = { go, hint: id => labels.opens(titleOf(id)), target, pins };\n const source: DiagramSource = { path: maquette.diagramLink && read ? diagramPathOf(path ?? '', maquette.diagramLink) : undefined, read };\n const workflow = !!maquette.diagram || !!source.path;\n // Inside a host bar with its own \"…\" menu, the tours and \"How it works\" go there (one menu, not two).\n const publish = useContext(MaquetteMenuContext);\n const latestTour = useRef(startTour); latestTour.current = startTour;\n useEffect(() => {\n if (!publish) return undefined;\n const items: MaquetteMenuEntry[] = [\n ...(tour ? [] : tours).map((item, i) => ({ id: `tour-${i}`, label: item.name, icon: <CompassIcon aria-hidden=\"true\" />, testId: 'maquette-tour', separatorBefore: i === 0, onSelect: () => latestTour.current(item) })),\n ...(workflow && !tour ? [{ id: 'how-it-works', label: labels.howItWorks, icon: <WorkflowIcon aria-hidden=\"true\" />, testId: 'maquette-how-open', separatorBefore: true, onSelect: () => setHow(true) }] : []),\n ];\n publish(items);\n return () => publish([]);\n }, [publish, tours, workflow, tour, labels.howItWorks]);\n const onKeyDown = (event: KeyboardEvent) => {\n if (event.defaultPrevented || event.ctrlKey || event.metaKey || event.shiftKey || (event.target as Element).closest?.(TYPING)) return;\n if (event.altKey) { if (event.key === 'ArrowLeft') { event.preventDefault(); back(); } return; }\n if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') { event.preventDefault(); step(event.key === 'ArrowLeft' ? -1 : 1); }\n };\n const onTouchStart = (event: TouchEvent) => { const point = event.touches[0]; touch.current = point ? { x: point.clientX, y: point.clientY } : null; };\n const onTouchEnd = (event: TouchEvent) => {\n const start = touch.current, point = event.changedTouches[0];\n touch.current = null;\n if (!start || !point) return;\n const dx = point.clientX - start.x, dy = point.clientY - start.y;\n if (Math.abs(dx) > 56 && Math.abs(dx) > Math.abs(dy) * 1.5) step(dx < 0 ? 1 : -1);\n };\n const caption = tour && walk.tour ? tour.steps[walk.tour.index]?.caption : undefined;\n\n return <div data-testid=\"maquette-walk\" data-screen={current.id} data-tour={tour?.name} className=\"flex min-w-0 flex-col gap-2\" onKeyDown={onKeyDown}>\n {/* The viewer's own chrome is never pinned, and keeps working while pointing: walk to another screen and pin there too. */}\n <nav aria-label={labels.screens} data-testid=\"maquette-stepper\" data-feedback-ignore=\"\" className=\"flex items-center gap-1\">\n {walk.history.length > 1 && <Button type=\"button\" variant=\"ghost\" size=\"icon-sm\" className={ICON} aria-label={labels.back} data-testid=\"maquette-back\" onClick={back}><ArrowLeftIcon aria-hidden=\"true\" /></Button>}\n <div className=\"flex flex-1 items-center justify-center gap-1\">\n <Button type=\"button\" variant=\"ghost\" size=\"icon-sm\" className={ICON} aria-label={labels.previous} data-testid=\"maquette-previous\" disabled={index <= 0} onClick={() => step(-1)}><ChevronLeftIcon aria-hidden=\"true\" /></Button>\n {tour ? <Dots count={count} index={index} label={labels.tourStep(index + 1, count)} />\n : <span data-testid=\"maquette-position\" className=\"min-w-10 text-center text-sm text-muted-foreground tabular-nums\">{index + 1} / {count}</span>}\n <Button type=\"button\" variant=\"ghost\" size=\"icon-sm\" className={ICON} aria-label={labels.next} data-testid=\"maquette-next\" disabled={index >= count - 1} onClick={() => step(1)}><ChevronRightIcon aria-hidden=\"true\" /></Button>\n </div>\n {comments && <Tooltip><TooltipTrigger render={<Toggle className={ICON} aria-label={labels.comment} pressed={comments.active}\n data-testid=\"maquette-comment\" onPressedChange={() => comments.point()} />}><MessageSquarePlusIcon aria-hidden=\"true\" /></TooltipTrigger><TooltipContent>{labels.comment}</TooltipContent></Tooltip>}\n {tour ? <Button type=\"button\" variant=\"ghost\" size=\"icon-sm\" className={ICON} aria-label={labels.endTour} data-testid=\"maquette-end-tour\" onClick={() => setWalk(previous => ({ ...previous, tour: null }))}><XIcon aria-hidden=\"true\" /></Button>\n : !publish && (tours.length > 0 || workflow) && <DropdownMenu>\n <DropdownMenuTrigger render={<Button type=\"button\" variant=\"ghost\" size=\"icon-sm\" className={ICON} aria-label={labels.more} data-testid=\"maquette-menu\" />}><EllipsisIcon aria-hidden=\"true\" /></DropdownMenuTrigger>\n <DropdownMenuContent align=\"end\" data-testid=\"maquette-menu-list\">\n {tours.map(item => <DropdownMenuItem key={item.name} className=\"pointer-coarse:min-h-11\" data-testid=\"maquette-tour\" onClick={() => startTour(item)}><CompassIcon aria-hidden=\"true\" />{item.name}</DropdownMenuItem>)}\n {tours.length > 0 && workflow && <DropdownMenuSeparator />}\n {workflow && <DropdownMenuItem className=\"pointer-coarse:min-h-11\" data-testid=\"maquette-how-open\" onClick={() => setHow(true)}><WorkflowIcon aria-hidden=\"true\" />{labels.howItWorks}</DropdownMenuItem>}\n </DropdownMenuContent>\n </DropdownMenu>}\n </nav>\n {caption && <p data-testid=\"maquette-caption\" className=\"m-0 line-clamp-2 px-1 text-sm text-pretty text-muted-foreground\">{caption}</p>}\n\n <div className={maquette.device === 'phone' ? 'mx-auto flex w-full max-w-sm flex-col gap-2' : 'flex min-w-0 flex-col gap-2'}>\n <div className=\"min-w-0\" data-hint={firstView ? 'pulse' : undefined} onTouchStart={onTouchStart} onTouchEnd={onTouchEnd}>\n <Card data-testid=\"maquette-frame\" data-device={maquette.device ?? 'panel'}><CardContent>\n <MaquetteScreenView key={current.id} maquette={maquette} screen={current} links={links} labels={labels} headingRef={heading} />\n </CardContent></Card>\n </div>\n {current.note && <div className=\"flex min-w-0 items-center gap-1 px-1\">\n <p data-testid=\"maquette-note\" title={current.note} className=\"m-0 min-w-0 flex-1 truncate text-xs text-muted-foreground\">{current.note}</p>\n <Popover>\n <PopoverTrigger render={<Button type=\"button\" variant=\"ghost\" size=\"icon-xs\" className={ICON} aria-label={labels.aboutScreen} data-testid=\"maquette-note-more\" />}><InfoIcon aria-hidden=\"true\" /></PopoverTrigger>\n <PopoverContent align=\"end\" data-testid=\"maquette-note-full\">{current.note}</PopoverContent>\n </Popover>\n </div>}\n </div>\n <p aria-live=\"polite\" className=\"m-0 sr-only\">{labels.showing(current.title)}</p>\n <HowItWorks maquette={maquette} source={source} labels={labels} open={how} onOpenChange={setHow} />\n </div>;\n}\n\n/** A maquette file drawn one screen at a time; a newer format version shows a notice, an unreadable file an inline error. */\nexport function MaquetteViewer({ text, fragment, locale, labels: given, path, comments, read }: MaquetteViewerProps) {\n const labels = useMemo(() => maquetteLabelsFor(locale, given), [locale, given]);\n const parsed = useMemo(() => parseMaquette(text), [text]);\n const newer = parsed.version !== undefined && parsed.version > MAQUETTE_FORMAT_VERSION;\n const notice = newer && <Alert data-testid=\"maquette-newer\"><InfoIcon aria-hidden=\"true\" /><AlertTitle>{labels.newerTitle}</AlertTitle><AlertDescription>{labels.newer(parsed.version!)}</AlertDescription></Alert>;\n return <section data-boring=\"maquette-viewer\" data-state={parsed.kind} aria-label={parsed.kind === 'ok' ? parsed.maquette.title || labels.kind : labels.kind}\n className=\"mx-auto flex w-full max-w-5xl min-w-0 flex-col gap-2 px-3 pt-1 pb-4 sm:px-6\">\n {notice}\n {parsed.kind === 'ok' ? <Walkthrough maquette={parsed.maquette} labels={labels} fragment={fragment} path={path} comments={comments} read={read} />\n : <Alert variant=\"destructive\" data-testid=\"maquette-error\" data-reason={parsed.reason}><TriangleAlertIcon aria-hidden=\"true\" /><AlertTitle>{labels.errorTitle}</AlertTitle>\n <AlertDescription>{parsed.reason === 'json' ? labels.invalidJson : labels.invalidShape}</AlertDescription></Alert>}\n </section>;\n}\n\n/**\n * The artifact kind of a maquette file (structurally an `ArtifactKind` of pi-artifacts): it matches `*.maquette.json`, names it, gives its icon\n * and draws it with `MaquetteViewer`. Pass it, or your own, in `artifactKinds`; kinds an app passes are consulted before the built-in one.\n */\nexport interface MaquetteKind {\n readonly match: (target: { readonly resource: { readonly path: string } }) => boolean;\n readonly label: string;\n readonly icon: ComponentType<{ readonly className?: string; readonly 'aria-hidden'?: boolean | 'true' }>;\n readonly renderer: (props: {\n readonly text: string; readonly fragment?: string | undefined;\n readonly artifact?: { readonly target: { readonly resource: { readonly path: string } } } | undefined; readonly read?: MaquetteRead | undefined;\n }) => ReactNode;\n}\n\n/**\n * A maquette kind in `locale` (\"Mock-up\" in English, \"Maquette\" in French) with any word overridden. `comments` (the feedback item's\n * `usePointComments(session)`) adds the comment icon; pass a fresh kind when it changes.\n */\nexport function createMaquetteKind({ locale, labels, comments }: {\n readonly locale?: MaquetteLocale | undefined; readonly labels?: Partial<MaquetteLabels> | undefined; readonly comments?: MaquetteComments | undefined;\n} = {}): MaquetteKind {\n return {\n match: target => isMaquettePath(target.resource.path),\n label: maquetteLabelsFor(locale, labels).kind,\n icon: LayoutTemplateIcon,\n renderer: ({ text, fragment, artifact, read }) => <MaquetteViewer text={text} fragment={fragment} locale={locale} labels={labels}\n path={artifact?.target.resource.path} comments={comments} read={read} />,\n };\n}\n\n/** The built-in maquette kind, in English. */\nexport const maquetteKind: MaquetteKind = createMaquetteKind();\n",
|
|
53
53
|
"type": "registry:component",
|
|
54
54
|
"target": "components/maquette-viewer/maquette-viewer.tsx"
|
|
55
55
|
}
|
package/r/markdown-editor.json
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
"title": "Markdown editor",
|
|
5
5
|
"description": "A host-owned styled wrapper over the concrete borrowed Markdown controller.",
|
|
6
6
|
"dependencies": [
|
|
7
|
-
"@hachej/boring-ui-kit@^0.4.
|
|
8
|
-
"@hachej/boring-files@^0.4.
|
|
7
|
+
"@hachej/boring-ui-kit@^0.4.12",
|
|
8
|
+
"@hachej/boring-files@^0.4.12",
|
|
9
9
|
"react@^19.0.0",
|
|
10
10
|
"react-dom@^19.0.0",
|
|
11
11
|
"marked@^17.0.6",
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
"lucide-react@^1.51.0"
|
|
25
25
|
],
|
|
26
26
|
"registryDependencies": [
|
|
27
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
27
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.12/r/button.json",
|
|
28
28
|
"button",
|
|
29
29
|
"toggle",
|
|
30
30
|
"dropdown-menu"
|
package/r/mockup-viewer.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
3
|
"name": "mockup-viewer",
|
|
4
4
|
"title": "HTML mock-up viewer",
|
|
5
|
-
"description": "Sandboxed *.mockup.html artifacts with host theme variables, lazy Tailwind compilation and feedback point comments. Host presentation only; artifact scripts run in an opaque-origin InteractiveFrame, never in the host.",
|
|
5
|
+
"description": "Sandboxed *.mockup.html artifacts with host theme variables (or a given shadcn theme through themeOverride), lazy Tailwind compilation and feedback point comments. Host presentation only; artifact scripts run in an opaque-origin InteractiveFrame, never in the host.",
|
|
6
6
|
"dependencies": [
|
|
7
7
|
"react@^19.0.0",
|
|
8
8
|
"lucide-react@^1.51.0",
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"zod@^4.6.5"
|
|
11
11
|
],
|
|
12
12
|
"registryDependencies": [
|
|
13
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
13
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.12/r/viewers.json",
|
|
14
14
|
"badge",
|
|
15
15
|
"toggle",
|
|
16
16
|
"tooltip"
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
"files": [
|
|
19
19
|
{
|
|
20
20
|
"path": "registry/mockup-viewer/mockup-viewer.tsx",
|
|
21
|
-
"content": "'use client';\n\nimport { useEffect, useLayoutEffect, useRef, useState } from 'react';\nimport type { ComponentType, ReactNode, RefObject } from 'react';\nimport { CodeXmlIcon, MessageSquarePlusIcon } from 'lucide-react';\nimport { Badge } from '@/components/ui/badge';\nimport { Toggle } from '@/components/ui/toggle';\nimport { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';\nimport { InteractiveFrame } from '../viewers/interactive-html';\nimport { mockupContent, readMockupPick } from './mockup-frame';\nimport type { MockupPick } from './mockup-frame';\n\nexport const isMockupPath = (path: string): boolean => /\\.mockup\\.html$/i.test(path);\nexport const mockupTarget = (path: string, selector: string): string => `mockup:${path}#element=${selector}`;\n\n/** Structurally the feedback item's usePointComments. Picks are host-owned proxy elements, never frame DOM. */\nexport interface MockupComments {\n readonly active: boolean;\n readonly picking?: boolean | undefined;\n readonly point: () => void;\n readonly pins: readonly { readonly number: number; readonly target: string }[];\n readonly pick?: ((element: Element) => void) | undefined;\n readonly reposition?: (() => void) | undefined;\n}\n\nexport interface MockupViewerProps {\n readonly text: string;\n readonly path?: string | undefined;\n readonly comments?: MockupComments | undefined;\n readonly locale?: 'en' | 'fr' | undefined;\n readonly onReady?: (() => void) | undefined;\n}\n\nfunction useTheme(root: RefObject<HTMLDivElement | null>) {\n const [theme, setTheme] = useState({ variables: '', dark: false });\n useEffect(() => {\n const element = root.current;\n if (!element) return undefined;\n const view = element.ownerDocument.defaultView;\n if (!view) return undefined;\n const media = view.matchMedia('(prefers-color-scheme: dark)');\n const read = () => {\n const computed = view.getComputedStyle(element);\n const variables = Array.from(computed).filter(name => name.startsWith('--')).map(name => `${name}:${computed.getPropertyValue(name)}`).join(';');\n const dark = !!element.closest('.dark') || (!element.closest('.light') && media.matches);\n setTheme(current => current.variables === variables && current.dark === dark ? current : { variables, dark });\n };\n read();\n const observer = typeof MutationObserver === 'undefined' ? undefined : new MutationObserver(read);\n for (let node: Element | null = element; node; node = node.parentElement) observer?.observe(node, { attributes: true, attributeFilter: ['class', 'style'] });\n media.addEventListener('change', read);\n return () => { observer?.disconnect(); media.removeEventListener('change', read); };\n }, [root]);\n return theme;\n}\n\nexport function MockupViewer({ text, path = '', comments, locale, onReady }: MockupViewerProps) {\n const root = useRef<HTMLDivElement>(null);\n const frame = useRef<HTMLIFrameElement>(null);\n const proxies = useRef(new Map<string, HTMLSpanElement>());\n const pending = useRef<string | undefined>(undefined);\n const theme = useTheme(root);\n const [compiled, setCompiled] = useState<{ text: string; css: string } | { text: string; error: true }>();\n const [picks, setPicks] = useState<readonly MockupPick[]>([]);\n const label = locale === 'fr' ? 'Commenter un élément' : 'Comment on an element';\n useEffect(() => {\n let off = false;\n setPicks([]);\n void import('./mockup-compile').then(({ compileMockup }) => compileMockup(text)).then(css => { if (!off) setCompiled({ text, css }); }, () => { if (!off) setCompiled({ text, error: true }); });\n return () => { off = true; };\n }, [text, path]);\n const ready = compiled?.text === text && 'css' in compiled;\n const content = ready ? mockupContent(text, compiled.css) : undefined;\n const picking = comments?.picking ?? comments?.active ?? false;\n const mode = () => frame.current?.contentWindow?.postMessage({ type: 'mockup:pick-mode', on: picking, theme }, '*');\n useEffect(mode, [picking, content, theme]);\n useLayoutEffect(() => { pending.current = undefined; setPicks([]); }, [content]);\n useEffect(() => {\n const view = root.current?.ownerDocument.defaultView;\n const onMessage = (event: MessageEvent<unknown>) => {\n if (!frame.current?.contentWindow || event.source !== frame.current.contentWindow || event.origin !== 'null' || !comments?.pick) return;\n const pick = readMockupPick(event.data);\n if (!pick || (pick.type === 'mockup:pick' && !picking)) return;\n if (pick.type === 'mockup:pick') pending.current = pick.selector;\n setPicks(current => {\n const exists = current.some(item => item.selector === pick.selector);\n if (!exists && (pick.type !== 'mockup:pick' || current.length >= 50)) { pending.current = undefined; return current; }\n return exists ? current.map(item => item.selector === pick.selector ? pick : item) : [...current, pick];\n });\n };\n view?.addEventListener('message', onMessage);\n return () => view?.removeEventListener('message', onMessage);\n }, [comments, picking]);\n useLayoutEffect(() => {\n const selector = pending.current;\n pending.current = undefined;\n const proxy = selector === undefined ? undefined : proxies.current.get(selector);\n if (proxy) comments?.pick?.(proxy);\n else comments?.reposition?.();\n }, [picks, comments?.pick, comments?.reposition]);\n return <div ref={root} data-boring=\"mockup-viewer\" className=\"flex h-full min-h-0 min-w-0 flex-col\">\n {comments?.pick && <nav data-feedback-ignore=\"\" className=\"flex shrink-0 justify-end px-2 py-1\">\n <Tooltip><TooltipTrigger render={<Toggle aria-label={label} pressed={comments.active} onPressedChange={comments.point} data-testid=\"mockup-comment\" className=\"pointer-coarse:min-h-11 pointer-coarse:min-w-11\" />}><MessageSquarePlusIcon aria-hidden=\"true\" /></TooltipTrigger><TooltipContent>{label}</TooltipContent></Tooltip>\n </nav>}\n <div className=\"relative flex min-h-0 min-w-0 flex-1 overflow-hidden\">\n {content === undefined ? <p role=\"status\" className=\"p-3 text-muted-foreground\">{compiled && 'error' in compiled ? 'HTML mock-up unavailable.' : 'Loading HTML mock-up…'}</p>\n : <InteractiveFrame key={content} text={content} sources={[]} title=\"HTML mock-up\" testId=\"mockup\" frameRef={frame} onLoad={() => { mode(); onReady?.(); }} />}\n {picks.map(pick => <span key={pick.selector} ref={element => { if (element) proxies.current.set(pick.selector, element); else proxies.current.delete(pick.selector); }}\n data-testid=\"mockup-element\" data-feedback-id={mockupTarget(path, pick.selector)} role=\"img\" aria-label=\"HTML mock-up element\"\n className=\"pointer-events-none absolute overflow-hidden\" style={{ left: pick.rect.x, top: pick.rect.y, width: pick.rect.w, height: pick.rect.h }}>\n {!comments?.active && comments?.pins.filter(pin => pin.target === mockupTarget(path, pick.selector)).map(pin => <Badge key={pin.number}>{pin.number}</Badge>)}\n </span>)}\n </div>\n </div>;\n}\n\nexport interface MockupKind {\n readonly match: (target: { readonly resource: { readonly path: string } }) => boolean;\n readonly label: string;\n readonly icon: ComponentType<{ readonly className?: string; readonly 'aria-hidden'?: boolean | 'true' }>;\n readonly renderer: (props: { readonly text: string; readonly onReady?: (() => void) | undefined; readonly artifact?: { readonly target: { readonly resource: { readonly path: string } } } | undefined }) => ReactNode;\n /** The sandboxed frame loads asynchronously: a `present` of a mock-up is confirmed only once it has loaded. */\n readonly waitsForReady: true;\n}\n\nexport function createMockupKind({ comments, locale }: { readonly comments?: MockupComments | undefined; readonly locale?: 'en' | 'fr' | undefined } = {}): MockupKind {\n return { match: target => isMockupPath(target.resource.path), label: locale === 'fr' ? 'Maquette HTML' : 'HTML mock-up', icon: CodeXmlIcon, waitsForReady: true,\n renderer: ({ text, artifact, onReady }) => <MockupViewer key={artifact?.target.resource.path} text={text} path={artifact?.target.resource.path} comments={comments} locale={locale} onReady={onReady} /> };\n}\nexport const mockupKind: MockupKind = createMockupKind();\n",
|
|
21
|
+
"content": "'use client';\n\nimport { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';\nimport type { ComponentType, ReactNode, RefObject } from 'react';\nimport { CodeXmlIcon, MessageSquarePlusIcon } from 'lucide-react';\nimport { Badge } from '@/components/ui/badge';\nimport { Toggle } from '@/components/ui/toggle';\nimport { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';\nimport { InteractiveFrame } from '../viewers/interactive-html';\nimport { mockupContent, readMockupPick } from './mockup-frame';\nimport type { MockupPick } from './mockup-frame';\n\nexport const isMockupPath = (path: string): boolean => /\\.mockup\\.html$/i.test(path);\nexport const mockupTarget = (path: string, selector: string): string => `mockup:${path}#element=${selector}`;\n\n/** Structurally the feedback item's usePointComments. Picks are host-owned proxy elements, never frame DOM. */\nexport interface MockupComments {\n readonly active: boolean;\n readonly picking?: boolean | undefined;\n readonly point: () => void;\n readonly pins: readonly { readonly number: number; readonly target: string }[];\n readonly pick?: ((element: Element) => void) | undefined;\n readonly reposition?: (() => void) | undefined;\n}\n\nexport interface MockupViewerProps {\n readonly text: string;\n readonly path?: string | undefined;\n readonly comments?: MockupComments | undefined;\n readonly locale?: 'en' | 'fr' | undefined;\n readonly onReady?: (() => void) | undefined;\n /**\n * Render the frame with this shadcn theme instead of the host's (a look being compared): shadcn's own `cssVars` shape, variable names\n * without `--`. Its variables are applied after the host's, so a look that names only some of them keeps the host's others.\n */\n readonly themeOverride?: ShadcnTheme | undefined;\n /** Force light or dark in the frame; absent: follow the host. */\n readonly mode?: 'light' | 'dark' | undefined;\n /** The frame's accessible name (several frames side by side need distinct names). */\n readonly title?: string | undefined;\n}\n\n/** A shadcn theme as the shadcn registry writes it (`cssVars`): `theme` (fonts, radius steps), `light` (:root) and `dark` (.dark). */\nexport interface ShadcnTheme {\n readonly theme?: Readonly<Record<string, string>> | undefined;\n readonly light?: Readonly<Record<string, string>> | undefined;\n readonly dark?: Readonly<Record<string, string>> | undefined;\n}\n\nconst NAME = /^[a-z][a-z0-9-]{0,47}$/;\nconst VALUE = /^[^;{}<>\\\\]{1,240}$/;\n\n/** The declarations a shadcn theme gives the frame in one mode. Names and values that could leave the declaration list are dropped. */\nexport function themeDeclarations(theme: ShadcnTheme, dark: boolean): string {\n const entries = { ...theme.theme, ...theme.light, ...(dark ? theme.dark : undefined) };\n // The frame's radius steps are bound to --radius (the registry theme's bindings), so a look's --radius reshapes every rounded-*.\n return Object.entries(entries).filter(([name, value]) => NAME.test(name) && typeof value === 'string' && VALUE.test(value.trim())).map(([name, value]) => `--${name}:${value.trim()}`).join(';');\n}\n\nfunction useTheme(root: RefObject<HTMLDivElement | null>) {\n const [theme, setTheme] = useState({ variables: '', dark: false });\n useEffect(() => {\n const element = root.current;\n if (!element) return undefined;\n const view = element.ownerDocument.defaultView;\n if (!view) return undefined;\n const media = view.matchMedia('(prefers-color-scheme: dark)');\n const read = () => {\n const computed = view.getComputedStyle(element);\n const variables = Array.from(computed).filter(name => name.startsWith('--')).map(name => `${name}:${computed.getPropertyValue(name)}`).join(';');\n const dark = !!element.closest('.dark') || (!element.closest('.light') && media.matches);\n setTheme(current => current.variables === variables && current.dark === dark ? current : { variables, dark });\n };\n read();\n const observer = typeof MutationObserver === 'undefined' ? undefined : new MutationObserver(read);\n for (let node: Element | null = element; node; node = node.parentElement) observer?.observe(node, { attributes: true, attributeFilter: ['class', 'style'] });\n media.addEventListener('change', read);\n return () => { observer?.disconnect(); media.removeEventListener('change', read); };\n }, [root]);\n return theme;\n}\n\nexport function MockupViewer({ text, path = '', comments, locale, onReady, themeOverride, mode: forced, title }: MockupViewerProps) {\n const root = useRef<HTMLDivElement>(null);\n const frame = useRef<HTMLIFrameElement>(null);\n const proxies = useRef(new Map<string, HTMLSpanElement>());\n const pending = useRef<string | undefined>(undefined);\n const host = useTheme(root);\n const overrideKey = themeOverride ? JSON.stringify(themeOverride) : '';\n const theme = useMemo(() => {\n const dark = forced ? forced === 'dark' : host.dark;\n if (!themeOverride) return dark === host.dark ? host : { ...host, dark };\n const own = themeDeclarations(themeOverride, dark);\n return { variables: [host.variables, own].filter(Boolean).join(';'), dark };\n }, [host, overrideKey, forced]); // eslint-disable-line react-hooks/exhaustive-deps\n const [compiled, setCompiled] = useState<{ text: string; css: string } | { text: string; error: true }>();\n const [picks, setPicks] = useState<readonly MockupPick[]>([]);\n const label = locale === 'fr' ? 'Commenter un élément' : 'Comment on an element';\n useEffect(() => {\n let off = false;\n setPicks([]);\n void import('./mockup-compile').then(({ compileMockup }) => compileMockup(text)).then(css => { if (!off) setCompiled({ text, css }); }, () => { if (!off) setCompiled({ text, error: true }); });\n return () => { off = true; };\n }, [text, path]);\n const ready = compiled?.text === text && 'css' in compiled;\n const content = ready ? mockupContent(text, compiled.css) : undefined;\n const picking = comments?.picking ?? comments?.active ?? false;\n const mode = () => frame.current?.contentWindow?.postMessage({ type: 'mockup:pick-mode', on: picking, theme }, '*');\n useEffect(mode, [picking, content, theme]);\n useLayoutEffect(() => { pending.current = undefined; setPicks([]); }, [content]);\n useEffect(() => {\n const view = root.current?.ownerDocument.defaultView;\n const onMessage = (event: MessageEvent<unknown>) => {\n if (!frame.current?.contentWindow || event.source !== frame.current.contentWindow || event.origin !== 'null' || !comments?.pick) return;\n const pick = readMockupPick(event.data);\n if (!pick || (pick.type === 'mockup:pick' && !picking)) return;\n if (pick.type === 'mockup:pick') pending.current = pick.selector;\n setPicks(current => {\n const exists = current.some(item => item.selector === pick.selector);\n if (!exists && (pick.type !== 'mockup:pick' || current.length >= 50)) { pending.current = undefined; return current; }\n return exists ? current.map(item => item.selector === pick.selector ? pick : item) : [...current, pick];\n });\n };\n view?.addEventListener('message', onMessage);\n return () => view?.removeEventListener('message', onMessage);\n }, [comments, picking]);\n useLayoutEffect(() => {\n const selector = pending.current;\n pending.current = undefined;\n const proxy = selector === undefined ? undefined : proxies.current.get(selector);\n if (proxy) comments?.pick?.(proxy);\n else comments?.reposition?.();\n }, [picks, comments?.pick, comments?.reposition]);\n return <div ref={root} data-boring=\"mockup-viewer\" className=\"flex h-full min-h-0 min-w-0 flex-col\">\n {comments?.pick && <nav data-feedback-ignore=\"\" className=\"flex shrink-0 justify-end px-2 py-1\">\n <Tooltip><TooltipTrigger render={<Toggle aria-label={label} pressed={comments.active} onPressedChange={comments.point} data-testid=\"mockup-comment\" className=\"pointer-coarse:min-h-11 pointer-coarse:min-w-11\" />}><MessageSquarePlusIcon aria-hidden=\"true\" /></TooltipTrigger><TooltipContent>{label}</TooltipContent></Tooltip>\n </nav>}\n <div className=\"relative flex min-h-0 min-w-0 flex-1 overflow-hidden\">\n {content === undefined ? <p role=\"status\" className=\"p-3 text-muted-foreground\">{compiled && 'error' in compiled ? 'HTML mock-up unavailable.' : 'Loading HTML mock-up…'}</p>\n : <InteractiveFrame key={content} text={content} sources={[]} title={title ?? 'HTML mock-up'} testId=\"mockup\" frameRef={frame} onLoad={() => { mode(); onReady?.(); }} />}\n {picks.map(pick => <span key={pick.selector} ref={element => { if (element) proxies.current.set(pick.selector, element); else proxies.current.delete(pick.selector); }}\n data-testid=\"mockup-element\" data-feedback-id={mockupTarget(path, pick.selector)} role=\"img\" aria-label=\"HTML mock-up element\"\n className=\"pointer-events-none absolute overflow-hidden\" style={{ left: pick.rect.x, top: pick.rect.y, width: pick.rect.w, height: pick.rect.h }}>\n {!comments?.active && comments?.pins.filter(pin => pin.target === mockupTarget(path, pick.selector)).map(pin => <Badge key={pin.number}>{pin.number}</Badge>)}\n </span>)}\n </div>\n </div>;\n}\n\nexport interface MockupKind {\n readonly match: (target: { readonly resource: { readonly path: string } }) => boolean;\n readonly label: string;\n readonly icon: ComponentType<{ readonly className?: string; readonly 'aria-hidden'?: boolean | 'true' }>;\n readonly renderer: (props: { readonly text: string; readonly onReady?: (() => void) | undefined; readonly artifact?: { readonly target: { readonly resource: { readonly path: string } } } | undefined }) => ReactNode;\n /** The sandboxed frame loads asynchronously: a `present` of a mock-up is confirmed only once it has loaded. */\n readonly waitsForReady: true;\n}\n\nexport function createMockupKind({ comments, locale }: { readonly comments?: MockupComments | undefined; readonly locale?: 'en' | 'fr' | undefined } = {}): MockupKind {\n return { match: target => isMockupPath(target.resource.path), label: locale === 'fr' ? 'Maquette HTML' : 'HTML mock-up', icon: CodeXmlIcon, waitsForReady: true,\n renderer: ({ text, artifact, onReady }) => <MockupViewer key={artifact?.target.resource.path} text={text} path={artifact?.target.resource.path} comments={comments} locale={locale} onReady={onReady} /> };\n}\nexport const mockupKind: MockupKind = createMockupKind();\n",
|
|
22
22
|
"type": "registry:component",
|
|
23
23
|
"target": "components/mockup-viewer/mockup-viewer.tsx"
|
|
24
24
|
},
|
package/r/pi-ambient.json
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"title": "Pi ambient agent",
|
|
5
5
|
"description": "AmbientChat, a compact floating agent bar that grows into a chat window over an existing page, and AgentNotifications, toasts for finished, failed and waiting tasks (with the one guarded browser Notification helper). A block over the pi-chat item; its in-window artifact panel uses the pi-workspace layout.",
|
|
6
6
|
"dependencies": [
|
|
7
|
-
"@hachej/boring-ui-kit@^0.4.
|
|
7
|
+
"@hachej/boring-ui-kit@^0.4.12",
|
|
8
8
|
"react@^19.0.0",
|
|
9
9
|
"react-dom@^19.0.0",
|
|
10
10
|
"@earendil-works/pi-durable@~1.2.0",
|
|
@@ -12,11 +12,14 @@
|
|
|
12
12
|
"lucide-react@^1.51.0"
|
|
13
13
|
],
|
|
14
14
|
"registryDependencies": [
|
|
15
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
16
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
17
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
18
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
19
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
15
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.12/r/theme.json",
|
|
16
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.12/r/pi-chat.json",
|
|
17
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.12/r/pi-chat-plugins.json",
|
|
18
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.12/r/pi-chat-question.json",
|
|
19
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.12/r/pi-artifacts.json",
|
|
20
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.12/r/pi-workspace.json",
|
|
21
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.12/r/utils.json",
|
|
22
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.12/r/button.json",
|
|
20
23
|
"message-scroller",
|
|
21
24
|
"button",
|
|
22
25
|
"toggle",
|
|
@@ -28,7 +31,7 @@
|
|
|
28
31
|
"files": [
|
|
29
32
|
{
|
|
30
33
|
"path": "registry/pi-ambient/ambient.tsx",
|
|
31
|
-
"content": "'use client';\n\nimport { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';\nimport type { KeyboardEvent, PointerEvent, ReactNode } from 'react';\nimport type { EntryRecord } from '@earendil-works/pi-durable';\nimport type { ImageContent, ToolCall, ToolResultMessage } from '@earendil-works/pi-ai';\nimport type { NativeChatController } from '@hachej/boring-ui-kit/native-chat';\nimport { BellIcon, ChevronDownIcon, CircleAlertIcon, CircleCheckIcon, ExternalLinkIcon, GripVerticalIcon, Loader2Icon, MessageCircleQuestionIcon, MinusIcon, PanelLeftIcon, ThumbsDownIcon, ThumbsUpIcon } from 'lucide-react';\nimport { Button } from '@/components/ui/button';\nimport { MessageScroller, MessageScrollerProvider, MessageScrollerViewport, MessageScrollerContent, MessageScrollerItem, MessageScrollerButton } from '@/components/ui/message-scroller';\nimport { Toggle } from '@/components/ui/toggle';\nimport { Avatar, AvatarFallback } from '@/components/ui/avatar';\nimport { Badge } from '@/components/ui/badge';\nimport { Card } from '@/components/ui/card';\nimport { Separator } from '@/components/ui/separator';\nimport { BlockActions } from '../button/actions';\nimport type { BlockAction, BlockIcon } from '../button/actions';\nimport { useChatPlacement } from '../pi-chat/use-remote-chat';\nimport { CopyButton } from '../pi-chat/code-block';\nimport { Composer } from '../pi-chat/composer';\nimport type { ComposerFeedback, ComposerProps } from '../pi-chat/composer';\nimport { ConversationFiles } from '../pi-chat/conversation-files';\nimport type { AttachmentsConfig, ConversationsConfig, EffortConfig, MentionsConfig, ModelConfig, SlashConfig } from '../pi-chat/config';\nimport { notifyPermission, requestNotifyPermission } from './browser-notify';\nimport type { NotifyPermission } from './browser-notify';\nimport { ConversationHistory, TranscriptHistory } from '../pi-chat/history';\nimport type { CommandMentions } from '../pi-chat/markdown';\nimport { RowView } from '../pi-chat/message';\nimport type { RowContext } from '../pi-chat/message';\nimport { ChatNotices, Notice, PreparingStatus, preparing } from '../pi-chat/notice';\nimport { AgentNotifications, KindIcon, createNotificationStore, defaultNotificationLabels, finishNotice, pendingQuestion, useNotifications } from './notifications';\nimport type { AgentNotification, NotificationLabels, NotificationStore } from './notifications';\nimport { ChatTextProvider, defaultChatIcons, defaultChatLabels, useMergedText } from '../pi-chat/labels';\nimport type { ChatIcons, ChatLabels } from '../pi-chat/labels';\nimport { MessageQueue } from '../pi-chat/queue';\nimport { artifactKey, collectArtifacts } from '../pi-chat/artifact';\nimport type { ArtifactDescriptor, ArtifactsConfig } from '../pi-chat/artifact';\nimport type { ChatCard, Mode } from '../pi-chat/rows';\nimport { useChatSession, useTranscript } from '../pi-chat/session';\nimport type { ChatFeatureProps, ChatSession } from '../pi-chat/session';\nimport { ArtifactWorkspace } from '../pi-workspace/workspace';\nimport type { WorkspacePanelApi } from '../pi-workspace/workspace';\nimport { cn, PHONE_BELOW } from '../utils/utils';\n\nexport { AgentNotifications, createNotificationStore, defaultNotificationLabels, summaryOf, useNotifications, watchConversation } from './notifications';\nexport type { AgentNotification, AgentNotificationsProps, NewNotification, NotificationKind, NotificationLabels, NotificationStore } from './notifications';\n\nconst elapsedText = (seconds: number) => seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m ${String(seconds % 60).padStart(2, '0')}s`;\n\n/** The floating window's own words, over the chat's (`defaultChatLabels`) and the toasts' (`defaultNotificationLabels`). */\nexport const defaultAmbientLabels = {\n ...defaultChatLabels,\n ...defaultNotificationLabels,\n /** Shown while the conversation has no title of its own (the active item of `conversations` provides one). */\n title: 'Agent',\n /** The input's placeholder while idle. */\n placeholder: 'Do anything',\n /** The input's placeholder while the agent works. */\n working: (seconds: number) => `Working for ${elapsedText(seconds)}`,\n region: (title: string) => `Agent: ${title}`,\n openAgent: (title: string, unread: boolean) => `Open agent: ${title}${unread ? ' (new activity)' : ''}`,\n openChat: (title: string) => `Open chat: ${title}`,\n minimize: 'Minimise',\n switchConversation: 'Switch conversation',\n statusWorking: 'Working',\n enableNotifications: 'Notify me when the page is in the background',\n dock: 'Dock chat',\n dockHint: 'Dock chat beside the panel',\n move: 'Move the agent window (arrow keys, Home resets)',\n moveHint: 'Drag to move',\n goodResponse: 'Good response',\n badResponse: 'Bad response',\n openFull: 'Open in full chat',\n empty: 'Ask the agent to do something.',\n loadFailedShort: 'Could not load the conversation.',\n disconnectedShort: 'Disconnected.',\n artifactPanel: 'Artifact panel',\n};\nexport type AmbientLabels = ChatLabels & NotificationLabels & typeof defaultAmbientLabels;\n\n/** The floating window's icons, over the chat's. */\nexport const defaultAmbientIcons = {\n ...defaultChatIcons,\n minimize: MinusIcon as BlockIcon,\n dock: PanelLeftIcon as BlockIcon,\n grip: GripVerticalIcon as BlockIcon,\n switchConversation: ChevronDownIcon as BlockIcon,\n notify: BellIcon as BlockIcon,\n openFull: ExternalLinkIcon as BlockIcon,\n goodResponse: ThumbsUpIcon as BlockIcon,\n badResponse: ThumbsDownIcon as BlockIcon,\n};\nexport type AmbientIcons = ChatIcons & typeof defaultAmbientIcons;\nconst AMBIENT_TEXT = { labels: defaultAmbientLabels as AmbientLabels, icons: defaultAmbientIcons as AmbientIcons };\n\n/** `bar` is the compact bar, `expanded` the same window grown into a chat, `minimized` a small pill. */\nexport type AmbientState = 'bar' | 'expanded' | 'minimized';\n\n/** What the artifact panel inside the window gets besides the full-screen and close controls: the versions of the open artifact and how to switch. */\nexport interface AmbientArtifactPanelApi extends WorkspacePanelApi {\n /** Every known version of the open artifact in this conversation, newest first. */\n readonly versions: readonly ArtifactDescriptor[];\n /** True while the panel follows the newest version; false when one older version is pinned. */\n readonly follow: boolean;\n /** `'latest'` follows the newest version; a descriptor from `versions` pins that one. */\n readonly select: (version: ArtifactDescriptor | 'latest') => void;\n}\n\nexport interface AmbientChatProps extends ChatFeatureProps {\n readonly controller: NativeChatController;\n /** Embed the expanded conversation in a host workspace. Switching this flag changes layout without remounting the transcript or composer. The host supplies the header. */\n readonly embedded?: boolean;\n /**\n * Every word the window shows, over `defaultAmbientLabels` (the chat's labels, the toasts' and its own): `title` while the conversation has\n * none of its own, `placeholder` while idle, `working(seconds)` while the agent works.\n */\n readonly labels?: Partial<AmbientLabels> | undefined;\n /** The window's icons (minimise, dock, grip, …) and the chat's, over `defaultAmbientIcons`. */\n readonly icons?: Partial<AmbientIcons> | undefined;\n /** The agent's avatar before the title in the bar. */\n readonly avatar?: ReactNode;\n /** Host actions in the bar, before Dock: `header` ones as buttons, `menu` ones in a \"…\" menu. Test ids `ambient-action-<id>`, the menu `ambient-action-more`. */\n readonly headerActions?: readonly BlockAction[] | undefined;\n /** Host-owned header controls, such as icon-anchored popovers, before the actions and Dock button. */\n readonly headerTools?: ReactNode;\n /** `contrast` is a dark window on any page: the root carries `.dark`, so it shows the host's own dark tokens; `surface` follows the host's theme. */\n readonly variant?: 'contrast' | 'surface';\n /** Controlled window state; otherwise `defaultState` starts it and the window keeps it. */\n readonly state?: AmbientState;\n readonly defaultState?: AmbientState;\n readonly onStateChange?: (state: AmbientState) => void;\n /** Where the bar was dragged is remembered under this key. */\n readonly storageKey?: string;\n /** `session` (default) remembers the position for this browser tab's session; `local` keeps it in `localStorage` across sessions. */\n readonly storage?: 'session' | 'local';\n /** Host controls in the composer row, between the effort picker and Send (for example a voice button the host guards itself). Absent: nothing is shown. */\n readonly tools?: ReactNode;\n /** Host content under the composer (for example a collapsed list of the person's requests). Absent: nothing is shown. */\n readonly belowComposer?: ReactNode;\n /** Two-row reference composer by default; older layouts require an explicit choice. */\n readonly composerLayout?: ComposerProps['layout'];\n /** Hand the conversation to a full `PiChat` or `ArtifactWorkspace`. Adds an \"Open in full chat\" action to replies. */\n readonly onOpenFull?: (conversationId: string) => void;\n /** Adds thumbs up and down to replies. `value` is `null` when the person takes the rating back. */\n readonly onFeedback?: (reply: { readonly key: string; readonly text: string }, value: 'up' | 'down' | null) => void;\n /** Toasts for finished, failed and waiting tasks. Pass a store to feed it for background conversations too; `false` turns them off. */\n readonly notifications?: NotificationStore | false;\n readonly autoDismissMs?: number;\n readonly maxToasts?: number;\n /** System notifications while the page is hidden. A bell in the header asks for permission when the person chooses to; nothing is asked on load. */\n readonly systemNotifications?: boolean;\n /**\n * The viewer shown inside the window when an artifact card is opened: the window widens into chat on the left and this panel on the right\n * (`ArtifactWorkspace`: draggable divider, full screen, a full-screen sheet on a phone). Build it from the viewer registry (`ViewerFrame`, ...),\n * wrapped in `ViewerWindowProvider` with `api.fullscreen` / `api.onFullscreenChange` so its bar shows the full screen button.\n */\n readonly artifactPanel?: (artifact: ArtifactDescriptor, api: AmbientArtifactPanelApi) => ReactNode;\n /** `window` (default when `artifactPanel` is given) opens artifacts inside the window; `host` calls `artifacts.open` instead, for hosts that show them in their own viewer. */\n readonly artifactTarget?: 'window' | 'host';\n /** The title's ⌄ opens this list to switch conversations or tasks. */\n readonly conversations?: ConversationsConfig;\n /** Adds a \"Dock chat\" action to the header, for a host that floats this window out of a docked chat (`ArtifactWorkspace` with `floatBelow`). It shares the controller with the docked `PiChat`, so nothing is lost. */\n readonly onDock?: () => void;\n /** The optional annotate toggle and notes chip in the composer (the registry `feedback` item's `useComposerFeedback`). Omit it and nothing changes. */\n readonly feedback?: ComposerFeedback;\n readonly className?: string;\n}\n\ninterface Position { readonly right: number; readonly bottom: number }\nconst MARGIN = 8, DEFAULT_POSITION: Position = { right: 16, bottom: 16 };\n\nfunction storageFor(persist: 'session' | 'local'): Storage | undefined { try { return persist === 'local' ? globalThis.localStorage : globalThis.sessionStorage; } catch { return undefined; } }\nfunction readPosition(key: string, persist: 'session' | 'local'): Position | undefined {\n try {\n const value: unknown = JSON.parse(storageFor(persist)?.getItem(key) ?? 'null');\n const { right, bottom } = (value ?? {}) as { right?: unknown; bottom?: unknown };\n return typeof right === 'number' && typeof bottom === 'number' && Number.isFinite(right) && Number.isFinite(bottom) ? { right, bottom } : undefined;\n } catch { return undefined; }\n}\nfunction writePosition(key: string, persist: 'session' | 'local', position: Position) {\n try { storageFor(persist)?.setItem(key, JSON.stringify(position)); } catch { /* the position is a convenience */ }\n}\nconst clamp = (position: Position, size: { w: number; h: number }, viewport: { w: number; h: number }): Position => ({\n right: Math.round(Math.min(Math.max(MARGIN, position.right), Math.max(MARGIN, viewport.w - size.w - MARGIN))),\n bottom: Math.round(Math.min(Math.max(MARGIN, position.bottom), Math.max(MARGIN, viewport.h - size.h - MARGIN))),\n});\n\n// The layout viewport that fixed positioning uses (without scrollbars), not the window's outer size.\nconst viewportSize = () => { const root = globalThis.document?.documentElement; return { w: root?.clientWidth || globalThis.innerWidth || 1024, h: root?.clientHeight || globalThis.innerHeight || 768 }; };\nexport function usePhone(): boolean {\n const query = `(max-width: ${PHONE_BELOW - 1}px)`;\n const [phone, setPhone] = useState(() => globalThis.matchMedia?.(query).matches === true);\n useEffect(() => {\n const list = globalThis.matchMedia?.(query);\n if (!list) return;\n const update = () => setPhone(list.matches);\n update();\n list.addEventListener('change', update);\n return () => list.removeEventListener('change', update);\n }, []);\n return phone;\n}\n\n/** Whole seconds since `active` became true; 0 while inactive. */\nfunction useElapsed(active: boolean): number {\n const [seconds, setSeconds] = useState(0);\n useEffect(() => {\n if (!active) { setSeconds(0); return; }\n const started = Date.now();\n setSeconds(0);\n const timer = setInterval(() => setSeconds(Math.floor((Date.now() - started) / 1000)), 1000);\n return () => clearInterval(timer);\n }, [active]);\n return seconds;\n}\n\n/** Pushes a toast when a run finishes or fails and when a question starts waiting; takes it back when the question is answered or a new run starts. */\nfunction useRunFeed({ enabled, store, conversationId, title, session, suppress, onOutcome, waiting }: {\n readonly enabled: boolean; readonly store: NotificationStore; readonly conversationId: string; readonly title: string; readonly session: ChatSession; readonly waiting: string;\n readonly suppress: () => boolean; readonly onOutcome: (outcome: 'done' | 'error' | null) => void;\n}) {\n const { working, waitingForAnswer, rows, connected } = session;\n const before = useRef({ working, waiting: waitingForAnswer });\n const latest = useRef({ rows, title, suppress, onOutcome, connected, waiting });\n latest.current = { rows, title, suppress, onOutcome, connected, waiting };\n useEffect(() => {\n const was = before.current;\n before.current = { working, waiting: waitingForAnswer };\n const now = latest.current;\n if (working && !was.working) { now.onOutcome(null); store.dismiss(`${conversationId}:done`); }\n if (!enabled) return;\n if (waitingForAnswer && !was.waiting && !now.suppress()) store.push({ id: `${conversationId}:input`, kind: 'input', title: now.title, summary: pendingQuestion(now.rows) ?? now.waiting, conversationId });\n if (!waitingForAnswer && was.waiting) store.dismiss(`${conversationId}:input`);\n if (was.working && !working && now.connected) {\n const finished = finishNotice(now.rows);\n if (!finished) return;\n now.onOutcome(finished.kind);\n if (!now.suppress()) store.push({ id: `${conversationId}:done`, kind: finished.kind, title: now.title, summary: finished.summary, conversationId });\n }\n }, [working, waitingForAnswer, enabled, store, conversationId]);\n}\n\n/** A compact floating agent bar over an existing page; it grows into a chat window in place. Borrows a concrete native controller like `PiChat`. */\nexport function AmbientChat(props: AmbientChatProps) {\n const active = useRef(props.controller); active.current = props.controller;\n const [mount, setMount] = useState({ controller: props.controller, sequence: 0 });\n if (mount.controller !== props.controller) setMount({ controller: props.controller, sequence: mount.sequence + 1 });\n // The window state and the toasts belong to the bar, not to one conversation: they survive switching conversations.\n const [local, setLocal] = useState<AmbientState>(props.defaultState ?? 'bar');\n if (props.embedded && local !== 'expanded') setLocal('expanded');\n const internalStore = useMemo(createNotificationStore, []);\n const text = useMergedText<AmbientLabels, AmbientIcons>(props.labels, props.icons, AMBIENT_TEXT);\n return <ChatTextProvider value={text}><MessageScrollerProvider key={mount.sequence} autoScroll><AmbientSession {...props} labels={text.labels} icons={text.icons} activeController={active} windowState={props.embedded ? 'expanded' : props.state ?? local} store={props.notifications || internalStore}\n onWindowState={next => { if (props.state === undefined) setLocal(next); props.onStateChange?.(next); }} /></MessageScrollerProvider></ChatTextProvider>;\n}\n\ntype SessionProps = Omit<AmbientChatProps, 'labels' | 'icons'> & { readonly labels: AmbientLabels; readonly icons: AmbientIcons; readonly activeController: { readonly current: NativeChatController }; readonly windowState: AmbientState; readonly store: NotificationStore; readonly onWindowState: (state: AmbientState) => void };\n\nfunction AmbientSession({ controller, embedded = false, labels, icons, avatar, headerActions, headerTools, messageActions, variant = 'contrast', mode = 'expert', presentation, actions,\n windowState, onWindowState, store, storageKey = 'boring.ambient.position', storage = 'session', tools, belowComposer, composerChips, composerLayout = 'card', onOpenFull, onFeedback, notifications, autoDismissMs, maxToasts, systemNotifications = false,\n renderEntry, hideLine, displayLimit, historicalMessageActions, unread: replyUnread, replyError, renderTool, toolRenderers, renderText, cards, groupTool, commandMentions, onOpenImage, onCopy, onComposerKeyDown, fileAccept = 'image/*', slash, mentions, attachments, onOpenFile, fileChanges, pasteToFile, model, effort, replyFooter, artifacts: hostArtifacts, artifactPanel, artifactTarget, conversations, onDock, feedback: composerFeedback, className,\n activeController }: SessionProps) {\n const { title } = labels;\n useChatPlacement(controller, embedded ? 'docked' : 'floating');\n const stateRef = useRef(windowState); stateRef.current = windowState;\n // The artifact open inside the window, and whether it follows the newest version. It belongs to the expanded window: leaving that state closes it.\n const [opened, setOpened] = useState<{ readonly descriptor: ArtifactDescriptor; readonly follow: boolean } | null>(null);\n const [fullscreen, setFullscreen] = useState(false);\n const setWindowState = (next: AmbientState) => {\n if (next !== 'expanded') { setOpened(null); setFullscreen(false); }\n if (next !== stateRef.current) onWindowState(next);\n };\n const expanded = windowState === 'expanded', minimized = windowState === 'minimized';\n const phone = usePhone();\n const root = useRef<HTMLDivElement>(null), titleButton = useRef<HTMLButtonElement>(null), pill = useRef<HTMLButtonElement>(null);\n const [picking, setPicking] = useState(false);\n const [feedback, setFeedback] = useState<Readonly<Record<string, 'up' | 'down'>>>({});\n const [outcome, setOutcome] = useState<'done' | 'error' | null>(null);\n const [permission, setPermission] = useState<NotifyPermission>('unsupported');\n useEffect(() => { setPermission(notifyPermission()); }, []);\n useEffect(() => { if (!outcome) return; const timer = setTimeout(() => setOutcome(null), 8000); return () => clearTimeout(timer); }, [outcome]);\n\n // Replies get copy, optional feedback and the time; the host may hand the conversation to its full chat.\n const conversationRef = useRef('');\n const replyActions = useMemo<NonNullable<RowContext['replyActions']>>(() => (reply, host) => <div data-testid=\"reply-actions\" className=\"mt-2 -ml-2 flex items-center gap-0.5 opacity-0 transition-opacity group-hover/message:opacity-100 group-focus-within/message:opacity-100 max-sm:opacity-100 pointer-coarse:opacity-100 motion-reduce:transition-none\">\n <CopyButton text={reply.text} label={labels.copyResponse} iconOnly {...(onCopy ? { onCopy } : {})} />\n {onFeedback && <>\n {(['up', 'down'] as const).map(value => <Toggle key={value} data-testid={`feedback-${value}`} pressed={feedback[reply.key] === value} aria-label={value === 'up' ? labels.goodResponse : labels.badResponse}\n title={value === 'up' ? labels.goodResponse : labels.badResponse}\n onPressedChange={pressed => { const next = pressed ? value : null; setFeedback(current => { const { [reply.key]: _removed, ...rest } = current; return next ? { ...rest, [reply.key]: next } : rest; }); onFeedback({ key: reply.key, text: reply.text }, next); }}>\n {value === 'up' ? <icons.goodResponse data-icon=\"inline-start\" aria-hidden=\"true\" /> : <icons.badResponse data-icon=\"inline-start\" aria-hidden=\"true\" />}</Toggle>)}\n </>}\n {onOpenFull && <Button variant=\"ghost\" size=\"icon\" data-testid=\"open-full\" aria-label={labels.openFull} title={labels.openFull} onClick={() => onOpenFull(conversationRef.current)}><icons.openFull data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>}\n {host}\n {reply.timestamp !== undefined && <time dateTime={new Date(reply.timestamp).toISOString()} data-testid=\"reply-time\" className=\"ml-1.5 text-xs tabular-nums text-muted-foreground\">{new Date(reply.timestamp).toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' })}</time>}\n </div>, [onCopy, onFeedback, onOpenFull, feedback, labels, icons]);\n const scrollToBottom = useRef<() => void>(() => {});\n const rowExtras = useMemo(() => ({ replyActions }), [replyActions]);\n const inWindow = Boolean(artifactPanel) && artifactTarget !== 'host';\n const detect = hostArtifacts?.detect;\n const versionsRef = useRef<readonly ArtifactDescriptor[]>([]);\n const artifacts = useMemo<ArtifactsConfig | undefined>(() => !inWindow ? hostArtifacts : {\n open: descriptor => { const newest = versionsRef.current.filter(version => artifactKey(version) === artifactKey(descriptor)).reduce((top, version) => Math.max(top, version.ordinal ?? 0), 0); setOpened({ descriptor, follow: (descriptor.ordinal ?? 0) >= newest }); },\n isOpen: descriptor => opened !== null && artifactKey(opened.descriptor) === artifactKey(descriptor) && (opened.follow ? (descriptor.ordinal ?? 0) >= (versionsRef.current.find(version => artifactKey(version) === artifactKey(descriptor))?.ordinal ?? 0) : descriptor.revision === opened.descriptor.revision),\n ...(detect ? { detect } : {}),\n ...(hostArtifacts?.kinds ? { kinds: hostArtifacts.kinds } : {}),\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [inWindow, hostArtifacts, opened, detect]);\n const session = useChatSession({ controller, activeController, mode, presentation, actions, renderEntry, hideLine, historicalMessageActions, unread: replyUnread, replyError, renderTool, toolRenderers, renderText, cards, groupTool, commandMentions, onOpenImage, onCopy, onComposerKeyDown, fileAccept,\n slash, mentions, attachments, model, effort, artifacts, messageActions, replyFooter, afterSend: () => scrollToBottom.current(), rowExtras, feedback: composerFeedback, onOpenFile, fileChanges, pasteToFile, composerChips });\n const { state, derived, rows, queued, queueActions, working, waitingForAnswer, connected, error, act, textarea, rowContext, composer, files, loading } = session;\n const empty = session.empty;\n const transcript = useTranscript(rows, displayLimit ?? 60, 40, { state: state.history, connected: !state.disposed && connected, load: controller.loadEarlier }, displayLimit === undefined);\n const versions = useMemo(() => inWindow ? collectArtifacts(state.view, detect).sort((a, b) => (b.ordinal ?? 0) - (a.ordinal ?? 0)) : [], [inWindow, state.view, detect]);\n versionsRef.current = versions;\n const known = opened ? versions.filter(version => artifactKey(version) === artifactKey(opened.descriptor)) : [];\n const shownArtifact = opened ? (opened.follow ? known[0] ?? opened.descriptor : opened.descriptor) : undefined;\n const { stick, hidden } = transcript;\n scrollToBottom.current = () => { transcript.reset(); void stick.scrollToBottom('instant'); };\n const { barStart, barNote } = session.pickers();\n\n const conversationId = String(conversations?.activeId ?? state.conversationId);\n conversationRef.current = conversationId;\n const activeItem = conversations?.items.find(item => item.id === conversations.activeId);\n const shownTitle = activeItem?.title?.trim() || title;\n const toasts = useNotifications(store);\n useRunFeed({ enabled: notifications !== false, store, conversationId, title: shownTitle, session, onOutcome: setOutcome, waiting: labels.waitingForAnswer,\n suppress: () => stateRef.current === 'expanded' && globalThis.document?.visibilityState !== 'hidden' });\n const unread = !expanded && toasts.length > 0;\n const seconds = useElapsed(working);\n\n // Position: offsets from the bottom right corner, remembered for the session, clamped to the viewport at render so a smaller window restores them.\n const [position, setPosition] = useState<Position>(() => readPosition(storageKey, storage) ?? DEFAULT_POSITION);\n const [viewport, setViewport] = useState(viewportSize);\n const [size, setSize] = useState({ w: 0, h: 0 });\n useEffect(() => {\n const onResize = () => setViewport(viewportSize());\n globalThis.addEventListener('resize', onResize);\n return () => globalThis.removeEventListener('resize', onResize);\n }, []);\n useEffect(() => {\n const element = root.current;\n if (!element || typeof ResizeObserver === 'undefined') return;\n const observer = new ResizeObserver(() => setSize({ w: element.offsetWidth, h: element.offsetHeight }));\n observer.observe(element);\n setSize({ w: element.offsetWidth, h: element.offsetHeight });\n return () => observer.disconnect();\n }, [minimized, expanded]);\n // The size and the viewport also follow every render, so the clamp below never works from a stale measurement (the title or status icon can change the pill's width; a scrollbar can appear without a resize event).\n useLayoutEffect(() => {\n const element = root.current;\n const fresh = viewportSize();\n setViewport(current => current.w === fresh.w && current.h === fresh.h ? current : fresh);\n if (element) setSize(current => current.w === element.offsetWidth && current.h === element.offsetHeight ? current : { w: element.offsetWidth, h: element.offsetHeight });\n });\n const placed = clamp(position, size, viewport);\n // Moves and the release are followed on the window, so a drag keeps working when the pointer leaves the handle (and when pointer capture is unavailable).\n // `threshold` is how far the pointer must travel before it is a drag: a click on the pill (threshold > 0) still restores it.\n const dragged = useRef(false);\n const beginDrag = (event: PointerEvent<HTMLElement>, threshold: number) => {\n if (phone || event.button !== 0) return;\n dragged.current = false;\n const origin = { pointer: event.pointerId, x: event.clientX, y: event.clientY, start: placed };\n let last = origin.start, moving = threshold === 0;\n const move = (next: globalThis.PointerEvent) => {\n if (next.pointerId !== origin.pointer) return;\n if (!moving) { if (Math.hypot(next.clientX - origin.x, next.clientY - origin.y) < threshold) return; moving = true; dragged.current = true; }\n last = clamp({ right: origin.start.right - (next.clientX - origin.x), bottom: origin.start.bottom - (next.clientY - origin.y) }, size, viewport);\n setPosition(last);\n };\n const end = (next: globalThis.PointerEvent) => {\n if (next.pointerId !== origin.pointer) return;\n globalThis.removeEventListener('pointermove', move); globalThis.removeEventListener('pointerup', end); globalThis.removeEventListener('pointercancel', end);\n if (moving) writePosition(storageKey, storage, last);\n };\n globalThis.addEventListener('pointermove', move); globalThis.addEventListener('pointerup', end); globalThis.addEventListener('pointercancel', end);\n };\n const onGripDown = (event: PointerEvent<HTMLButtonElement>) => { event.preventDefault(); beginDrag(event, 0); };\n // Arrow keys move the window (Shift for big steps); Home puts it back in the default corner.\n const onGripKey = (event: KeyboardEvent<HTMLElement>) => {\n if (phone) return;\n const step = event.shiftKey ? 64 : 16;\n const delta = event.key === 'ArrowLeft' ? { right: step, bottom: 0 } : event.key === 'ArrowRight' ? { right: -step, bottom: 0 } : event.key === 'ArrowUp' ? { right: 0, bottom: step } : event.key === 'ArrowDown' ? { right: 0, bottom: -step } : undefined;\n if (!delta && event.key !== 'Home') return;\n event.preventDefault();\n const next = delta ? clamp({ right: placed.right + delta.right, bottom: placed.bottom + delta.bottom }, size, viewport) : clamp(DEFAULT_POSITION, size, viewport);\n setPosition(next); writePosition(storageKey, storage, next);\n };\n const grip = (className?: string) => <Button variant=\"ghost\" size=\"icon\" data-testid=\"ambient-grip\" aria-label={labels.move} title={labels.moveHint} onPointerDown={onGripDown} onKeyDown={onGripKey}\n className={cn('touch-none', className)}><icons.grip data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>;\n\n const expand = () => { setOutcome(null); store.dismissConversation(conversationId); setWindowState('expanded'); };\n useEffect(() => {\n if (expanded && !globalThis.matchMedia?.('(pointer: coarse)').matches) textarea.current?.focus();\n }, [expanded, textarea]);\n // The title is disabled while the window is open, so focus goes back to it once the bar is showing again.\n const refocusTitle = useRef(false);\n useEffect(() => { if (!expanded && refocusTitle.current) { refocusTitle.current = false; titleButton.current?.focus(); } }, [expanded]);\n const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {\n if (embedded || event.key !== 'Escape' || event.defaultPrevented || !expanded || picking) return;\n event.preventDefault();\n // Escape steps back: full screen, then the artifact, then the window.\n if (fullscreen) return setFullscreen(false);\n if (opened) return setOpened(null);\n refocusTitle.current = true;\n setWindowState('bar');\n };\n const onActivate = useCallback((item: AgentNotification) => {\n if (item.conversationId !== undefined && item.conversationId !== conversationId) conversations?.onSelect(item.conversationId);\n expand();\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [conversationId, conversations]);\n\n const connectionKind = state.connection.kind;\n const status = waitingForAnswer ? 'input' : working ? 'working' : outcome;\n const label = waitingForAnswer ? labels.waitingForAnswer : labels.working(seconds);\n const panelOpen = expanded && shownArtifact !== undefined && artifactPanel !== undefined;\n // The floating window is drawn by the host's Card behind it (a sibling layer, so switching placement never remounts the chat);\n // embedded and full-screen phone windows are plain page regions.\n const card = !embedded && !(phone && expanded);\n const surface = embedded ? 'relative flex min-h-0 flex-1 flex-col overflow-clip bg-transparent text-foreground' : cn('relative flex flex-col', card ? 'isolate' : 'h-dvh bg-background pt-[env(safe-area-inset-top)] text-foreground',\n !phone && expanded && (panelOpen ? 'h-full overflow-hidden' : 'max-h-[70dvh]'));\n // With an artifact open the window widens (most of the viewport, at most 72rem) and takes a fixed height so the panel fills it.\n const rootClass = embedded ? cn('pi-chat relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden', className) : cn('pi-chat pi-ambient fixed z-50', variant === 'contrast' && 'dark scheme-dark', phone ? (expanded ? 'inset-0' : 'inset-x-2 bottom-[max(0.5rem,env(safe-area-inset-bottom))]')\n : cn('transition-[width] motion-reduce:transition-none', minimized ? 'w-auto' : panelOpen ? 'h-[min(46rem,calc(100dvh-1rem))] w-[min(72rem,calc(100vw-1rem))]' : expanded ? 'w-[min(35rem,calc(100vw-1rem))]' : 'w-[min(26rem,calc(100vw-1rem))]'), className);\n const rootStyle = phone || embedded ? undefined : { right: placed.right, bottom: placed.bottom };\n\n if (minimized) return <div ref={root} data-boring=\"ambient-chat\" data-state=\"minimized\" data-variant={variant} className={cn(rootClass, phone && 'flex justify-end')} style={rootStyle} onKeyDown={onKeyDown}>\n {notifications !== false && <AgentNotifications store={store} variant={variant} placement=\"inline\" systemNotifications={systemNotifications} onActivate={onActivate} labels={labels}\n {...(autoDismissMs !== undefined ? { autoDismissMs } : {})} {...(maxToasts !== undefined ? { max: maxToasts } : {})} className=\"absolute right-0 bottom-full mb-3 w-[min(26rem,calc(100vw-1rem))]\" />}\n <div data-testid=\"ambient-pill-box\" onPointerDown={event => beginDrag(event, 4)} onKeyDown={onGripKey} onClickCapture={event => { if (dragged.current) { dragged.current = false; event.stopPropagation(); event.preventDefault(); } }}\n className={cn('relative flex max-w-[min(20rem,calc(100vw-1rem))] items-center gap-0.5', !phone && 'cursor-grab touch-none select-none active:cursor-grabbing')}>\n {!phone && grip('ml-1.5')}\n <Button ref={pill} variant=\"outline\" size=\"lg\" data-testid=\"ambient-pill\" onClick={() => setWindowState('bar')} aria-label={labels.openAgent(shownTitle, unread)}\n className=\"relative min-w-0 gap-2.5\">\n {status === 'working' ? <Loader2Icon data-icon=\"inline-start\" className=\"animate-spin motion-reduce:animate-none\" aria-hidden=\"true\" />\n : status === 'input' ? <MessageCircleQuestionIcon data-icon=\"inline-start\" aria-hidden=\"true\" />\n : status === 'done' ? <CircleCheckIcon data-icon=\"inline-start\" aria-hidden=\"true\" />\n : status === 'error' ? <CircleAlertIcon data-icon=\"inline-start\" aria-hidden=\"true\" /> : null}\n <span className=\"min-w-0 truncate\">{shownTitle}</span>\n {unread && <Badge data-testid=\"ambient-unread\" aria-hidden=\"true\" className=\"absolute -top-1.5 -right-1.5\" />}\n </Button>\n </div>\n </div>;\n\n const visibleRows = transcript.visible(rows);\n const conversation = <section className=\"flex min-h-0 min-w-0 flex-1 flex-col\">\n <header hidden={embedded} data-testid=\"ambient-header\" className={cn(embedded && '!hidden','flex h-12 shrink-0 items-center gap-0.5 px-2 max-sm:h-14 max-sm:px-3')}>\n <Button variant=\"ghost\" size=\"icon\" data-testid=\"ambient-minimize\" aria-label={labels.minimize} title={labels.minimize} onClick={() => { setPicking(false); setWindowState(expanded ? 'bar' : 'minimized'); }}><icons.minimize data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>\n <span data-testid=\"ambient-status\" data-status={status ?? 'idle'} role=\"img\" aria-label={status === 'working' ? labels.statusWorking : status === 'input' ? labels.needsInput : status === 'done' ? labels.finished : status === 'error' ? labels.failedRun : undefined}\n aria-hidden={status ? undefined : 'true'} className={cn('flex shrink-0 items-center justify-center', status ? 'mx-1.5 w-5' : 'w-1.5')}>\n {status === 'working' ? <Loader2Icon className=\"size-4 animate-spin text-muted-foreground motion-reduce:animate-none\" aria-hidden=\"true\" /> : status ? <KindIcon kind={status === 'input' ? 'input' : status} className=\"size-[1.125rem]\" /> : null}\n </span>\n {avatar && <Avatar size=\"sm\" data-testid=\"ambient-avatar\" className=\"overflow-hidden\"><AvatarFallback className=\"*:max-h-full *:max-w-full\">{avatar}</AvatarFallback></Avatar>}\n <Button ref={titleButton} variant=\"ghost\" data-testid=\"ambient-title\" onClick={expand} disabled={expanded} aria-label={expanded ? shownTitle : labels.openChat(shownTitle)}\n className=\"min-w-0 max-w-full shrink\"><span className=\"truncate\">{shownTitle}</span></Button>\n {conversations && <Button variant=\"ghost\" size=\"icon\" data-testid=\"ambient-switch\" aria-label={labels.switchConversation} aria-haspopup=\"dialog\" aria-expanded={picking} title={labels.switchConversation} onClick={() => setPicking(open => !open)}>\n <icons.switchConversation data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>}\n {expanded ? <span className=\"flex-1\" /> : <Button variant=\"ghost\" tabIndex={-1} aria-hidden=\"true\" data-testid=\"ambient-header-fill\" onClick={expand} className=\"min-w-2 flex-1\" />}\n {systemNotifications && permission === 'default' && <Button variant=\"ghost\" size=\"icon\" data-testid=\"ambient-enable-notifications\" aria-label={labels.enableNotifications} title={labels.enableNotifications}\n onClick={() => { void requestNotifyPermission().then(setPermission); }}><icons.notify data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>}\n {headerTools}\n <BlockActions actions={headerActions} testId=\"ambient-action\" menuLabel={labels.moreActions} />\n {onDock && !phone && <Button variant=\"ghost\" size=\"icon\" data-testid=\"ambient-dock\" aria-label={labels.dock} title={labels.dockHint} onClick={() => { setPicking(false); onDock(); }}><icons.dock data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>}\n {!phone && grip()}\n </header>\n\n {expanded && <div className=\"flex min-h-0 flex-1 flex-col\">\n {(connectionKind === 'error' || connectionKind === 'closed') && <div className=\"px-4 pb-2\"><Notice tone={connectionKind === 'error' ? 'error' : 'info'} testid=\"connection-notice\">\n <span className=\"flex-1\">{connectionKind === 'error' ? labels.loadFailed : labels.disconnected}</span>\n {!state.disposed && <Button size=\"sm\" variant=\"outline\" onClick={() => act(controller.connect)}><icons.reconnect data-icon=\"inline-start\" aria-hidden=\"true\" />{labels.reconnect}</Button>}</Notice></div>}\n {derived.pinned.length > 0 && <aside aria-label={labels.requiredActions} className=\"shrink-0 space-y-2 px-4 pb-2\">{derived.pinned.map(card => <div key={card.key}>{card.content}</div>)}</aside>}\n <div className=\"relative flex min-h-0 flex-1 flex-col\">\n <MessageScroller>\n <MessageScrollerViewport ref={stick.scrollRef} data-testid=\"transcript-scroll\" aria-label={labels.messages} className=\"scrollbar-thumb-border scrollbar-track-transparent\">\n {!empty && <TranscriptHistory history={state.history} connected={!state.disposed && connected} onLoad={transcript.reveal} sentinelRef={transcript.sentinelRef} hidden={hidden} loaded={transcript.loaded} />}\n <div className=\"px-4 pt-4 pb-6\">\n <MessageScrollerContent aria-label={labels.messages} aria-live=\"polite\" data-testid=\"transcript\">\n {visibleRows.map(row => <MessageScrollerItem key={row.key} messageId={row.key} className=\"[content-visibility:visible]\"><RowView row={row} context={rowContext} /></MessageScrollerItem>)}\n {loading && <p role=\"status\" data-testid=\"loading\" className=\"m-0 flex items-center gap-2 text-sm text-muted-foreground\"><Loader2Icon className=\"size-4 animate-spin motion-reduce:animate-none\" aria-hidden=\"true\" />{labels.loading}</p>}\n {empty && !loading && <p data-testid=\"ambient-empty\" className=\"m-0 max-w-sm py-8 text-sm leading-6 text-muted-foreground\">{labels.empty}</p>}\n </MessageScrollerContent>\n </div>\n </MessageScrollerViewport>\n {!stick.isAtBottom && <MessageScrollerButton data-testid=\"jump-latest\" aria-label={labels.jumpToLatest} title={labels.jumpToLatest} onClick={transcript.reset}><icons.jumpToLatest aria-hidden=\"true\" /></MessageScrollerButton>}\n </MessageScroller>\n </div>\n </div>}\n\n {(expanded || !embedded) && <Separator />}\n <div className={cn('shrink-0', phone && 'pb-[env(safe-area-inset-bottom)]')}>\n <div className=\"space-y-2 px-3 empty:hidden [&:has(*)]:pt-2\">\n {preparing({ natural: session.natural, waitingForAnswer, run: derived.run, retry: derived.retry, compacting: derived.compacting, stop: state.stop }) && <PreparingStatus>{derived.stageStatus}</PreparingStatus>}\n <ChatNotices natural={session.natural} state={state} error={error} onReconcile={() => act(controller.reconcile)} onRetry={() => act(controller.retrySameRequest)} />\n {!expanded && (connectionKind === 'error' || connectionKind === 'closed') && <Notice tone=\"info\" testid=\"connection-notice\">\n <span className=\"flex-1\">{connectionKind === 'error' ? labels.loadFailedShort : labels.disconnectedShort}</span>\n {!state.disposed && <Button size=\"sm\" variant=\"outline\" onClick={() => act(controller.connect)}>{labels.reconnect}</Button>}</Notice>}\n </div>\n <div className=\"px-3 pt-2 pb-3\">\n <ConversationFiles files={files} />\n <MessageQueue items={queued} sending={state.outbox} withdraw={actions?.withdraw} actions={queueActions} />\n <Composer {...composer} layout={composerLayout} placeholder={working || waitingForAnswer ? label : labels.placeholder} barStart={barStart} barNote={barNote} barEnd={tools} />\n {belowComposer !== undefined && <div data-testid=\"below-composer\" className=\"pt-2\">{belowComposer}</div>}\n </div>\n </div>\n </section>;\n return <div ref={root} data-boring={embedded ? 'pi-chat' : 'ambient-chat'} data-state={windowState} data-variant={variant} data-phone={phone ? 'true' : undefined} role=\"region\" aria-label={labels.region(shownTitle)}\n className={rootClass} style={rootStyle} onKeyDown={onKeyDown}>\n {!embedded && notifications !== false && !(phone && expanded) && <AgentNotifications store={store} variant={variant} placement=\"inline\" systemNotifications={systemNotifications} onActivate={onActivate} labels={labels}\n {...(autoDismissMs !== undefined ? { autoDismissMs } : {})} {...(maxToasts !== undefined ? { max: maxToasts } : {})} className=\"absolute right-0 bottom-full mb-3 w-full\" />}\n {picking && conversations && <ConversationHistory conversations={conversations} placement={expanded ? 'below' : 'above'} onClose={() => setPicking(false)} />}\n <div data-testid=\"ambient-surface\" className={surface}>\n {card && <Card aria-hidden=\"true\" data-boring=\"chrome\" className=\"pointer-events-none absolute inset-0 -z-10\" />}\n {artifactPanel ? <ArtifactWorkspace open={panelOpen} onClose={() => { setOpened(null); setFullscreen(false); }} fullscreen={fullscreen} onFullscreenChange={setFullscreen} storageKey=\"boring.ambient.panel-width\" panelLabel={labels.artifactPanel}\n defaultWidth={600} minPanel={320} minChat={300} sheetBelow={720}\n panel={api => shownArtifact ? artifactPanel(shownArtifact, { ...api, versions: known, follow: opened?.follow ?? true,\n select: version => setOpened(current => current ? (version === 'latest' ? { descriptor: known[0] ?? current.descriptor, follow: true } : { descriptor: version, follow: false }) : current) }) : null}\n chat={conversation} /> : conversation}\n </div>\n </div>;\n}\n",
|
|
34
|
+
"content": "'use client';\n\nimport { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';\nimport type { KeyboardEvent, PointerEvent, ReactNode } from 'react';\nimport type { EntryRecord } from '@earendil-works/pi-durable';\nimport type { ImageContent, ToolCall, ToolResultMessage } from '@earendil-works/pi-ai';\nimport type { NativeChatController } from '@hachej/boring-ui-kit/native-chat';\nimport { BellIcon, ChevronDownIcon, CircleAlertIcon, CircleCheckIcon, ExternalLinkIcon, GripVerticalIcon, Loader2Icon, MessageCircleQuestionIcon, MinusIcon, PanelLeftIcon, ThumbsDownIcon, ThumbsUpIcon } from 'lucide-react';\nimport { Button } from '@/components/ui/button';\nimport { MessageScroller, MessageScrollerProvider, MessageScrollerViewport, MessageScrollerContent, MessageScrollerItem, MessageScrollerButton } from '@/components/ui/message-scroller';\nimport { Toggle } from '@/components/ui/toggle';\nimport { Avatar, AvatarFallback } from '@/components/ui/avatar';\nimport { Badge } from '@/components/ui/badge';\nimport { Card } from '@/components/ui/card';\nimport { Separator } from '@/components/ui/separator';\nimport { BlockActions } from '../button/actions';\nimport type { BlockAction, BlockIcon } from '../button/actions';\nimport { useChatPlacement } from '../pi-chat/use-remote-chat';\nimport { CopyButton } from '../pi-chat/code-block';\nimport { Composer } from '../pi-chat/composer';\nimport type { ComposerFeedback, ComposerProps } from '../pi-chat/composer';\nimport { ConversationFiles } from '../pi-chat/conversation-files';\nimport type { AttachmentsConfig, ConversationsConfig, EffortConfig, MentionsConfig, ModelConfig, SlashConfig } from '../pi-chat/config';\nimport { notifyPermission, requestNotifyPermission } from './browser-notify';\nimport type { NotifyPermission } from './browser-notify';\nimport { ConversationHistory, TranscriptHistory } from '../pi-chat/history';\nimport type { CommandMentions } from '../pi-chat/config';\nimport { withChatPlugins } from '../pi-chat-plugins/chat-plugins';\nimport { RowView } from '../pi-chat/message';\nimport type { RowContext } from '../pi-chat/message';\nimport { ChatNotices, Notice, PreparingStatus, preparing } from '../pi-chat/notice';\nimport { AgentNotifications, KindIcon, createNotificationStore, defaultNotificationLabels, finishNotice, pendingQuestion, useNotifications } from './notifications';\nimport type { AgentNotification, NotificationLabels, NotificationStore } from './notifications';\nimport { ChatTextProvider, defaultChatIcons, defaultChatLabels, useMergedText } from '../pi-chat/labels';\nimport type { ChatIcons, ChatLabels } from '../pi-chat/labels';\nimport { MessageQueue } from '../pi-chat/queue';\nimport { artifactKey, collectArtifacts } from '../pi-artifacts/artifact';\nimport type { ArtifactDescriptor, ArtifactsConfig } from '../pi-artifacts/artifact';\nimport { artifactCards } from '../pi-artifacts/artifact-card';\nimport type { ChatCard, Mode } from '../pi-chat/rows';\nimport { useChatSession, useTranscript } from '../pi-chat/session';\nimport type { ChatFeatureProps, ChatSession } from '../pi-chat/session';\nimport { ArtifactWorkspace } from '../pi-workspace/workspace';\nimport type { WorkspacePanelApi } from '../pi-workspace/workspace';\nimport { cn, PHONE_BELOW } from '../utils/utils';\n\nexport { AgentNotifications, createNotificationStore, defaultNotificationLabels, summaryOf, useNotifications, watchConversation } from './notifications';\nexport type { AgentNotification, AgentNotificationsProps, NewNotification, NotificationKind, NotificationLabels, NotificationStore } from './notifications';\n\nconst elapsedText = (seconds: number) => seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m ${String(seconds % 60).padStart(2, '0')}s`;\n\n/** The floating window's own words, over the chat's (`defaultChatLabels`) and the toasts' (`defaultNotificationLabels`). */\nexport const defaultAmbientLabels = {\n ...defaultChatLabels,\n ...defaultNotificationLabels,\n /** Shown while the conversation has no title of its own (the active item of `conversations` provides one). */\n title: 'Agent',\n /** The input's placeholder while idle. */\n placeholder: 'Do anything',\n /** The input's placeholder while the agent works. */\n working: (seconds: number) => `Working for ${elapsedText(seconds)}`,\n region: (title: string) => `Agent: ${title}`,\n openAgent: (title: string, unread: boolean) => `Open agent: ${title}${unread ? ' (new activity)' : ''}`,\n openChat: (title: string) => `Open chat: ${title}`,\n minimize: 'Minimise',\n switchConversation: 'Switch conversation',\n statusWorking: 'Working',\n enableNotifications: 'Notify me when the page is in the background',\n dock: 'Dock chat',\n dockHint: 'Dock chat beside the panel',\n move: 'Move the agent window (arrow keys, Home resets)',\n moveHint: 'Drag to move',\n goodResponse: 'Good response',\n badResponse: 'Bad response',\n openFull: 'Open in full chat',\n empty: 'Ask the agent to do something.',\n loadFailedShort: 'Could not load the conversation.',\n disconnectedShort: 'Disconnected.',\n artifactPanel: 'Artifact panel',\n};\nexport type AmbientLabels = ChatLabels & NotificationLabels & typeof defaultAmbientLabels;\n\n/** The floating window's icons, over the chat's. */\nexport const defaultAmbientIcons = {\n ...defaultChatIcons,\n minimize: MinusIcon as BlockIcon,\n dock: PanelLeftIcon as BlockIcon,\n grip: GripVerticalIcon as BlockIcon,\n switchConversation: ChevronDownIcon as BlockIcon,\n notify: BellIcon as BlockIcon,\n openFull: ExternalLinkIcon as BlockIcon,\n goodResponse: ThumbsUpIcon as BlockIcon,\n badResponse: ThumbsDownIcon as BlockIcon,\n};\nexport type AmbientIcons = ChatIcons & typeof defaultAmbientIcons;\nconst AMBIENT_TEXT = { labels: defaultAmbientLabels as AmbientLabels, icons: defaultAmbientIcons as AmbientIcons };\n\n/** `bar` is the compact bar, `expanded` the same window grown into a chat, `minimized` a small pill. */\nexport type AmbientState = 'bar' | 'expanded' | 'minimized';\n\n/** What the artifact panel inside the window gets besides the full-screen and close controls: the versions of the open artifact and how to switch. */\nexport interface AmbientArtifactPanelApi extends WorkspacePanelApi {\n /** Every known version of the open artifact in this conversation, newest first. */\n readonly versions: readonly ArtifactDescriptor[];\n /** True while the panel follows the newest version; false when one older version is pinned. */\n readonly follow: boolean;\n /** `'latest'` follows the newest version; a descriptor from `versions` pins that one. */\n readonly select: (version: ArtifactDescriptor | 'latest') => void;\n}\n\nexport interface AmbientChatProps extends Omit<ChatFeatureProps, 'artifacts'> {\n readonly controller: NativeChatController;\n /** Artifact cards (`ArtifactsConfig` of `pi-artifacts`); the ambient bar plugs in their card itself. */\n readonly artifacts?: ArtifactsConfig | undefined;\n /** Embed the expanded conversation in a host workspace. Switching this flag changes layout without remounting the transcript or composer. The host supplies the header. */\n readonly embedded?: boolean;\n /**\n * Every word the window shows, over `defaultAmbientLabels` (the chat's labels, the toasts' and its own): `title` while the conversation has\n * none of its own, `placeholder` while idle, `working(seconds)` while the agent works.\n */\n readonly labels?: Partial<AmbientLabels> | undefined;\n /** The window's icons (minimise, dock, grip, …) and the chat's, over `defaultAmbientIcons`. */\n readonly icons?: Partial<AmbientIcons> | undefined;\n /** The agent's avatar before the title in the bar. */\n readonly avatar?: ReactNode;\n /** Host actions in the bar, before Dock: `header` ones as buttons, `menu` ones in a \"…\" menu. Test ids `ambient-action-<id>`, the menu `ambient-action-more`. */\n readonly headerActions?: readonly BlockAction[] | undefined;\n /** Host-owned header controls, such as icon-anchored popovers, before the actions and Dock button. */\n readonly headerTools?: ReactNode;\n /** `contrast` is a dark window on any page: the root carries `.dark`, so it shows the host's own dark tokens; `surface` follows the host's theme. */\n readonly variant?: 'contrast' | 'surface';\n /** Controlled window state; otherwise `defaultState` starts it and the window keeps it. */\n readonly state?: AmbientState;\n readonly defaultState?: AmbientState;\n readonly onStateChange?: (state: AmbientState) => void;\n /** Where the bar was dragged is remembered under this key. */\n readonly storageKey?: string;\n /** `session` (default) remembers the position for this browser tab's session; `local` keeps it in `localStorage` across sessions. */\n readonly storage?: 'session' | 'local';\n /** Host controls in the composer row, between the effort picker and Send (for example a voice button the host guards itself). Absent: nothing is shown. */\n readonly tools?: ReactNode;\n /** Host content under the composer (for example a collapsed list of the person's requests). Absent: nothing is shown. */\n readonly belowComposer?: ReactNode;\n /** Two-row reference composer by default; older layouts require an explicit choice. */\n readonly composerLayout?: ComposerProps['layout'];\n /** Hand the conversation to a full `PiChat` or `ArtifactWorkspace`. Adds an \"Open in full chat\" action to replies. */\n readonly onOpenFull?: (conversationId: string) => void;\n /** Adds thumbs up and down to replies. `value` is `null` when the person takes the rating back. */\n readonly onFeedback?: (reply: { readonly key: string; readonly text: string }, value: 'up' | 'down' | null) => void;\n /** Toasts for finished, failed and waiting tasks. Pass a store to feed it for background conversations too; `false` turns them off. */\n readonly notifications?: NotificationStore | false;\n readonly autoDismissMs?: number;\n readonly maxToasts?: number;\n /** System notifications while the page is hidden. A bell in the header asks for permission when the person chooses to; nothing is asked on load. */\n readonly systemNotifications?: boolean;\n /**\n * The viewer shown inside the window when an artifact card is opened: the window widens into chat on the left and this panel on the right\n * (`ArtifactWorkspace`: draggable divider, full screen, a full-screen sheet on a phone). Build it from the viewer registry (`ViewerFrame`, ...),\n * wrapped in `ViewerWindowProvider` with `api.fullscreen` / `api.onFullscreenChange` so its bar shows the full screen button.\n */\n readonly artifactPanel?: (artifact: ArtifactDescriptor, api: AmbientArtifactPanelApi) => ReactNode;\n /** `window` (default when `artifactPanel` is given) opens artifacts inside the window; `host` calls `artifacts.open` instead, for hosts that show them in their own viewer. */\n readonly artifactTarget?: 'window' | 'host';\n /** The title's ⌄ opens this list to switch conversations or tasks. */\n readonly conversations?: ConversationsConfig;\n /** Adds a \"Dock chat\" action to the header, for a host that floats this window out of a docked chat (`ArtifactWorkspace` with `floatBelow`). It shares the controller with the docked `PiChat`, so nothing is lost. */\n readonly onDock?: () => void;\n /** The optional annotate toggle and notes chip in the composer (the registry `feedback` item's `useComposerFeedback`). Omit it and nothing changes. */\n readonly feedback?: ComposerFeedback;\n readonly className?: string;\n}\n\ninterface Position { readonly right: number; readonly bottom: number }\nconst MARGIN = 8, DEFAULT_POSITION: Position = { right: 16, bottom: 16 };\n\nfunction storageFor(persist: 'session' | 'local'): Storage | undefined { try { return persist === 'local' ? globalThis.localStorage : globalThis.sessionStorage; } catch { return undefined; } }\nfunction readPosition(key: string, persist: 'session' | 'local'): Position | undefined {\n try {\n const value: unknown = JSON.parse(storageFor(persist)?.getItem(key) ?? 'null');\n const { right, bottom } = (value ?? {}) as { right?: unknown; bottom?: unknown };\n return typeof right === 'number' && typeof bottom === 'number' && Number.isFinite(right) && Number.isFinite(bottom) ? { right, bottom } : undefined;\n } catch { return undefined; }\n}\nfunction writePosition(key: string, persist: 'session' | 'local', position: Position) {\n try { storageFor(persist)?.setItem(key, JSON.stringify(position)); } catch { /* the position is a convenience */ }\n}\nconst clamp = (position: Position, size: { w: number; h: number }, viewport: { w: number; h: number }): Position => ({\n right: Math.round(Math.min(Math.max(MARGIN, position.right), Math.max(MARGIN, viewport.w - size.w - MARGIN))),\n bottom: Math.round(Math.min(Math.max(MARGIN, position.bottom), Math.max(MARGIN, viewport.h - size.h - MARGIN))),\n});\n\n// The layout viewport that fixed positioning uses (without scrollbars), not the window's outer size.\nconst viewportSize = () => { const root = globalThis.document?.documentElement; return { w: root?.clientWidth || globalThis.innerWidth || 1024, h: root?.clientHeight || globalThis.innerHeight || 768 }; };\nexport function usePhone(): boolean {\n const query = `(max-width: ${PHONE_BELOW - 1}px)`;\n const [phone, setPhone] = useState(() => globalThis.matchMedia?.(query).matches === true);\n useEffect(() => {\n const list = globalThis.matchMedia?.(query);\n if (!list) return;\n const update = () => setPhone(list.matches);\n update();\n list.addEventListener('change', update);\n return () => list.removeEventListener('change', update);\n }, []);\n return phone;\n}\n\n/** Whole seconds since `active` became true; 0 while inactive. */\nfunction useElapsed(active: boolean): number {\n const [seconds, setSeconds] = useState(0);\n useEffect(() => {\n if (!active) { setSeconds(0); return; }\n const started = Date.now();\n setSeconds(0);\n const timer = setInterval(() => setSeconds(Math.floor((Date.now() - started) / 1000)), 1000);\n return () => clearInterval(timer);\n }, [active]);\n return seconds;\n}\n\n/** Pushes a toast when a run finishes or fails and when a question starts waiting; takes it back when the question is answered or a new run starts. */\nfunction useRunFeed({ enabled, store, conversationId, title, session, suppress, onOutcome, waiting }: {\n readonly enabled: boolean; readonly store: NotificationStore; readonly conversationId: string; readonly title: string; readonly session: ChatSession; readonly waiting: string;\n readonly suppress: () => boolean; readonly onOutcome: (outcome: 'done' | 'error' | null) => void;\n}) {\n const { working, waitingForAnswer, rows, connected } = session;\n const before = useRef({ working, waiting: waitingForAnswer });\n const latest = useRef({ rows, title, suppress, onOutcome, connected, waiting });\n latest.current = { rows, title, suppress, onOutcome, connected, waiting };\n useEffect(() => {\n const was = before.current;\n before.current = { working, waiting: waitingForAnswer };\n const now = latest.current;\n if (working && !was.working) { now.onOutcome(null); store.dismiss(`${conversationId}:done`); }\n if (!enabled) return;\n if (waitingForAnswer && !was.waiting && !now.suppress()) store.push({ id: `${conversationId}:input`, kind: 'input', title: now.title, summary: pendingQuestion(now.rows) ?? now.waiting, conversationId });\n if (!waitingForAnswer && was.waiting) store.dismiss(`${conversationId}:input`);\n if (was.working && !working && now.connected) {\n const finished = finishNotice(now.rows);\n if (!finished) return;\n now.onOutcome(finished.kind);\n if (!now.suppress()) store.push({ id: `${conversationId}:done`, kind: finished.kind, title: now.title, summary: finished.summary, conversationId });\n }\n }, [working, waitingForAnswer, enabled, store, conversationId]);\n}\n\n/** A compact floating agent bar over an existing page; it grows into a chat window in place. Borrows a concrete native controller like `PiChat`. */\n/** The ambient bar comes with every composer feature item (`withChatPlugins`); a component the host passes is kept. */\nexport function AmbientChat(given: AmbientChatProps) {\n const props = withChatPlugins(given);\n const active = useRef(props.controller); active.current = props.controller;\n const [mount, setMount] = useState({ controller: props.controller, sequence: 0 });\n if (mount.controller !== props.controller) setMount({ controller: props.controller, sequence: mount.sequence + 1 });\n // The window state and the toasts belong to the bar, not to one conversation: they survive switching conversations.\n const [local, setLocal] = useState<AmbientState>(props.defaultState ?? 'bar');\n if (props.embedded && local !== 'expanded') setLocal('expanded');\n const internalStore = useMemo(createNotificationStore, []);\n const text = useMergedText<AmbientLabels, AmbientIcons>(props.labels, props.icons, AMBIENT_TEXT);\n return <ChatTextProvider value={text}><MessageScrollerProvider key={mount.sequence} autoScroll><AmbientSession {...props} labels={text.labels} icons={text.icons} activeController={active} windowState={props.embedded ? 'expanded' : props.state ?? local} store={props.notifications || internalStore}\n onWindowState={next => { if (props.state === undefined) setLocal(next); props.onStateChange?.(next); }} /></MessageScrollerProvider></ChatTextProvider>;\n}\n\ntype SessionProps = Omit<AmbientChatProps, 'labels' | 'icons'> & { readonly labels: AmbientLabels; readonly icons: AmbientIcons; readonly activeController: { readonly current: NativeChatController }; readonly windowState: AmbientState; readonly store: NotificationStore; readonly onWindowState: (state: AmbientState) => void };\n\nfunction AmbientSession({ controller, embedded = false, labels, icons, avatar, headerActions, headerTools, messageActions, variant = 'contrast', mode = 'expert', presentation, actions,\n windowState, onWindowState, store, storageKey = 'boring.ambient.position', storage = 'session', tools, belowComposer, composerChips, composerLayout = 'card', onOpenFull, onFeedback, notifications, autoDismissMs, maxToasts, systemNotifications = false,\n renderEntry, hideLine, displayLimit, historicalMessageActions, unread: replyUnread, replyError, renderTool, toolRenderers, renderText, cards, groupTool, commandMentions, onOpenImage, onCopy, onComposerKeyDown, fileAccept = 'image/*', slash, mentions, attachments, onOpenFile, fileChanges, pasteToFile, model, effort, replyFooter, artifacts: hostArtifacts, artifactPanel, artifactTarget, conversations, onDock, feedback: composerFeedback, className,\n activeController }: SessionProps) {\n const { title } = labels;\n useChatPlacement(controller, embedded ? 'docked' : 'floating');\n const stateRef = useRef(windowState); stateRef.current = windowState;\n // The artifact open inside the window, and whether it follows the newest version. It belongs to the expanded window: leaving that state closes it.\n const [opened, setOpened] = useState<{ readonly descriptor: ArtifactDescriptor; readonly follow: boolean } | null>(null);\n const [fullscreen, setFullscreen] = useState(false);\n const setWindowState = (next: AmbientState) => {\n if (next !== 'expanded') { setOpened(null); setFullscreen(false); }\n if (next !== stateRef.current) onWindowState(next);\n };\n const expanded = windowState === 'expanded', minimized = windowState === 'minimized';\n const phone = usePhone();\n const root = useRef<HTMLDivElement>(null), titleButton = useRef<HTMLButtonElement>(null), pill = useRef<HTMLButtonElement>(null);\n const [picking, setPicking] = useState(false);\n const [feedback, setFeedback] = useState<Readonly<Record<string, 'up' | 'down'>>>({});\n const [outcome, setOutcome] = useState<'done' | 'error' | null>(null);\n const [permission, setPermission] = useState<NotifyPermission>('unsupported');\n useEffect(() => { setPermission(notifyPermission()); }, []);\n useEffect(() => { if (!outcome) return; const timer = setTimeout(() => setOutcome(null), 8000); return () => clearTimeout(timer); }, [outcome]);\n\n // Replies get copy, optional feedback and the time; the host may hand the conversation to its full chat.\n const conversationRef = useRef('');\n const replyActions = useMemo<NonNullable<RowContext['replyActions']>>(() => (reply, host) => <div data-testid=\"reply-actions\" className=\"mt-2 -ml-2 flex items-center gap-0.5 opacity-0 transition-opacity group-hover/message:opacity-100 group-focus-within/message:opacity-100 max-sm:opacity-100 pointer-coarse:opacity-100 motion-reduce:transition-none\">\n <CopyButton text={reply.text} label={labels.copyResponse} iconOnly {...(onCopy ? { onCopy } : {})} />\n {onFeedback && <>\n {(['up', 'down'] as const).map(value => <Toggle key={value} data-testid={`feedback-${value}`} pressed={feedback[reply.key] === value} aria-label={value === 'up' ? labels.goodResponse : labels.badResponse}\n title={value === 'up' ? labels.goodResponse : labels.badResponse}\n onPressedChange={pressed => { const next = pressed ? value : null; setFeedback(current => { const { [reply.key]: _removed, ...rest } = current; return next ? { ...rest, [reply.key]: next } : rest; }); onFeedback({ key: reply.key, text: reply.text }, next); }}>\n {value === 'up' ? <icons.goodResponse data-icon=\"inline-start\" aria-hidden=\"true\" /> : <icons.badResponse data-icon=\"inline-start\" aria-hidden=\"true\" />}</Toggle>)}\n </>}\n {onOpenFull && <Button variant=\"ghost\" size=\"icon\" data-testid=\"open-full\" aria-label={labels.openFull} title={labels.openFull} onClick={() => onOpenFull(conversationRef.current)}><icons.openFull data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>}\n {host}\n {reply.timestamp !== undefined && <time dateTime={new Date(reply.timestamp).toISOString()} data-testid=\"reply-time\" className=\"ml-1.5 text-xs tabular-nums text-muted-foreground\">{new Date(reply.timestamp).toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' })}</time>}\n </div>, [onCopy, onFeedback, onOpenFull, feedback, labels, icons]);\n const scrollToBottom = useRef<() => void>(() => {});\n const rowExtras = useMemo(() => ({ replyActions }), [replyActions]);\n const inWindow = Boolean(artifactPanel) && artifactTarget !== 'host';\n const detect = hostArtifacts?.detect;\n const versionsRef = useRef<readonly ArtifactDescriptor[]>([]);\n const artifacts = useMemo<ArtifactsConfig | undefined>(() => !inWindow ? hostArtifacts : {\n open: descriptor => { const newest = versionsRef.current.filter(version => artifactKey(version) === artifactKey(descriptor)).reduce((top, version) => Math.max(top, version.ordinal ?? 0), 0); setOpened({ descriptor, follow: (descriptor.ordinal ?? 0) >= newest }); },\n isOpen: descriptor => opened !== null && artifactKey(opened.descriptor) === artifactKey(descriptor) && (opened.follow ? (descriptor.ordinal ?? 0) >= (versionsRef.current.find(version => artifactKey(version) === artifactKey(descriptor))?.ordinal ?? 0) : descriptor.revision === opened.descriptor.revision),\n ...(detect ? { detect } : {}),\n ...(hostArtifacts?.kinds ? { kinds: hostArtifacts.kinds } : {}),\n recognise: hostArtifacts?.recognise ?? artifactCards.recognise, Card: hostArtifacts?.Card ?? artifactCards.Card,\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [inWindow, hostArtifacts, opened, detect]);\n const session = useChatSession({ controller, activeController, mode, presentation, actions, renderEntry, hideLine, historicalMessageActions, unread: replyUnread, replyError, renderTool, toolRenderers, renderText, cards, groupTool, commandMentions, onOpenImage, onCopy, onComposerKeyDown, fileAccept,\n slash, mentions, attachments, model, effort, artifacts, messageActions, replyFooter, afterSend: () => scrollToBottom.current(), rowExtras, feedback: composerFeedback, onOpenFile, fileChanges, pasteToFile, composerChips });\n const { state, derived, rows, queued, queueActions, working, waitingForAnswer, connected, error, act, textarea, rowContext, composer, files, loading } = session;\n const empty = session.empty;\n const transcript = useTranscript(rows, displayLimit ?? 60, 40, { state: state.history, connected: !state.disposed && connected, load: controller.loadEarlier }, displayLimit === undefined);\n const versions = useMemo(() => inWindow ? collectArtifacts(state.view, detect).sort((a, b) => (b.ordinal ?? 0) - (a.ordinal ?? 0)) : [], [inWindow, state.view, detect]);\n versionsRef.current = versions;\n const known = opened ? versions.filter(version => artifactKey(version) === artifactKey(opened.descriptor)) : [];\n const shownArtifact = opened ? (opened.follow ? known[0] ?? opened.descriptor : opened.descriptor) : undefined;\n const { stick, hidden } = transcript;\n scrollToBottom.current = () => { transcript.reset(); void stick.scrollToBottom('instant'); };\n const { barStart, barNote } = session.pickers();\n\n const conversationId = String(conversations?.activeId ?? state.conversationId);\n conversationRef.current = conversationId;\n const activeItem = conversations?.items.find(item => item.id === conversations.activeId);\n const shownTitle = activeItem?.title?.trim() || title;\n const toasts = useNotifications(store);\n useRunFeed({ enabled: notifications !== false, store, conversationId, title: shownTitle, session, onOutcome: setOutcome, waiting: labels.waitingForAnswer,\n suppress: () => stateRef.current === 'expanded' && globalThis.document?.visibilityState !== 'hidden' });\n const unread = !expanded && toasts.length > 0;\n const seconds = useElapsed(working);\n\n // Position: offsets from the bottom right corner, remembered for the session, clamped to the viewport at render so a smaller window restores them.\n const [position, setPosition] = useState<Position>(() => readPosition(storageKey, storage) ?? DEFAULT_POSITION);\n const [viewport, setViewport] = useState(viewportSize);\n const [size, setSize] = useState({ w: 0, h: 0 });\n useEffect(() => {\n const onResize = () => setViewport(viewportSize());\n globalThis.addEventListener('resize', onResize);\n return () => globalThis.removeEventListener('resize', onResize);\n }, []);\n useEffect(() => {\n const element = root.current;\n if (!element || typeof ResizeObserver === 'undefined') return;\n const observer = new ResizeObserver(() => setSize({ w: element.offsetWidth, h: element.offsetHeight }));\n observer.observe(element);\n setSize({ w: element.offsetWidth, h: element.offsetHeight });\n return () => observer.disconnect();\n }, [minimized, expanded]);\n // The size and the viewport also follow every render, so the clamp below never works from a stale measurement (the title or status icon can change the pill's width; a scrollbar can appear without a resize event).\n useLayoutEffect(() => {\n const element = root.current;\n const fresh = viewportSize();\n setViewport(current => current.w === fresh.w && current.h === fresh.h ? current : fresh);\n if (element) setSize(current => current.w === element.offsetWidth && current.h === element.offsetHeight ? current : { w: element.offsetWidth, h: element.offsetHeight });\n });\n const placed = clamp(position, size, viewport);\n // Moves and the release are followed on the window, so a drag keeps working when the pointer leaves the handle (and when pointer capture is unavailable).\n // `threshold` is how far the pointer must travel before it is a drag: a click on the pill (threshold > 0) still restores it.\n const dragged = useRef(false);\n const beginDrag = (event: PointerEvent<HTMLElement>, threshold: number) => {\n if (phone || event.button !== 0) return;\n dragged.current = false;\n const origin = { pointer: event.pointerId, x: event.clientX, y: event.clientY, start: placed };\n let last = origin.start, moving = threshold === 0;\n const move = (next: globalThis.PointerEvent) => {\n if (next.pointerId !== origin.pointer) return;\n if (!moving) { if (Math.hypot(next.clientX - origin.x, next.clientY - origin.y) < threshold) return; moving = true; dragged.current = true; }\n last = clamp({ right: origin.start.right - (next.clientX - origin.x), bottom: origin.start.bottom - (next.clientY - origin.y) }, size, viewport);\n setPosition(last);\n };\n const end = (next: globalThis.PointerEvent) => {\n if (next.pointerId !== origin.pointer) return;\n globalThis.removeEventListener('pointermove', move); globalThis.removeEventListener('pointerup', end); globalThis.removeEventListener('pointercancel', end);\n if (moving) writePosition(storageKey, storage, last);\n };\n globalThis.addEventListener('pointermove', move); globalThis.addEventListener('pointerup', end); globalThis.addEventListener('pointercancel', end);\n };\n const onGripDown = (event: PointerEvent<HTMLButtonElement>) => { event.preventDefault(); beginDrag(event, 0); };\n // Arrow keys move the window (Shift for big steps); Home puts it back in the default corner.\n const onGripKey = (event: KeyboardEvent<HTMLElement>) => {\n if (phone) return;\n const step = event.shiftKey ? 64 : 16;\n const delta = event.key === 'ArrowLeft' ? { right: step, bottom: 0 } : event.key === 'ArrowRight' ? { right: -step, bottom: 0 } : event.key === 'ArrowUp' ? { right: 0, bottom: step } : event.key === 'ArrowDown' ? { right: 0, bottom: -step } : undefined;\n if (!delta && event.key !== 'Home') return;\n event.preventDefault();\n const next = delta ? clamp({ right: placed.right + delta.right, bottom: placed.bottom + delta.bottom }, size, viewport) : clamp(DEFAULT_POSITION, size, viewport);\n setPosition(next); writePosition(storageKey, storage, next);\n };\n const grip = (className?: string) => <Button variant=\"ghost\" size=\"icon\" data-testid=\"ambient-grip\" aria-label={labels.move} title={labels.moveHint} onPointerDown={onGripDown} onKeyDown={onGripKey}\n className={cn('touch-none', className)}><icons.grip data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>;\n\n const expand = () => { setOutcome(null); store.dismissConversation(conversationId); setWindowState('expanded'); };\n useEffect(() => {\n if (expanded && !globalThis.matchMedia?.('(pointer: coarse)').matches) textarea.current?.focus();\n }, [expanded, textarea]);\n // The title is disabled while the window is open, so focus goes back to it once the bar is showing again.\n const refocusTitle = useRef(false);\n useEffect(() => { if (!expanded && refocusTitle.current) { refocusTitle.current = false; titleButton.current?.focus(); } }, [expanded]);\n const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {\n if (embedded || event.key !== 'Escape' || event.defaultPrevented || !expanded || picking) return;\n event.preventDefault();\n // Escape steps back: full screen, then the artifact, then the window.\n if (fullscreen) return setFullscreen(false);\n if (opened) return setOpened(null);\n refocusTitle.current = true;\n setWindowState('bar');\n };\n const onActivate = useCallback((item: AgentNotification) => {\n if (item.conversationId !== undefined && item.conversationId !== conversationId) conversations?.onSelect(item.conversationId);\n expand();\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [conversationId, conversations]);\n\n const connectionKind = state.connection.kind;\n const status = waitingForAnswer ? 'input' : working ? 'working' : outcome;\n const label = waitingForAnswer ? labels.waitingForAnswer : labels.working(seconds);\n const panelOpen = expanded && shownArtifact !== undefined && artifactPanel !== undefined;\n // The floating window is drawn by the host's Card behind it (a sibling layer, so switching placement never remounts the chat);\n // embedded and full-screen phone windows are plain page regions.\n const card = !embedded && !(phone && expanded);\n const surface = embedded ? 'relative flex min-h-0 flex-1 flex-col overflow-clip bg-transparent text-foreground' : cn('relative flex flex-col', card ? 'isolate' : 'h-dvh bg-background pt-[env(safe-area-inset-top)] text-foreground',\n !phone && expanded && (panelOpen ? 'h-full overflow-hidden' : 'max-h-[70dvh]'));\n // With an artifact open the window widens (most of the viewport, at most 72rem) and takes a fixed height so the panel fills it.\n const rootClass = embedded ? cn('pi-chat relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden', className) : cn('pi-chat pi-ambient fixed z-50', variant === 'contrast' && 'dark scheme-dark', phone ? (expanded ? 'inset-0' : 'inset-x-2 bottom-[max(0.5rem,env(safe-area-inset-bottom))]')\n : cn('transition-[width] motion-reduce:transition-none', minimized ? 'w-auto' : panelOpen ? 'h-[min(46rem,calc(100dvh-1rem))] w-[min(72rem,calc(100vw-1rem))]' : expanded ? 'w-[min(35rem,calc(100vw-1rem))]' : 'w-[min(26rem,calc(100vw-1rem))]'), className);\n const rootStyle = phone || embedded ? undefined : { right: placed.right, bottom: placed.bottom };\n\n if (minimized) return <div ref={root} data-boring=\"ambient-chat\" data-state=\"minimized\" data-variant={variant} className={cn(rootClass, phone && 'flex justify-end')} style={rootStyle} onKeyDown={onKeyDown}>\n {notifications !== false && <AgentNotifications store={store} variant={variant} placement=\"inline\" systemNotifications={systemNotifications} onActivate={onActivate} labels={labels}\n {...(autoDismissMs !== undefined ? { autoDismissMs } : {})} {...(maxToasts !== undefined ? { max: maxToasts } : {})} className=\"absolute right-0 bottom-full mb-3 w-[min(26rem,calc(100vw-1rem))]\" />}\n <div data-testid=\"ambient-pill-box\" onPointerDown={event => beginDrag(event, 4)} onKeyDown={onGripKey} onClickCapture={event => { if (dragged.current) { dragged.current = false; event.stopPropagation(); event.preventDefault(); } }}\n className={cn('relative flex max-w-[min(20rem,calc(100vw-1rem))] items-center gap-0.5', !phone && 'cursor-grab touch-none select-none active:cursor-grabbing')}>\n {!phone && grip('ml-1.5')}\n <Button ref={pill} variant=\"outline\" size=\"lg\" data-testid=\"ambient-pill\" onClick={() => setWindowState('bar')} aria-label={labels.openAgent(shownTitle, unread)}\n className=\"relative min-w-0 gap-2.5\">\n {status === 'working' ? <Loader2Icon data-icon=\"inline-start\" className=\"animate-spin motion-reduce:animate-none\" aria-hidden=\"true\" />\n : status === 'input' ? <MessageCircleQuestionIcon data-icon=\"inline-start\" aria-hidden=\"true\" />\n : status === 'done' ? <CircleCheckIcon data-icon=\"inline-start\" aria-hidden=\"true\" />\n : status === 'error' ? <CircleAlertIcon data-icon=\"inline-start\" aria-hidden=\"true\" /> : null}\n <span className=\"min-w-0 truncate\">{shownTitle}</span>\n {unread && <Badge data-testid=\"ambient-unread\" aria-hidden=\"true\" className=\"absolute -top-1.5 -right-1.5\" />}\n </Button>\n </div>\n </div>;\n\n const visibleRows = transcript.visible(rows);\n const conversation = <section className=\"flex min-h-0 min-w-0 flex-1 flex-col\">\n <header hidden={embedded} data-testid=\"ambient-header\" className={cn(embedded && '!hidden','flex h-12 shrink-0 items-center gap-0.5 px-2 max-sm:h-14 max-sm:px-3')}>\n <Button variant=\"ghost\" size=\"icon\" data-testid=\"ambient-minimize\" aria-label={labels.minimize} title={labels.minimize} onClick={() => { setPicking(false); setWindowState(expanded ? 'bar' : 'minimized'); }}><icons.minimize data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>\n <span data-testid=\"ambient-status\" data-status={status ?? 'idle'} role=\"img\" aria-label={status === 'working' ? labels.statusWorking : status === 'input' ? labels.needsInput : status === 'done' ? labels.finished : status === 'error' ? labels.failedRun : undefined}\n aria-hidden={status ? undefined : 'true'} className={cn('flex shrink-0 items-center justify-center', status ? 'mx-1.5 w-5' : 'w-1.5')}>\n {status === 'working' ? <Loader2Icon className=\"size-4 animate-spin text-muted-foreground motion-reduce:animate-none\" aria-hidden=\"true\" /> : status ? <KindIcon kind={status === 'input' ? 'input' : status} className=\"size-[1.125rem]\" /> : null}\n </span>\n {avatar && <Avatar size=\"sm\" data-testid=\"ambient-avatar\" className=\"overflow-hidden\"><AvatarFallback className=\"*:max-h-full *:max-w-full\">{avatar}</AvatarFallback></Avatar>}\n <Button ref={titleButton} variant=\"ghost\" data-testid=\"ambient-title\" onClick={expand} disabled={expanded} aria-label={expanded ? shownTitle : labels.openChat(shownTitle)}\n className=\"min-w-0 max-w-full shrink\"><span className=\"truncate\">{shownTitle}</span></Button>\n {conversations && <Button variant=\"ghost\" size=\"icon\" data-testid=\"ambient-switch\" aria-label={labels.switchConversation} aria-haspopup=\"dialog\" aria-expanded={picking} title={labels.switchConversation} onClick={() => setPicking(open => !open)}>\n <icons.switchConversation data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>}\n {expanded ? <span className=\"flex-1\" /> : <Button variant=\"ghost\" tabIndex={-1} aria-hidden=\"true\" data-testid=\"ambient-header-fill\" onClick={expand} className=\"min-w-2 flex-1\" />}\n {systemNotifications && permission === 'default' && <Button variant=\"ghost\" size=\"icon\" data-testid=\"ambient-enable-notifications\" aria-label={labels.enableNotifications} title={labels.enableNotifications}\n onClick={() => { void requestNotifyPermission().then(setPermission); }}><icons.notify data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>}\n {headerTools}\n <BlockActions actions={headerActions} testId=\"ambient-action\" menuLabel={labels.moreActions} />\n {onDock && !phone && <Button variant=\"ghost\" size=\"icon\" data-testid=\"ambient-dock\" aria-label={labels.dock} title={labels.dockHint} onClick={() => { setPicking(false); onDock(); }}><icons.dock data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>}\n {!phone && grip()}\n </header>\n\n {expanded && <div className=\"flex min-h-0 flex-1 flex-col\">\n {(connectionKind === 'error' || connectionKind === 'closed') && <div className=\"px-4 pb-2\"><Notice tone={connectionKind === 'error' ? 'error' : 'info'} testid=\"connection-notice\">\n <span className=\"flex-1\">{connectionKind === 'error' ? labels.loadFailed : labels.disconnected}</span>\n {!state.disposed && <Button size=\"sm\" variant=\"outline\" onClick={() => act(controller.connect)}><icons.reconnect data-icon=\"inline-start\" aria-hidden=\"true\" />{labels.reconnect}</Button>}</Notice></div>}\n {derived.pinned.length > 0 && <aside aria-label={labels.requiredActions} className=\"shrink-0 space-y-2 px-4 pb-2\">{derived.pinned.map(card => <div key={card.key}>{card.content}</div>)}</aside>}\n <div className=\"relative flex min-h-0 flex-1 flex-col\">\n <MessageScroller>\n <MessageScrollerViewport ref={stick.scrollRef} data-testid=\"transcript-scroll\" aria-label={labels.messages} className=\"scrollbar-thumb-border scrollbar-track-transparent\">\n {!empty && <TranscriptHistory history={state.history} connected={!state.disposed && connected} onLoad={transcript.reveal} sentinelRef={transcript.sentinelRef} hidden={hidden} loaded={transcript.loaded} />}\n <div className=\"px-4 pt-4 pb-6\">\n <MessageScrollerContent aria-label={labels.messages} aria-live=\"polite\" data-testid=\"transcript\">\n {visibleRows.map(row => <MessageScrollerItem key={row.key} messageId={row.key} className=\"[content-visibility:visible]\"><RowView row={row} context={rowContext} /></MessageScrollerItem>)}\n {loading && <p role=\"status\" data-testid=\"loading\" className=\"m-0 flex items-center gap-2 text-sm text-muted-foreground\"><Loader2Icon className=\"size-4 animate-spin motion-reduce:animate-none\" aria-hidden=\"true\" />{labels.loading}</p>}\n {empty && !loading && <p data-testid=\"ambient-empty\" className=\"m-0 max-w-sm py-8 text-sm leading-6 text-muted-foreground\">{labels.empty}</p>}\n </MessageScrollerContent>\n </div>\n </MessageScrollerViewport>\n {!stick.isAtBottom && <MessageScrollerButton data-testid=\"jump-latest\" aria-label={labels.jumpToLatest} title={labels.jumpToLatest} onClick={transcript.reset}><icons.jumpToLatest aria-hidden=\"true\" /></MessageScrollerButton>}\n </MessageScroller>\n </div>\n </div>}\n\n {(expanded || !embedded) && <Separator />}\n <div className={cn('shrink-0', phone && 'pb-[env(safe-area-inset-bottom)]')}>\n <div className=\"space-y-2 px-3 empty:hidden [&:has(*)]:pt-2\">\n {preparing({ natural: session.natural, waitingForAnswer, run: derived.run, retry: derived.retry, compacting: derived.compacting, stop: state.stop }) && <PreparingStatus>{derived.stageStatus}</PreparingStatus>}\n <ChatNotices natural={session.natural} state={state} error={error} onReconcile={() => act(controller.reconcile)} onRetry={() => act(controller.retrySameRequest)} />\n {!expanded && (connectionKind === 'error' || connectionKind === 'closed') && <Notice tone=\"info\" testid=\"connection-notice\">\n <span className=\"flex-1\">{connectionKind === 'error' ? labels.loadFailedShort : labels.disconnectedShort}</span>\n {!state.disposed && <Button size=\"sm\" variant=\"outline\" onClick={() => act(controller.connect)}>{labels.reconnect}</Button>}</Notice>}\n </div>\n <div className=\"px-3 pt-2 pb-3\">\n <ConversationFiles files={files} />\n <MessageQueue items={queued} sending={state.outbox} withdraw={actions?.withdraw} actions={queueActions} />\n <Composer {...composer} layout={composerLayout} placeholder={working || waitingForAnswer ? label : labels.placeholder} barStart={barStart} barNote={barNote} barEnd={tools} />\n {belowComposer !== undefined && <div data-testid=\"below-composer\" className=\"pt-2\">{belowComposer}</div>}\n </div>\n </div>\n </section>;\n return <div ref={root} data-boring={embedded ? 'pi-chat' : 'ambient-chat'} data-state={windowState} data-variant={variant} data-phone={phone ? 'true' : undefined} role=\"region\" aria-label={labels.region(shownTitle)}\n className={rootClass} style={rootStyle} onKeyDown={onKeyDown}>\n {!embedded && notifications !== false && !(phone && expanded) && <AgentNotifications store={store} variant={variant} placement=\"inline\" systemNotifications={systemNotifications} onActivate={onActivate} labels={labels}\n {...(autoDismissMs !== undefined ? { autoDismissMs } : {})} {...(maxToasts !== undefined ? { max: maxToasts } : {})} className=\"absolute right-0 bottom-full mb-3 w-full\" />}\n {picking && conversations && <ConversationHistory conversations={conversations} placement={expanded ? 'below' : 'above'} onClose={() => setPicking(false)} />}\n <div data-testid=\"ambient-surface\" className={surface}>\n {card && <Card aria-hidden=\"true\" data-boring=\"chrome\" className=\"pointer-events-none absolute inset-0 -z-10\" />}\n {artifactPanel ? <ArtifactWorkspace open={panelOpen} onClose={() => { setOpened(null); setFullscreen(false); }} fullscreen={fullscreen} onFullscreenChange={setFullscreen} storageKey=\"boring.ambient.panel-width\" panelLabel={labels.artifactPanel}\n defaultWidth={600} minPanel={320} minChat={300} sheetBelow={720}\n panel={api => shownArtifact ? artifactPanel(shownArtifact, { ...api, versions: known, follow: opened?.follow ?? true,\n select: version => setOpened(current => current ? (version === 'latest' ? { descriptor: known[0] ?? current.descriptor, follow: true } : { descriptor: version, follow: false }) : current) }) : null}\n chat={conversation} /> : conversation}\n </div>\n </div>;\n}\n",
|
|
32
35
|
"type": "registry:component",
|
|
33
36
|
"target": "components/pi-ambient/ambient.tsx"
|
|
34
37
|
},
|
|
@@ -40,7 +43,7 @@
|
|
|
40
43
|
},
|
|
41
44
|
{
|
|
42
45
|
"path": "registry/pi-ambient/notifications.tsx",
|
|
43
|
-
"content": "'use client';\n\nimport { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';\nimport type { KeyboardEvent } from 'react';\nimport { CircleCheckIcon, CircleAlertIcon, MessageCircleQuestionIcon, XIcon } from 'lucide-react';\nimport { Button } from '@/components/ui/button';\nimport type { NativeChatController } from '@hachej/boring-ui-kit/native-chat';\nimport { notifyWhenHidden } from './browser-notify';\nimport { parseQuestion } from '../pi-chat/question-card';\nimport { derive, object } from '../pi-chat/rows';\nimport type { Row } from '../pi-chat/rows';\nimport { cn, withDefaults } from '../utils/utils';\n\n/** `done`: a run finished. `input`: the agent waits for an answer (never auto-dismissed). `error`: a run failed. */\nexport type NotificationKind = 'done' | 'input' | 'error';\n\nexport interface AgentNotification {\n readonly id: string;\n readonly kind: NotificationKind;\n /** The task or conversation title. */\n readonly title: string;\n /** One line: the first line of the final reply, the question, or the failure. */\n readonly summary?: string | undefined;\n /** What a click opens. Hosts use their own ids for background tasks. */\n readonly conversationId?: string | undefined;\n readonly createdAt: number;\n}\nexport type NewNotification = Omit<AgentNotification, 'id' | 'createdAt'> & { readonly id?: string | undefined };\n\n/**\n * A small external store of notifications. `AmbientChat` feeds it for its own conversation; a host feeds it for background\n * conversations or tasks (`push` from any event), and may share one store between `AmbientChat` and `AgentNotifications`.\n */\nexport interface NotificationStore {\n readonly subscribe: (listener: () => void) => () => void;\n readonly getSnapshot: () => readonly AgentNotification[];\n /** Adds a notification (or replaces the one with the same `id`) and returns its id. */\n readonly push: (notification: NewNotification) => string;\n readonly dismiss: (id: string) => void;\n /** Dismisses every notification of a conversation, optionally of one kind. */\n readonly dismissConversation: (conversationId: string, kind?: NotificationKind) => void;\n readonly clear: () => void;\n}\n\nexport function createNotificationStore(): NotificationStore {\n let items: readonly AgentNotification[] = [];\n let sequence = 0;\n const listeners = new Set<() => void>();\n const set = (next: readonly AgentNotification[]) => { items = next; for (const listener of [...listeners]) listener(); };\n return {\n subscribe: listener => { listeners.add(listener); return () => { listeners.delete(listener); }; },\n getSnapshot: () => items,\n push: notification => {\n const id = notification.id ?? `notification-${++sequence}`;\n set([...items.filter(item => item.id !== id), { ...notification, id, createdAt: Date.now() }]);\n return id;\n },\n dismiss: id => { if (items.some(item => item.id === id)) set(items.filter(item => item.id !== id)); },\n dismissConversation: (conversationId, kind) => {\n if (items.some(item => item.conversationId === conversationId && (!kind || item.kind === kind))) set(items.filter(item => item.conversationId !== conversationId || (kind && item.kind !== kind)));\n },\n clear: () => { if (items.length) set([]); },\n };\n}\n\n/** The current notifications of a store. */\nexport function useNotifications(store: NotificationStore): readonly AgentNotification[] {\n return useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot);\n}\n\n/** One line from a Markdown reply: its first non-empty line without Markdown marks, cut with an ellipsis. */\nexport function summaryOf(text: string, limit = 140): string {\n const line = text.split('\\n').map(item => item.trim()).find(item => item && !/^(```|---+$)/.test(item)) ?? '';\n const plain = line.replace(/^(#{1,6}\\s+|>\\s*|[-*+]\\s+|\\d+[.)]\\s+)/, '').replace(/!?\\[([^\\]]*)\\]\\([^)]*\\)/g, '$1').replace(/[*_`~]+/g, '').replace(/\\s+/g, ' ').trim();\n return plain.length > limit ? `${plain.slice(0, limit - 1).trimEnd()}…` : plain;\n}\n\n/** What the newest pending `ask_user` question or approval asks, if one waits for the person. */\nexport function pendingQuestion(rows: readonly Row[]): string | undefined {\n for (const row of [...rows].reverse()) if (row.type === 'assistant') for (const part of row.parts) {\n if (part.kind === 'question' && !part.live && !part.result) return parseQuestion(part.call).question;\n if (part.kind === 'approval' && !part.live && !part.result && !part.decision) return `Approve: ${part.summary}`;\n }\n return undefined;\n}\n\n/** What a toast says about a run that just ended, from the conversation's rows; `undefined` when the person stopped it. */\nexport function finishNotice(rows: readonly Row[]): { readonly kind: 'done' | 'error'; readonly summary: string } | undefined {\n const last = [...rows].reverse().find((row): row is Extract<Row, { type: 'assistant' }> => row.type === 'assistant');\n if (last?.stopReason === 'aborted') return undefined;\n if (last?.stopReason === 'error') return { kind: 'error', summary: last.errorMessage ?? 'The run failed' };\n return { kind: 'done', summary: summaryOf(last?.text ?? '') || 'Finished' };\n}\n\n/**\n * Feeds a store from a conversation the host keeps connected in the background (a task, a subagent, another chat): a toast when its\n * run finishes or fails and while a question waits. Returns the unsubscribe. `AmbientChat` does the same for its own conversation.\n */\nexport function watchConversation(controller: NativeChatController, store: NotificationStore, options: { readonly conversationId: string; readonly title: string | (() => string) }): () => void {\n const { conversationId } = options;\n const title = () => typeof options.title === 'function' ? options.title() : options.title;\n const state = () => {\n const snapshot = controller.getSnapshot();\n const live = object(snapshot.view?.docs['pi.live']);\n return { working: Boolean(object(live?.['run'])), entries: snapshot.view?.entries.length ?? 0, connected: snapshot.connection.kind === 'connected' };\n };\n let before = state(), waiting = false;\n return controller.subscribe(() => {\n const now = state();\n const was = before;\n before = now;\n if (!now.connected || (now.working === was.working && now.entries === was.entries)) return;\n const rows = derive(controller.getSnapshot().view, { mode: 'expert', renderEntry: undefined, renderTool: undefined, groupTool: undefined }).rows;\n const question = now.working ? pendingQuestion(rows) : undefined;\n if (question !== undefined && !waiting) store.push({ id: `${conversationId}:input`, kind: 'input', title: title(), summary: question, conversationId });\n if (question === undefined && waiting) store.dismiss(`${conversationId}:input`);\n waiting = question !== undefined;\n if (now.working && !was.working) store.dismiss(`${conversationId}:done`);\n if (was.working && !now.working) {\n const finished = finishNotice(rows);\n if (finished) store.push({ id: `${conversationId}:done`, kind: finished.kind, title: title(), summary: finished.summary, conversationId });\n }\n });\n}\n\nconst ICONS = {\n done: { Icon: CircleCheckIcon, label: 'finished', tone: 'text-(--chat-success)' },\n input: { Icon: MessageCircleQuestionIcon, label: 'needsInput', tone: 'text-(--chat-warning)' },\n error: { Icon: CircleAlertIcon, label: 'failedRun', tone: 'text-destructive' },\n} as const;\n\n/** The toasts' words; `AmbientChat`'s `labels` carries them too. */\nexport const defaultNotificationLabels = {\n finished: 'Finished',\n needsInput: 'Needs your input',\n failedRun: 'Failed',\n notifications: 'Agent notifications',\n /** A toast's accessible name: its kind, title and summary, then what a press does. */\n openNotification: (kind: string, title: string, summary: string | undefined) => `${kind}: ${title}${summary ? `. ${summary}` : ''}. Open`,\n dismissNotification: (title: string) => `Dismiss: ${title}`,\n dismissShort: 'Dismiss',\n};\nexport type NotificationLabels = typeof defaultNotificationLabels;\n\n/** The status icon of a notification, also used by the ambient header. */\nexport function KindIcon({ kind, className }: { readonly kind: NotificationKind; readonly className?: string }) {\n const { Icon, tone } = ICONS[kind];\n return <Icon className={cn('size-5 shrink-0', tone, className)} aria-hidden=\"true\" />;\n}\n\nfunction Toast({ item, autoDismissMs, onActivate, onDismiss, labels }: {\n readonly item: AgentNotification; readonly autoDismissMs: number; readonly labels: NotificationLabels;\n readonly onActivate: (item: AgentNotification) => void; readonly onDismiss: (item: AgentNotification) => void;\n}) {\n const [paused, setPaused] = useState(false);\n const dismissible = item.kind !== 'input' && autoDismissMs > 0;\n useEffect(() => {\n if (!dismissible || paused) return;\n const timer = setTimeout(() => onDismiss(item), autoDismissMs);\n return () => clearTimeout(timer);\n }, [dismissible, paused, autoDismissMs, item, onDismiss]);\n const { Icon } = ICONS[item.kind];\n const label = labels[ICONS[item.kind].label];\n return <li data-testid=\"agent-toast\" data-kind={item.kind} data-conversation-id={item.conversationId} className=\"pointer-events-auto relative list-none\"\n onMouseEnter={() => setPaused(true)} onMouseLeave={() => setPaused(false)} onFocus={() => setPaused(true)} onBlur={() => setPaused(false)}\n onKeyDown={(event: KeyboardEvent) => { if (event.key === 'Escape') { event.stopPropagation(); onDismiss(item); } }}>\n <Button variant=\"ghost\" data-testid=\"agent-toast-open\" onClick={() => onActivate(item)} aria-label={labels.openNotification(label, item.title, item.summary)}\n className=\"h-auto w-full flex-col items-start whitespace-normal\">\n <span className=\"flex items-center gap-2.5\"><Icon data-icon=\"inline-start\" aria-hidden=\"true\" /><span className=\"min-w-0 flex-1 truncate\">{item.title}</span></span>\n {item.summary && <span data-testid=\"agent-toast-summary\" className=\"mt-1 block max-w-full truncate\">{item.summary}</span>}\n </Button>\n <Button variant=\"ghost\" size=\"icon-sm\" data-testid=\"agent-toast-close\" aria-label={labels.dismissNotification(item.title)} title={labels.dismissShort} onClick={() => onDismiss(item)}\n className=\"absolute -top-2 -left-2\">\n <XIcon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>\n </li>;\n}\n\nexport interface AgentNotificationsProps {\n readonly store: NotificationStore;\n /** Toasts shown at once; older ones appear when newer ones are closed. */\n readonly max?: number;\n /** Milliseconds before a toast closes itself; 0 keeps them. \"Needs input\" toasts never close themselves. Hovering or focusing a toast pauses the timer. */\n readonly autoDismissMs?: number;\n /** A click on a toast. It is dismissed afterwards. */\n readonly onActivate?: (notification: AgentNotification) => void;\n /** Also show a system notification while the page is hidden, when the person has allowed it (see `AmbientChat`'s bell). */\n readonly systemNotifications?: boolean;\n readonly variant?: 'contrast' | 'surface';\n /** `fixed` docks the stack at the bottom right of the page; `inline` leaves placement to `className`. */\n readonly placement?: 'fixed' | 'inline';\n /** The toasts' words, over `defaultNotificationLabels`. */\n readonly labels?: Partial<NotificationLabels> | undefined;\n readonly className?: string;\n}\n\n/**\n * A stack of toasts for finished, failed and waiting tasks, driven by a `NotificationStore`. It is a polite live region;\n * Escape closes the focused toast. `AmbientChat` renders one above its bar; use this directly for a host-placed stack.\n */\nexport function AgentNotifications({ store, max = 3, autoDismissMs = 8000, onActivate, systemNotifications = false, variant = 'contrast', placement = 'fixed', labels: given, className }: AgentNotificationsProps) {\n const labels = useMemo(() => withDefaults(defaultNotificationLabels, given), [given]);\n const items = useNotifications(store);\n const seen = useRef(new Set<string>());\n const dismiss = useCallback((item: AgentNotification) => store.dismiss(item.id), [store]);\n const activate = useCallback((item: AgentNotification) => { onActivate?.(item); store.dismiss(item.id); }, [onActivate, store]);\n useEffect(() => {\n for (const item of items) {\n if (seen.current.has(`${item.id}:${item.createdAt}`)) continue;\n seen.current.add(`${item.id}:${item.createdAt}`);\n if (systemNotifications) notifyWhenHidden(item.title, item.summary ?? labels[ICONS[item.kind].label], item.id, () => { globalThis.focus?.(); activate(item); });\n }\n }, [items, systemNotifications, activate, labels]);\n const shown = items.slice(-max);\n return <div data-boring=\"agent-notifications\" data-variant={variant} role=\"status\" aria-live=\"polite\" aria-relevant=\"additions\" aria-label={labels.notifications}\n className={cn('pi-chat pi-ambient pointer-events-none z-50', variant === 'contrast' && 'dark scheme-dark', placement === 'fixed' && 'fixed right-4 bottom-4 w-[min(26rem,calc(100vw-2rem))]', className)}>\n <ul className=\"m-0 flex list-none flex-col gap-3 p-0\">\n {shown.map(item => <Toast key={`${item.id}:${item.createdAt}`} item={item} autoDismissMs={autoDismissMs} onActivate={activate} onDismiss={dismiss} labels={labels} />)}\n </ul>\n </div>;\n}\n",
|
|
46
|
+
"content": "'use client';\n\nimport { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';\nimport type { KeyboardEvent } from 'react';\nimport { CircleCheckIcon, CircleAlertIcon, MessageCircleQuestionIcon, XIcon } from 'lucide-react';\nimport { Button } from '@/components/ui/button';\nimport type { NativeChatController } from '@hachej/boring-ui-kit/native-chat';\nimport { notifyWhenHidden } from './browser-notify';\nimport { parseQuestion } from '../pi-chat-question/question-card';\nimport { derive, object } from '../pi-chat/rows';\nimport type { Row } from '../pi-chat/rows';\nimport { cn, withDefaults } from '../utils/utils';\n\n/** `done`: a run finished. `input`: the agent waits for an answer (never auto-dismissed). `error`: a run failed. */\nexport type NotificationKind = 'done' | 'input' | 'error';\n\nexport interface AgentNotification {\n readonly id: string;\n readonly kind: NotificationKind;\n /** The task or conversation title. */\n readonly title: string;\n /** One line: the first line of the final reply, the question, or the failure. */\n readonly summary?: string | undefined;\n /** What a click opens. Hosts use their own ids for background tasks. */\n readonly conversationId?: string | undefined;\n readonly createdAt: number;\n}\nexport type NewNotification = Omit<AgentNotification, 'id' | 'createdAt'> & { readonly id?: string | undefined };\n\n/**\n * A small external store of notifications. `AmbientChat` feeds it for its own conversation; a host feeds it for background\n * conversations or tasks (`push` from any event), and may share one store between `AmbientChat` and `AgentNotifications`.\n */\nexport interface NotificationStore {\n readonly subscribe: (listener: () => void) => () => void;\n readonly getSnapshot: () => readonly AgentNotification[];\n /** Adds a notification (or replaces the one with the same `id`) and returns its id. */\n readonly push: (notification: NewNotification) => string;\n readonly dismiss: (id: string) => void;\n /** Dismisses every notification of a conversation, optionally of one kind. */\n readonly dismissConversation: (conversationId: string, kind?: NotificationKind) => void;\n readonly clear: () => void;\n}\n\nexport function createNotificationStore(): NotificationStore {\n let items: readonly AgentNotification[] = [];\n let sequence = 0;\n const listeners = new Set<() => void>();\n const set = (next: readonly AgentNotification[]) => { items = next; for (const listener of [...listeners]) listener(); };\n return {\n subscribe: listener => { listeners.add(listener); return () => { listeners.delete(listener); }; },\n getSnapshot: () => items,\n push: notification => {\n const id = notification.id ?? `notification-${++sequence}`;\n set([...items.filter(item => item.id !== id), { ...notification, id, createdAt: Date.now() }]);\n return id;\n },\n dismiss: id => { if (items.some(item => item.id === id)) set(items.filter(item => item.id !== id)); },\n dismissConversation: (conversationId, kind) => {\n if (items.some(item => item.conversationId === conversationId && (!kind || item.kind === kind))) set(items.filter(item => item.conversationId !== conversationId || (kind && item.kind !== kind)));\n },\n clear: () => { if (items.length) set([]); },\n };\n}\n\n/** The current notifications of a store. */\nexport function useNotifications(store: NotificationStore): readonly AgentNotification[] {\n return useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot);\n}\n\n/** One line from a Markdown reply: its first non-empty line without Markdown marks, cut with an ellipsis. */\nexport function summaryOf(text: string, limit = 140): string {\n const line = text.split('\\n').map(item => item.trim()).find(item => item && !/^(```|---+$)/.test(item)) ?? '';\n const plain = line.replace(/^(#{1,6}\\s+|>\\s*|[-*+]\\s+|\\d+[.)]\\s+)/, '').replace(/!?\\[([^\\]]*)\\]\\([^)]*\\)/g, '$1').replace(/[*_`~]+/g, '').replace(/\\s+/g, ' ').trim();\n return plain.length > limit ? `${plain.slice(0, limit - 1).trimEnd()}…` : plain;\n}\n\n/** What the newest pending `ask_user` question or approval asks, if one waits for the person. */\nexport function pendingQuestion(rows: readonly Row[]): string | undefined {\n for (const row of [...rows].reverse()) if (row.type === 'assistant') for (const part of row.parts) {\n if (part.kind === 'question' && !part.live && !part.result) return parseQuestion(part.call).question;\n if (part.kind === 'approval' && !part.live && !part.result && !part.decision) return `Approve: ${part.summary}`;\n }\n return undefined;\n}\n\n/** What a toast says about a run that just ended, from the conversation's rows; `undefined` when the person stopped it. */\nexport function finishNotice(rows: readonly Row[]): { readonly kind: 'done' | 'error'; readonly summary: string } | undefined {\n const last = [...rows].reverse().find((row): row is Extract<Row, { type: 'assistant' }> => row.type === 'assistant');\n if (last?.stopReason === 'aborted') return undefined;\n if (last?.stopReason === 'error') return { kind: 'error', summary: last.errorMessage ?? 'The run failed' };\n return { kind: 'done', summary: summaryOf(last?.text ?? '') || 'Finished' };\n}\n\n/**\n * Feeds a store from a conversation the host keeps connected in the background (a task, a subagent, another chat): a toast when its\n * run finishes or fails and while a question waits. Returns the unsubscribe. `AmbientChat` does the same for its own conversation.\n */\nexport function watchConversation(controller: NativeChatController, store: NotificationStore, options: { readonly conversationId: string; readonly title: string | (() => string) }): () => void {\n const { conversationId } = options;\n const title = () => typeof options.title === 'function' ? options.title() : options.title;\n const state = () => {\n const snapshot = controller.getSnapshot();\n const live = object(snapshot.view?.docs['pi.live']);\n return { working: Boolean(object(live?.['run'])), entries: snapshot.view?.entries.length ?? 0, connected: snapshot.connection.kind === 'connected' };\n };\n let before = state(), waiting = false;\n return controller.subscribe(() => {\n const now = state();\n const was = before;\n before = now;\n if (!now.connected || (now.working === was.working && now.entries === was.entries)) return;\n const rows = derive(controller.getSnapshot().view, { mode: 'expert', renderEntry: undefined, renderTool: undefined, groupTool: undefined }).rows;\n const question = now.working ? pendingQuestion(rows) : undefined;\n if (question !== undefined && !waiting) store.push({ id: `${conversationId}:input`, kind: 'input', title: title(), summary: question, conversationId });\n if (question === undefined && waiting) store.dismiss(`${conversationId}:input`);\n waiting = question !== undefined;\n if (now.working && !was.working) store.dismiss(`${conversationId}:done`);\n if (was.working && !now.working) {\n const finished = finishNotice(rows);\n if (finished) store.push({ id: `${conversationId}:done`, kind: finished.kind, title: title(), summary: finished.summary, conversationId });\n }\n });\n}\n\nconst ICONS = {\n done: { Icon: CircleCheckIcon, label: 'finished', tone: 'text-(--chat-success)' },\n input: { Icon: MessageCircleQuestionIcon, label: 'needsInput', tone: 'text-(--chat-warning)' },\n error: { Icon: CircleAlertIcon, label: 'failedRun', tone: 'text-destructive' },\n} as const;\n\n/** The toasts' words; `AmbientChat`'s `labels` carries them too. */\nexport const defaultNotificationLabels = {\n finished: 'Finished',\n needsInput: 'Needs your input',\n failedRun: 'Failed',\n notifications: 'Agent notifications',\n /** A toast's accessible name: its kind, title and summary, then what a press does. */\n openNotification: (kind: string, title: string, summary: string | undefined) => `${kind}: ${title}${summary ? `. ${summary}` : ''}. Open`,\n dismissNotification: (title: string) => `Dismiss: ${title}`,\n dismissShort: 'Dismiss',\n};\nexport type NotificationLabels = typeof defaultNotificationLabels;\n\n/** The status icon of a notification, also used by the ambient header. */\nexport function KindIcon({ kind, className }: { readonly kind: NotificationKind; readonly className?: string }) {\n const { Icon, tone } = ICONS[kind];\n return <Icon className={cn('size-5 shrink-0', tone, className)} aria-hidden=\"true\" />;\n}\n\nfunction Toast({ item, autoDismissMs, onActivate, onDismiss, labels }: {\n readonly item: AgentNotification; readonly autoDismissMs: number; readonly labels: NotificationLabels;\n readonly onActivate: (item: AgentNotification) => void; readonly onDismiss: (item: AgentNotification) => void;\n}) {\n const [paused, setPaused] = useState(false);\n const dismissible = item.kind !== 'input' && autoDismissMs > 0;\n useEffect(() => {\n if (!dismissible || paused) return;\n const timer = setTimeout(() => onDismiss(item), autoDismissMs);\n return () => clearTimeout(timer);\n }, [dismissible, paused, autoDismissMs, item, onDismiss]);\n const { Icon } = ICONS[item.kind];\n const label = labels[ICONS[item.kind].label];\n return <li data-testid=\"agent-toast\" data-kind={item.kind} data-conversation-id={item.conversationId} className=\"pointer-events-auto relative list-none\"\n onMouseEnter={() => setPaused(true)} onMouseLeave={() => setPaused(false)} onFocus={() => setPaused(true)} onBlur={() => setPaused(false)}\n onKeyDown={(event: KeyboardEvent) => { if (event.key === 'Escape') { event.stopPropagation(); onDismiss(item); } }}>\n <Button variant=\"ghost\" data-testid=\"agent-toast-open\" onClick={() => onActivate(item)} aria-label={labels.openNotification(label, item.title, item.summary)}\n className=\"h-auto w-full flex-col items-start whitespace-normal\">\n <span className=\"flex items-center gap-2.5\"><Icon data-icon=\"inline-start\" aria-hidden=\"true\" /><span className=\"min-w-0 flex-1 truncate\">{item.title}</span></span>\n {item.summary && <span data-testid=\"agent-toast-summary\" className=\"mt-1 block max-w-full truncate\">{item.summary}</span>}\n </Button>\n <Button variant=\"ghost\" size=\"icon-sm\" data-testid=\"agent-toast-close\" aria-label={labels.dismissNotification(item.title)} title={labels.dismissShort} onClick={() => onDismiss(item)}\n className=\"absolute -top-2 -left-2\">\n <XIcon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>\n </li>;\n}\n\nexport interface AgentNotificationsProps {\n readonly store: NotificationStore;\n /** Toasts shown at once; older ones appear when newer ones are closed. */\n readonly max?: number;\n /** Milliseconds before a toast closes itself; 0 keeps them. \"Needs input\" toasts never close themselves. Hovering or focusing a toast pauses the timer. */\n readonly autoDismissMs?: number;\n /** A click on a toast. It is dismissed afterwards. */\n readonly onActivate?: (notification: AgentNotification) => void;\n /** Also show a system notification while the page is hidden, when the person has allowed it (see `AmbientChat`'s bell). */\n readonly systemNotifications?: boolean;\n readonly variant?: 'contrast' | 'surface';\n /** `fixed` docks the stack at the bottom right of the page; `inline` leaves placement to `className`. */\n readonly placement?: 'fixed' | 'inline';\n /** The toasts' words, over `defaultNotificationLabels`. */\n readonly labels?: Partial<NotificationLabels> | undefined;\n readonly className?: string;\n}\n\n/**\n * A stack of toasts for finished, failed and waiting tasks, driven by a `NotificationStore`. It is a polite live region;\n * Escape closes the focused toast. `AmbientChat` renders one above its bar; use this directly for a host-placed stack.\n */\nexport function AgentNotifications({ store, max = 3, autoDismissMs = 8000, onActivate, systemNotifications = false, variant = 'contrast', placement = 'fixed', labels: given, className }: AgentNotificationsProps) {\n const labels = useMemo(() => withDefaults(defaultNotificationLabels, given), [given]);\n const items = useNotifications(store);\n const seen = useRef(new Set<string>());\n const dismiss = useCallback((item: AgentNotification) => store.dismiss(item.id), [store]);\n const activate = useCallback((item: AgentNotification) => { onActivate?.(item); store.dismiss(item.id); }, [onActivate, store]);\n useEffect(() => {\n for (const item of items) {\n if (seen.current.has(`${item.id}:${item.createdAt}`)) continue;\n seen.current.add(`${item.id}:${item.createdAt}`);\n if (systemNotifications) notifyWhenHidden(item.title, item.summary ?? labels[ICONS[item.kind].label], item.id, () => { globalThis.focus?.(); activate(item); });\n }\n }, [items, systemNotifications, activate, labels]);\n const shown = items.slice(-max);\n return <div data-boring=\"agent-notifications\" data-variant={variant} role=\"status\" aria-live=\"polite\" aria-relevant=\"additions\" aria-label={labels.notifications}\n className={cn('pi-chat pi-ambient pointer-events-none z-50', variant === 'contrast' && 'dark scheme-dark', placement === 'fixed' && 'fixed right-4 bottom-4 w-[min(26rem,calc(100vw-2rem))]', className)}>\n <ul className=\"m-0 flex list-none flex-col gap-3 p-0\">\n {shown.map(item => <Toast key={`${item.id}:${item.createdAt}`} item={item} autoDismissMs={autoDismissMs} onActivate={activate} onDismiss={dismiss} labels={labels} />)}\n </ul>\n </div>;\n}\n",
|
|
44
47
|
"type": "registry:component",
|
|
45
48
|
"target": "components/pi-ambient/notifications.tsx"
|
|
46
49
|
}
|