@hachej/boring-ui-kit 0.4.58 → 0.4.59
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/bin/boring-ui-blocks.mjs +49 -4
- package/package.json +6 -6
- package/r/_shared.json +19 -0
- package/r/agent-target.json +4 -4
- package/r/app-shell.json +17 -17
- package/r/board-viewer.json +10 -10
- package/r/button.json +4 -4
- package/r/data-table.json +21 -0
- package/r/diagram-viewer.json +6 -4
- package/r/dock.json +1 -1
- package/r/documents.json +13 -14
- package/r/drawing-viewer.json +6 -4
- package/r/empty-state.json +22 -0
- package/r/feedback.json +14 -14
- package/r/file-tree.json +4 -4
- package/r/highlights.json +3 -3
- package/r/html-viewer.json +3 -3
- package/r/kpi-stat.json +22 -0
- package/r/list-row.json +21 -0
- package/r/looks-viewer.json +6 -6
- package/r/maquette-viewer.json +7 -6
- package/r/markdown-editor.json +10 -9
- package/r/mockup-viewer.json +5 -5
- package/r/page-header.json +22 -0
- package/r/pi-ambient.json +12 -12
- package/r/pi-app-kinds.json +10 -10
- package/r/pi-app.json +17 -17
- package/r/pi-artifacts.json +4 -4
- package/r/pi-chat-approval.json +4 -4
- package/r/pi-chat-attachments.json +4 -4
- package/r/pi-chat-markdown.json +3 -3
- package/r/pi-chat-mentions.json +4 -4
- package/r/pi-chat-pickers.json +4 -4
- package/r/pi-chat-plugins.json +11 -11
- package/r/pi-chat-question.json +3 -3
- package/r/pi-chat-slash.json +5 -5
- package/r/pi-chat-subagent.json +4 -4
- package/r/pi-chat-voice.json +7 -7
- package/r/pi-chat.json +17 -17
- package/r/pi-workspace.json +3 -3
- package/r/placed-agent.json +7 -7
- package/r/provider-setup.json +4 -4
- package/r/record-viewer.json +5 -3
- package/r/registry.json +494 -280
- package/r/shared-chat.json +11 -11
- package/r/spaces.json +7 -5
- package/r/task-list-viewer.json +28 -3
- package/r/theme.json +115 -50
- package/r/version-history.json +2 -2
- package/r/viewer-code.json +5 -5
- package/r/viewer-frame.json +9 -9
- package/r/viewer-html.json +7 -7
- package/r/viewer-image.json +5 -5
- package/r/viewer-markdown.json +7 -7
- package/r/viewer-pdf.json +6 -6
- package/r/viewers.json +6 -6
- package/r/windows.json +4 -4
package/r/pi-app.json
CHANGED
|
@@ -4,21 +4,21 @@
|
|
|
4
4
|
"title": "Pi agent app",
|
|
5
5
|
"description": "AgentWorkspace: a whole agent page in one component. Left: the sessions pane (search, New, rename, archive, delete; collapsible, a drawer on a narrow screen). Center: PiChat. Right: the artifact panel (versions, file viewer, auto-open of new artifacts) in the ArtifactWorkspace panel, a full-screen sheet on a phone; it draws files with the kinds the app passes in artifactKinds (defaultKinds of the pi-app-kinds item is the default list). layout=\"embedded\" drops the left column for one compact top bar (title, History, Library, New) that joins an app-shell screen header. Props are data and callbacks; hooks for the conversations handler (useConversations), the chat transport (useRemoteChat) and saved resources (useSaved).",
|
|
6
6
|
"dependencies": [
|
|
7
|
-
"@hachej/boring-ui-kit@^0.4.
|
|
8
|
-
"@hachej/boring-files@^0.4.
|
|
7
|
+
"@hachej/boring-ui-kit@^0.4.59",
|
|
8
|
+
"@hachej/boring-files@^0.4.59",
|
|
9
9
|
"react@^19.0.0",
|
|
10
|
-
"react-dom@^19.0.0"
|
|
11
|
-
"lucide-react@^1.51.0"
|
|
10
|
+
"react-dom@^19.0.0"
|
|
12
11
|
],
|
|
13
12
|
"registryDependencies": [
|
|
14
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
15
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
16
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
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.
|
|
20
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
21
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
13
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/pi-chat.json",
|
|
14
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/pi-chat-plugins.json",
|
|
15
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/pi-artifacts.json",
|
|
16
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/pi-workspace.json",
|
|
17
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/viewer-frame.json",
|
|
18
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/utils.json",
|
|
19
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/button.json",
|
|
20
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/file-tree.json",
|
|
21
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/_shared.json"
|
|
22
22
|
],
|
|
23
23
|
"files": [
|
|
24
24
|
{
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
},
|
|
30
30
|
{
|
|
31
31
|
"path": "registry/pi-app/sessions.tsx",
|
|
32
|
-
"content": "'use client';\n\nimport type { ReactNode } from 'react';\nimport { Button } from '@/components/ui/button';\nimport { Drawer, DrawerContent, DrawerTitle } from '@/components/ui/drawer';\nimport { DropdownMenu, DropdownMenuContent, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';\nimport { ScrollArea } from '@/components/ui/scroll-area';\nimport { ChevronsUpDownIcon, MenuIcon } from '
|
|
32
|
+
"content": "'use client';\n\nimport type { ReactNode } from 'react';\nimport { Button } from '@/components/ui/button';\nimport { Drawer, DrawerContent, DrawerTitle } from '@/components/ui/drawer';\nimport { DropdownMenu, DropdownMenuContent, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';\nimport { ScrollArea } from '@/components/ui/scroll-area';\nimport { ChevronsUpDownIcon, MenuIcon } from '../_shared/icons';\nimport { useAppText } from './app-labels';\nimport { ConversationList } from '../pi-chat/pi-chat';\nimport type { ConversationsConfig } from '../pi-chat/pi-chat';\nimport { cn } from '../utils/utils';\n\nexport interface WorkspaceAgent { readonly id: string; readonly label: string; readonly description?: string | undefined }\nexport interface WorkspaceAgents { readonly items: readonly WorkspaceAgent[]; readonly activeId: string; readonly onSelect: (id: string) => void }\nexport type SessionsView = 'conversations' | 'library';\n\n/** The same navigation in a desktop column and a focus-trapped, swipe-dismissable phone drawer. */\nexport function SessionsPane({ id, conversations, title: givenTitle, drawer, onClose, onCollapse, library, onChat, libraryContent, view = 'conversations', onViewChange, agents }: {\n /** The pane's element id (the toggle's `aria-controls`). */\n readonly id?: string | undefined;\n readonly conversations?: ConversationsConfig | undefined;\n readonly library?: { readonly selected: boolean; readonly onSelect: () => void; readonly side?: boolean } | undefined;\n readonly onChat?: (() => void) | undefined;\n readonly libraryContent?: ((onPicked: () => void) => ReactNode) | undefined;\n readonly view?: SessionsView | undefined;\n readonly onViewChange?: ((view: SessionsView) => void) | undefined;\n readonly agents?: WorkspaceAgents | undefined;\n readonly title?: string | undefined;\n readonly drawer: boolean;\n readonly onClose?: (() => void) | undefined;\n readonly onCollapse?: (() => void) | undefined;\n}) {\n const { labels, icons } = useAppText();\n const title = givenTitle ?? (conversations || !libraryContent ? labels.sessionsTitle : labels.library);\n const picked = () => { onChat?.(); if (drawer) onClose?.(); };\n const tabs = Boolean(libraryContent && conversations);\n const shown = !conversations && libraryContent ? 'library' : tabs ? view : 'conversations';\n const navigation = library && <Button variant=\"ghost\" data-testid=\"workspace-library\" aria-pressed={library.selected} className=\"mx-2 justify-start\" onClick={() => { library.onSelect(); if (drawer) onClose?.(); }}><icons.library aria-hidden=\"true\" />{labels.library}</Button>;\n const pane = <aside id={id} data-testid=\"conversations\" data-drawer={drawer ? 'true' : 'false'} aria-label={title}\n className={cn('pi-chat flex min-h-0 flex-1 flex-col bg-muted/30 text-foreground', drawer ? 'pt-[env(safe-area-inset-top)] pb-[env(safe-area-inset-bottom)]' : 'w-[260px] shrink-0 flex-none')}>\n <header className=\"flex h-13 shrink-0 items-center gap-2 px-4\">\n {agents && agents.items.length > 1 ? <DropdownMenu>\n <DropdownMenuTrigger render={<Button variant=\"ghost\" aria-label={labels.agent} data-testid=\"agent-select\" className=\"min-w-0 flex-1 justify-between\" />}>\n <span className=\"truncate\">{(agents.items.find(agent => agent.id === agents.activeId) ?? agents.items[0])?.label}</span><ChevronsUpDownIcon data-icon=\"inline-end\" aria-hidden=\"true\" />\n </DropdownMenuTrigger>\n <DropdownMenuContent align=\"start\" aria-label={labels.agent}>\n <DropdownMenuRadioGroup value={agents.activeId} onValueChange={agents.onSelect}>\n {agents.items.map(agent => <DropdownMenuRadioItem key={agent.id} value={agent.id} title={agent.description} data-testid={`agent-option-${agent.id}`}>{agent.label}</DropdownMenuRadioItem>)}\n </DropdownMenuRadioGroup>\n </DropdownMenuContent>\n </DropdownMenu> : <h2 className=\"m-0 min-w-0 flex-1 truncate text-sm font-semibold tracking-tight\">{agents?.items[0]?.label ?? title}</h2>}\n {(drawer ? onClose : onCollapse) && <SessionsToggle open drawer={drawer} navigation={Boolean(library || libraryContent)} onToggle={(drawer ? onClose : onCollapse)!} />}\n </header>\n {conversations?.onNew && <Button variant=\"ghost\" data-testid=\"conversation-new\" className=\"mx-2 mb-1 justify-start\" onClick={() => { conversations.onNew?.(); picked(); }}><icons.newChat data-icon=\"inline-start\" aria-hidden=\"true\" />{labels.newChat}</Button>}\n {tabs && <div role=\"tablist\" aria-label={title} className=\"mx-2 flex gap-1\">{(['conversations', 'library'] as const).map(tab => <Button key={tab} role=\"tab\" variant=\"ghost\" aria-selected={shown === tab} data-testid={`sessions-tab-${tab}`} onClick={() => onViewChange?.(tab)}>{tab === 'library' ? labels.library : title}</Button>)}</div>}\n {shown === 'library' && libraryContent ? <ScrollArea data-testid=\"sessions-library\" className=\"min-h-0 flex-1\">{libraryContent(() => { if (drawer) onClose?.(); })}</ScrollArea>\n : conversations ? <ConversationList conversations={conversations} onPicked={picked} navigation={navigation} /> : navigation}\n </aside>;\n if (!drawer) return pane;\n return <Drawer open onOpenChange={next => { if (!next) onClose?.(); }} swipeDirection=\"left\">\n <DrawerContent aria-label={title}>\n <DrawerTitle className=\"sr-only\">{title}</DrawerTitle>{pane}\n </DrawerContent>\n </Drawer>;\n}\n\nexport function SessionsToggle({ open, drawer, onToggle, navigation = false, controls }: { readonly open: boolean; readonly drawer: boolean; readonly onToggle: () => void; readonly navigation?: boolean; readonly controls?: string | undefined }) {\n const { labels, icons } = useAppText();\n const label = navigation ? drawer ? open ? labels.closeNavigation : labels.openNavigation : open ? labels.hideNavigation : labels.showNavigation\n : drawer ? open ? labels.closeSessions : labels.openSessions : open ? labels.hideSessions : labels.showSessions;\n return <Button size=\"icon-sm\" variant=\"ghost\" data-testid=\"sessions-toggle\" aria-label={label} title={label} aria-expanded={open} aria-controls={controls} onClick={onToggle} className=\"shrink-0\">\n {drawer && !open ? <MenuIcon data-icon=\"inline-start\" aria-hidden=\"true\" /> : <icons.sessionsToggle data-icon=\"inline-start\" aria-hidden=\"true\" />}\n </Button>;\n}\n",
|
|
33
33
|
"type": "registry:component",
|
|
34
34
|
"target": "components/pi-app/sessions.tsx"
|
|
35
35
|
},
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
},
|
|
42
42
|
{
|
|
43
43
|
"path": "registry/pi-app/workspace-bar.tsx",
|
|
44
|
-
"content": "'use client';\n\n// The compact top bar of `AgentWorkspace layout=\"embedded\"`: the conversation title, then History, Library, the host's own panels and New\n// as icon buttons (each panel opens a popover on a wide workspace and a bottom sheet on a phone) and the host's controls. Inside an app-shell\n// screen the controls go into the shell's header instead (`useScreenHeaderSlot`), so the page keeps one row. Host concern: Pi has no layout.\nimport { useEffect, useState } from 'react';\nimport type { ReactElement, ReactNode } from 'react';\nimport { createPortal } from 'react-dom';\nimport { CircleIcon, XIcon } from '
|
|
44
|
+
"content": "'use client';\n\n// The compact top bar of `AgentWorkspace layout=\"embedded\"`: the conversation title, then History, Library, the host's own panels and New\n// as icon buttons (each panel opens a popover on a wide workspace and a bottom sheet on a phone) and the host's controls. Inside an app-shell\n// screen the controls go into the shell's header instead (`useScreenHeaderSlot`), so the page keeps one row. Host concern: Pi has no layout.\nimport { useEffect, useState } from 'react';\nimport type { ReactElement, ReactNode } from 'react';\nimport { createPortal } from 'react-dom';\nimport { CircleIcon, XIcon } from '../_shared/icons';\nimport { Button } from '@/components/ui/button';\nimport { Drawer, DrawerContent, DrawerTitle } from '@/components/ui/drawer';\nimport { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';\nimport { Separator } from '@/components/ui/separator';\nimport { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';\nimport { ConversationList } from '../pi-chat/history';\nimport type { ConversationsConfig } from '../pi-chat/config';\nimport type { BlockIcon } from '../button/actions';\nimport { useScreenHeaderSlot } from '../utils/screen-header-slot';\nimport { cn } from '../utils/utils';\nimport { useAppText } from './app-labels';\n\n/** 44px on a phone (`narrow`), the bar's 32px otherwise. */\nconst iconSize = (narrow: boolean) => cn(narrow && 'size-11');\n\n/** A hover and focus tooltip on a wide workspace; a phone has no hover, and focus coming back from a sheet must not raise one. */\nfunction Hinted({ label, trigger, narrow }: { readonly label: string; readonly trigger: ReactElement; readonly narrow: boolean }) {\n if (narrow) return trigger;\n return <Tooltip><TooltipTrigger render={trigger} /><TooltipContent>{label}</TooltipContent></Tooltip>;\n}\n\n/** One icon button that opens `children`: an anchored popover on a wide workspace, a bottom sheet on a phone. */\nfunction BarPanel({ id, label, icon: Icon, dot, narrow, open, onOpenChange, children }: {\n readonly id: string;\n readonly label: string;\n readonly icon: BlockIcon;\n readonly dot?: boolean | undefined;\n readonly narrow: boolean;\n readonly open: boolean;\n readonly onOpenChange: (open: boolean) => void;\n readonly children: ReactNode;\n}) {\n const content = <><Icon aria-hidden=\"true\" />{dot && <CircleIcon data-testid={`workspace-bar-${id}-dot`} aria-hidden=\"true\" className=\"absolute top-1 right-1 size-2 fill-primary stroke-none\" />}</>;\n const button = <Button variant=\"ghost\" size=\"icon\" data-testid={`workspace-bar-${id}`} aria-label={label} data-unread={dot || undefined} className={cn('relative', iconSize(narrow))}>{content}</Button>;\n if (narrow) return <>\n <Button variant=\"ghost\" size=\"icon\" data-testid={`workspace-bar-${id}`} aria-label={label} data-unread={dot || undefined} aria-haspopup=\"dialog\" aria-expanded={open} className={cn('relative', iconSize(narrow))} onClick={() => onOpenChange(true)}>{content}</Button>\n <Drawer open={open} onOpenChange={onOpenChange} showSwipeHandle>\n <DrawerContent data-testid={`workspace-${id}`} aria-label={label} className=\"pi-chat min-h-[50dvh] max-h-[85dvh]\">\n <DrawerTitle className=\"sr-only\">{label}</DrawerTitle>\n <div className=\"flex min-h-0 flex-1 flex-col pb-[env(safe-area-inset-bottom)]\">{children}</div>\n </DrawerContent>\n </Drawer>\n </>;\n return <Popover open={open} onOpenChange={onOpenChange}>\n <Hinted label={label} narrow={false} trigger={<PopoverTrigger render={button} />} />\n <PopoverContent data-testid={`workspace-${id}`} aria-label={label} align=\"end\" className=\"pi-chat w-[23rem] max-w-[calc(100vw-1rem)] max-h-[min(30rem,70dvh)]\">{children}</PopoverContent>\n </Popover>;\n}\n\n/** A host panel in the bar, beside History and Library: an icon button that opens `content` (`close` closes the popover or sheet). */\nexport interface WorkspaceBarPanel {\n readonly id: string;\n readonly label: string;\n readonly icon: BlockIcon;\n /** Host-owned unread indicator; opening the panel calls `onOpen`. */\n readonly dot?: boolean | undefined;\n readonly onOpen?: (() => void) | undefined;\n /** Local panel state only, including switches to another panel; not a durable acknowledgement. */\n readonly onOpenChange?: ((open: boolean) => void) | undefined;\n readonly content: (close: () => void) => ReactNode;\n}\n\nexport interface WorkspaceBarProps {\n readonly title: string;\n /** Muted after a fixed `title`: the conversation's title. Hidden on a phone, where History names the conversations. */\n readonly subtitle?: string | undefined;\n readonly conversations: ConversationsConfig | undefined;\n /** The Library's list (`onPicked` closes the popover or sheet), or undefined without a Library. */\n readonly library: ((onPicked: () => void) => ReactNode) | undefined;\n /** The host's own panels, after Library and before New (test ids `workspace-bar-<id>` and `workspace-<id>`). */\n readonly panels?: readonly WorkspaceBarPanel[] | undefined;\n /** Put host panels before History and Library. Default false. */\n readonly panelsFirst?: boolean | undefined;\n /** A phone-sized workspace: sheets instead of popovers, 44px buttons. */\n readonly narrow: boolean;\n /** The host's own controls, after New. */\n readonly controls?: ReactNode;\n /**\n * The host owns the one bar (Studio's phone header): nothing is drawn here, but `command` still opens History, the Library or a new\n * conversation. The panels open from an invisible anchor at the top right.\n */\n readonly hidden?: boolean | undefined;\n /** A host's request to open `history` or `library` or start a `new` conversation; `n` changes with each request (the same kind twice needs a new `n`). */\n readonly command?: { readonly kind: 'history' | 'library' | 'new'; readonly n: number } | undefined;\n}\n\n/** The bar, or only its controls in the app shell's header when the page sits in a shell screen (then nothing renders here). */\nexport function WorkspaceBar({ title, subtitle, conversations, library, panels, panelsFirst = false, narrow, controls, hidden = false, command }: WorkspaceBarProps) {\n const { labels, icons } = useAppText();\n const slot = useScreenHeaderSlot();\n const [shown, setShown] = useState<string | null>(null);\n const change = (next: string | null) => {\n if (next === shown) return;\n panels?.find(panel => panel.id === shown)?.onOpenChange?.(false);\n panels?.find(panel => panel.id === next)?.onOpenChange?.(true);\n setShown(next);\n };\n const [handled, setHandled] = useState(command?.n);\n useEffect(() => {\n if (!command || command.n === handled) return;\n // eslint-disable-next-line react-hooks/set-state-in-effect -- a host's request is handled once per `n`; the effect settles\n setHandled(command.n);\n if (command.kind === 'new') conversations?.onNew?.();\n else if (command.kind === 'history' ? conversations : library) setShown(command.kind);\n }, [command, handled, conversations, library]);\n const toggle = (which: string) => (open: boolean) => change(open ? which : shown === which ? null : shown);\n const close = () => change(null);\n const hostPanels = panels?.map(panel => <BarPanel key={panel.id} id={panel.id} label={panel.label} icon={panel.icon} dot={panel.dot} narrow={narrow} open={shown === panel.id} onOpenChange={open => { toggle(panel.id)(open); if (open) panel.onOpen?.(); }}>\n {panel.content(close)}\n </BarPanel>);\n const tools = <div data-testid=\"workspace-bar-tools\" className=\"flex shrink-0 items-center gap-0.5\">\n {panelsFirst && hostPanels}\n {conversations && <BarPanel id=\"history\" label={labels.history} icon={icons.history} narrow={narrow} open={shown === 'history'} onOpenChange={toggle('history')}>\n <header className=\"flex shrink-0 items-center gap-2 pl-1\">\n <h3 className=\"m-0 min-w-0 flex-1 truncate text-sm font-semibold\">{labels.history}</h3>\n <Button variant=\"ghost\" size=\"icon-sm\" aria-label={labels.close} title={labels.close} className={iconSize(narrow)} onClick={close}><XIcon aria-hidden=\"true\" /></Button>\n </header>\n <Separator />\n <div className=\"flex min-h-0 flex-1 flex-col\"><ConversationList conversations={conversations} onPicked={close} /></div>\n </BarPanel>}\n {library && <BarPanel id=\"library\" label={labels.library} icon={icons.library} narrow={narrow} open={shown === 'library'} onOpenChange={toggle('library')}>\n <div className=\"min-h-0 flex-1 overflow-auto\">{library(close)}</div>\n </BarPanel>}\n {!panelsFirst && hostPanels}\n {conversations?.onNew && <Hinted label={labels.newChat} narrow={narrow} trigger={<Button variant=\"ghost\" size=\"icon\" data-testid=\"workspace-bar-new\" aria-label={labels.newChat} className={iconSize(narrow)} onClick={() => conversations.onNew?.()}><icons.newChat aria-hidden=\"true\" /></Button>} />}\n {controls}\n </div>;\n if (hidden) return <div data-testid=\"workspace-bar-hidden\" inert className=\"pointer-events-none absolute top-0 right-0 size-px overflow-visible opacity-0\">{tools}</div>;\n if (slot) return createPortal(tools, slot);\n return <>\n <header data-testid=\"workspace-bar\" className=\"flex h-13 shrink-0 items-center gap-1 pr-2 pl-4 max-md:h-14 max-md:pl-3\">\n <div className=\"flex min-w-0 flex-1 items-baseline gap-2\">\n <h2 data-testid=\"workspace-bar-title\" className={cn('m-0 min-w-0 truncate text-sm font-medium tracking-tight', subtitle === undefined && 'flex-1')}>{title}</h2>\n {subtitle !== undefined && <span data-testid=\"workspace-bar-conversation\" className=\"min-w-0 flex-1 truncate text-sm text-muted-foreground max-md:hidden\">{subtitle}</span>}\n </div>\n {tools}\n </header>\n <Separator className=\"h-px w-full\" />\n </>;\n}\n",
|
|
45
45
|
"type": "registry:component",
|
|
46
46
|
"target": "components/pi-app/workspace-bar.tsx"
|
|
47
47
|
},
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
},
|
|
54
54
|
{
|
|
55
55
|
"path": "registry/pi-app/file-viewer.tsx",
|
|
56
|
-
"content": "'use client';\n\n// Opens one workspace file in the viewer its kind names (`ViewerOptions.kinds`), always in the standard viewer frame (viewer-frame item):\n// Markdown and HTML in their editors (revisioned through the host's resource client, so saves go back to the file and an edit made behind\n// the editor's back is a conflict), images and PDFs read-only from the file's bytes, a drawing kind in the artifact panel, anything else as\n// text. Each viewer loads the first time its kind opens. A host viewer (`viewers[kind]`) wins over a document kind.\nimport { Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';\nimport type { ReactNode } from 'react';\nimport { DownloadIcon } from 'lucide-react';\nimport { Button } from '@/components/ui/button';\nimport { randomUUID } from '@hachej/boring-files/platform';\nimport type { ResourceLocator, ResourceSnapshot } from '@hachej/boring-files';\nimport { createMarkdownController } from '@hachej/boring-ui-kit/markdown';\nimport type { MarkdownController } from '@hachej/boring-ui-kit/markdown';\nimport { createHtmlController } from '@hachej/boring-ui-kit/html';\nimport type { HtmlController } from '@hachej/boring-ui-kit/html';\nimport { ViewerFrame } from '../viewer-frame/viewer-frame';\nimport { downloadFile } from '../viewer-frame/download';\nimport { formatBytes } from '../viewer-frame/media';\nimport { copyText } from '../utils/utils';\nimport { mediaTypeOf, viewerKindOf } from '@hachej/boring-files/file-types';\nimport { isBlobPointerPath, resolveBlobPointer } from '@hachej/boring-files/blob';\nimport type { BlobItem } from '@hachej/boring-files/blob';\nimport type { ViewerKind as FileKind } from '@hachej/boring-files/file-types';\nimport { KIND_LABEL, decodeText } from './file-kinds';\nimport { ArtifactPanel, useReportShown } from './artifact-panel';\nimport { artifactKind } from '../pi-artifacts/artifact';\nimport type { ArtifactDescriptor, ArtifactType } from '../pi-artifacts/artifact';\nimport { LazyPane, Mounted, ViewLoading, drawsText, isEditorKind, isMediaKind, noKinds } from './viewer-kinds';\nimport type { EditorKind, MediaKind, PanelKind } from './viewer-kinds';\nimport type { ViewerOptions } from './artifact-panel';\nimport { useSaved } from './use-saved';\nimport { useAppText } from './app-labels';\n\nconst POLL_MS = 1500;\nconst nameOf = (path: string) => path.split('/').pop() || path;\n\ninterface Common {\n readonly path: string;\n readonly kind: FileKind;\n readonly locator: ResourceLocator;\n readonly options: ViewerOptions;\n readonly onClose: () => void;\n readonly back: ReactNode;\n readonly onUnsavedChange?: ((unsaved: boolean) => void) | undefined;\n readonly onBeforeLeaveChange?: ((flush: (() => Promise<boolean>) | null) => void) | undefined;\n}\n\nconst FILE_TYPE: Partial<Record<FileKind, ArtifactType>> = { markdown: 'markdown', html: 'html' };\n/** A file as the artifact its kind sees: the same descriptor a `present` of it would carry, so one kind match serves every way of opening a file. */\nconst fileArtifact = (path: string, locator: ResourceLocator): ArtifactDescriptor => ({ schema: 'boring.artifact', version: 1, title: nameOf(path), type: FILE_TYPE[viewerKindOf(path)] ?? 'code', mediaType: mediaTypeOf(path), target: locator, revision: 'latest' });\n/** The image or PDF kind of a media type (a file's, or a large file's behind a pointer). */\nconst mediaKindOf = (kinds: readonly PanelKind[], artifact: ArtifactDescriptor): MediaKind | undefined => artifactKind(artifact, kinds.filter(isMediaKind));\n\nconst subtitleOf = (locator: ResourceLocator) => <span data-testid=\"file-path\" className=\"truncate\">{locator.resource.path}</span>;\n\n/** Markdown or HTML: a revisioned controller over the file. A new revision remounts the pane; it starts in the mode the person chose. */\nfunction FileDocument({ path, editor, locator, options, onClose, back, onUnsavedChange, onBeforeLeaveChange }: Common & { readonly editor: EditorKind }) {\n const { client, identity } = options;\n const markdown = editor.editor === 'markdown';\n const create = useMemo(() => (snapshot: ResourceSnapshot): MarkdownController | HtmlController => (markdown ? createMarkdownController : createHtmlController)({\n identity, client, instanceId: randomUUID(), epoch: 'workspace', source: { kind: 'saved', snapshot } }), [markdown, client, identity]);\n const autosave = useRef<{ controller: MarkdownController | HtmlController; flush: () => Promise<boolean> } | null>(null);\n const saved = useSaved({ client, target: locator, create, beforeDispose: controller => autosave.current?.controller === controller ? autosave.current.flush() : undefined });\n const registerAutosave = useCallback((flush: (() => Promise<boolean>) | null) => {\n autosave.current = flush && saved.controller ? { controller: saved.controller, flush } : null;\n }, [saved.controller]);\n useLayoutEffect(() => {\n const controller = saved.controller;\n if (!controller) return;\n const notify = () => {\n const snapshot = controller.getSnapshot();\n onUnsavedChange?.(snapshot.dirty || snapshot.save.kind === 'pending'\n || (snapshot.save.kind === 'settled' && snapshot.save.result.kind === 'unknown'));\n };\n notify();\n const unsubscribe = controller.subscribe(notify);\n return () => { unsubscribe(); onUnsavedChange?.(false); };\n }, [saved.controller, onUnsavedChange]);\n const { labels } = useAppText();\n const [mode, setMode] = useState<string>(markdown ? 'rich' : 'preview');\n const [formattingToolbar, setFormattingToolbar] = useState<'selection' | 'pinned'>('selection');\n // The editor loads the first time a document opens: the file counts as shown once it has mounted.\n const [mounted, setMounted] = useState<string>();\n const opened = saved.kind === 'open' && saved.controller ? saved.snapshot.ref.revision : undefined;\n useReportShown(options.onShown, path, opened !== undefined && mounted === opened ? opened : undefined);\n const common = { title: nameOf(path), subtitle: subtitleOf(locator), target: { file: path }, onClose, titleTestId: 'file-title', headerStart: back, ...(options.share ? { onShare: options.share } : {}) };\n // Until the document is open the bar offers no Share, Copy or menu actions of its own: they would act on a document that is not shown.\n if (saved.kind !== 'open' || !saved.controller) {\n return <ViewerFrame title={common.title} subtitle={common.subtitle} onClose={onClose} headerStart={back}>\n <p role=\"status\" className=\"m-0 p-4 text-sm text-muted-foreground\">{saved.kind === 'loading' ? labels.loading : saved.kind === 'missing' ? labels.fileMissing : labels.fileNotText}</p>\n </ViewerFrame>;\n }\n const revision = saved.snapshot.ref.revision;\n // The preview selector the journeys use keeps working: the wrapper holds the document's text.\n let pane: ReactNode;\n if (editor.editor === 'markdown') {\n pane = <LazyPane load={editor.load} key={revision} controller={saved.controller as MarkdownController} revision={revision} initialMode={mode as 'rich'} onModeChange={setMode} formattingToolbar={formattingToolbar} onFormattingToolbarChange={setFormattingToolbar} onAutosaveChange={registerAutosave} onBeforeLeaveChange={onBeforeLeaveChange} {...common} />;\n } else {\n pane = <LazyPane load={editor.load} key={revision} controller={saved.controller as HtmlController} revision={revision} initialMode={mode as 'preview'} onModeChange={setMode} {...(options.interactive ? { interactive: options.interactive } : {})} {...common} />;\n }\n return <div data-testid=\"file-preview\" className=\"h-full min-h-0\" data-revision={revision}>\n <Suspense fallback={<ViewerFrame testId=\"viewer-pending\" title={common.title} subtitle={common.subtitle} onClose={onClose} headerStart={back}><ViewLoading label={labels.loading} /></ViewerFrame>}>\n {pane}<Mounted value={revision} onMount={setMounted} />\n </Suspense>\n </div>;\n}\n\n/** Native controls only. A failed codec leaves a download instead of a broken video box. */\nfunction VideoPreview({ item }: { readonly item: BlobItem }) {\n const [failed, setFailed] = useState(false);\n const { labels } = useAppText();\n return <div className=\"flex min-h-0 min-w-0 flex-1 items-center justify-center\">\n {failed ? <div className=\"flex flex-col items-center gap-3 p-4\">\n <p role=\"status\" data-testid=\"video-unplayable\" className=\"text-muted-foreground\">{labels.videoUnreadable}</p>\n <Button nativeButton={false} render={<a href={item.url} download={item.name} />} data-testid=\"video-download\">{labels.download}</Button>\n </div> : <video src={item.url} aria-label={item.name} controls playsInline preload=\"metadata\" onError={() => setFailed(true)} className=\"h-full max-h-full w-full min-w-0 object-contain\" />}\n </div>;\n}\n\n/**\n * A large file behind a pointer, from the host's URL: images, PDFs and videos inline, anything else as a download.\n * The viewers get a URL; none reads the media bytes into the page.\n */\nfunction BlobFile({ item, subtitle, media, ...common }: { readonly item: BlobItem; readonly subtitle: ReactNode; readonly media: MediaKind | undefined; readonly onClose: () => void; readonly onRefresh: () => unknown; readonly target: unknown; readonly titleTestId: string; readonly headerStart: ReactNode; readonly onShare?: NonNullable<ViewerOptions['share']> }) {\n const { labels } = useAppText();\n const details = <>{subtitle}<span aria-hidden=\"true\">·</span><span>{formatBytes(item.size)}</span></>;\n if (media?.media === 'image') return <LazyPane load={media.load} name={item.name} mediaType={item.type} url={item.url} subtitle={details} {...common} />;\n if (media?.media === 'pdf') return <LazyPane load={media.load} name={item.name} url={item.url} subtitle={subtitle} {...common} />;\n if (item.type.startsWith('video/')) return <ViewerFrame title={item.name} subtitle={details} {...common} onDownload={() => {\n const link = Object.assign(document.createElement('a'), { href: item.url, download: item.name });\n document.body.append(link); link.click(); link.remove();\n }}><VideoPreview key={item.url} item={item} /></ViewerFrame>;\n return <ViewerFrame title={item.name} subtitle={details} {...common}>\n <div data-testid=\"blob-file\" data-blob-type={item.type} className=\"m-auto flex flex-col items-center gap-3 p-6 text-center\">\n <p className=\"m-0 text-sm text-muted-foreground\">{item.type}</p>\n <Button nativeButton={false} render={<a href={item.url} download={item.name} />} data-testid=\"blob-download\"><DownloadIcon data-icon=\"inline-start\" aria-hidden=\"true\" />{labels.download}</Button>\n </div>\n </ViewerFrame>;\n}\n\ntype Loaded = { readonly kind: 'loading' | 'missing' } | { readonly kind: 'ready'; readonly bytes: Uint8Array; readonly revision: string; readonly text: string | undefined };\nconst LOADING: Loaded = { kind: 'loading' };\n\n/** Image, PDF and text files: read through the resource client, refreshed on demand (and followed while open, for text). */\nfunction FileBytes({ path, kind, locator, options, onClose, back, media }: Common & { readonly media: MediaKind | undefined }) {\n const { labels } = useAppText();\n // An image or PDF pane loads the first time one opens: the file counts as shown once it has mounted.\n const [mounted, setMounted] = useState<string>();\n const targetKey = JSON.stringify(locator);\n const binding = useMemo(() => ({ client: options.client, locator, path }), [options.client, targetKey, path]); // eslint-disable-line react-hooks/exhaustive-deps\n const [stored, setStored] = useState<{ readonly binding: typeof binding; readonly value: Loaded }>();\n const file: Loaded = stored?.binding === binding ? stored.value : LOADING;\n const running = useRef<AbortController | undefined>(undefined);\n const load = useCallback(async () => {\n running.current?.abort();\n const abort = new AbortController(); running.current = abort;\n try {\n const read = await binding.client.read({ target: binding.locator, revision: { kind: 'latest' } }, abort.signal);\n if (abort.signal.aborted || running.current !== abort) return;\n if (read.kind !== 'available') {\n setStored(current => current?.binding === binding && current.value.kind === 'ready' ? current : { binding, value: { kind: 'missing' } });\n return;\n }\n const { bytes, ref } = read.snapshot;\n setStored({ binding, value: { kind: 'ready', bytes, revision: ref.revision, text: decodeText(binding.path, bytes) } });\n } catch {\n if (!abort.signal.aborted && running.current === abort) setStored(current => current?.binding === binding && current.value.kind === 'ready' ? current : { binding, value: { kind: 'missing' } });\n }\n }, [binding]);\n useEffect(() => {\n // eslint-disable-next-line react-hooks/set-state-in-effect -- load is async: it sets state when the read settles, not during the effect\n void load();\n const timer = kind === 'text' ? setInterval(() => { void load(); }, POLL_MS) : undefined;\n return () => { running.current?.abort(); clearInterval(timer); };\n }, [load, kind]);\n // A pointer file is resolved here, where it is read, once per read: the viewers below receive the item, never the pointer.\n const blob = useMemo(() => file.kind === 'ready' && isBlobPointerPath(path) ? resolveBlobPointer(file.bytes, options.blobBase) : undefined, [file, path, options.blobBase]);\n // A lazily loaded pane (an image, a PDF, a large file's viewer) counts as shown once it has mounted; the text view at once.\n const lazyPane = blob?.kind === 'blob' || !blob && !!media;\n useReportShown(options.onShown, path, file.kind === 'ready' && (!lazyPane || mounted === file.revision) ? file.revision : undefined);\n const common = { subtitle: subtitleOf(locator), target: { file: path }, onClose, titleTestId: 'file-title', headerStart: back, ...(options.share ? { onShare: options.share } : {}) };\n // Until the file is read the bar offers no Share: the image and PDF panes bring their own frame, so this one is replaced (remounted)\n // when the bytes arrive, and a Share pressed just then would copy the link with its \"Link copied\" notice lost with the old frame.\n if (file.kind !== 'ready') {\n return <ViewerFrame title={nameOf(path)} subtitle={common.subtitle} onClose={onClose} titleTestId=\"file-title\" headerStart={back}>\n <p role=\"status\" className=\"m-0 p-4 text-sm text-muted-foreground\">{file.kind === 'loading' ? labels.loading : labels.fileMissing}</p>\n </ViewerFrame>;\n }\n // The bar while a view loads: `viewer-pending-*` test ids, so `viewer-more` is the view's own menu.\n const loading = <ViewerFrame testId=\"viewer-pending\" title={nameOf(path)} subtitle={common.subtitle} onClose={onClose} titleTestId=\"file-title\" headerStart={back}><ViewLoading label={labels.loading} /></ViewerFrame>;\n const shown = <Mounted value={file.revision} onMount={setMounted} />;\n if (blob?.kind === 'blob') {\n const blobMedia = mediaKindOf(options.kinds ?? noKinds, { ...fileArtifact(path, locator), mediaType: blob.item.type });\n return <Suspense fallback={loading}><BlobFile item={blob.item} media={blobMedia} onRefresh={load} {...common} />{shown}</Suspense>;\n }\n if (media?.media === 'image') return <Suspense fallback={loading}><LazyPane load={media.load} name={nameOf(path)} mediaType={mediaTypeOf(path)} bytes={file.bytes} onRefresh={load} {...common} />{shown}</Suspense>;\n if (media?.media === 'pdf') return <Suspense fallback={loading}><LazyPane load={media.load} name={nameOf(path)} bytes={file.bytes} onRefresh={load} {...common} />{shown}</Suspense>;\n const { text } = file, size = file.bytes.byteLength;\n return <ViewerFrame title={nameOf(path)} {...common} subtitle={<>{subtitleOf(locator)}<span aria-hidden=\"true\">·</span><span>{formatBytes(size)}</span></>}\n onRefresh={load} {...(text === undefined ? {} : { onCopy: () => copyText(text), onDownload: () => downloadFile(nameOf(path), text, 'text/plain;charset=utf-8') })}>\n {blob?.kind === 'unsupported-version' && <p role=\"note\" data-testid=\"blob-newer-format\" className=\"m-0 px-3 pt-3 text-sm text-muted-foreground\">{labels.newerBlobFormat}</p>}\n {text === undefined\n ? <p role=\"status\" className=\"m-0 p-4 text-sm text-muted-foreground\">{labels.binaryFile(formatBytes(size))}</p>\n : <div className=\"min-h-0 flex-1 overflow-auto p-3\"><pre data-testid=\"file-preview\" className=\"m-0 font-mono text-sm leading-relaxed break-words whitespace-pre-wrap\">{text}</pre></div>}\n </ViewerFrame>;\n}\n\n/**\n * One file in the panel. `path` is the host's name for it (shown in the share target and `data-path`); `locator` is the resource the\n * client reads. Remount per file with a `key`. `onBack` adds a back button to the bar (for example to the host's file list).\n */\nexport function FileViewer({ path, locator, options, onClose, onBack, backLabel, onUnsavedChange, onBeforeLeaveChange }: {\n readonly path: string;\n readonly locator: ResourceLocator;\n readonly options: ViewerOptions;\n readonly onClose: () => void;\n readonly onBack?: (() => void) | undefined;\n readonly backLabel?: string | undefined;\n /** Built-in editable viewers report unsaved and unconfirmed changes for navigation guards. */\n readonly onUnsavedChange?: ((unsaved: boolean) => void) | undefined;\n /** The Markdown autosave barrier, for flush-before-navigation guards. Null for explicit-save viewers. */\n readonly onBeforeLeaveChange?: ((flush: (() => Promise<boolean>) | null) => void) | undefined;\n}) {\n const kind = viewerKindOf(path);\n const { labels, icons } = useAppText();\n const back = onBack ? <Button variant=\"ghost\" size=\"sm\" data-testid=\"file-back\" aria-label={backLabel ?? labels.back} title={backLabel ?? labels.back} onClick={onBack} className=\"shrink-0\"><icons.back data-icon=\"inline-start\" aria-hidden=\"true\" /><span className=\"max-md:sr-only\">{backLabel ?? labels.back}</span></Button> : undefined;\n const common: Common = { path, kind, locator, options, onClose, back, onUnsavedChange, onBeforeLeaveChange };\n const locatorKey = JSON.stringify(locator);\n const artifact = useMemo(() => fileArtifact(path, locator), [path, locatorKey]); // eslint-disable-line react-hooks/exhaustive-deps\n // One resolution through the kinds, whoever opened the file: a host viewer for the file kind wins over a document kind (a `fallback`\n // one), not over any other kind. A drawing kind (a maquette, a diagram, an app's) shows the file in the artifact panel.\n const kinds = options.kinds ?? noKinds;\n const found = artifactKind(artifact, kinds.filter(entry => drawsText(entry) || isMediaKind(entry)));\n const custom = found && !found.fallback ? undefined : options.viewers?.[kind];\n const resolved = custom ? undefined : found;\n const drawn = resolved && !isEditorKind(resolved) && !isMediaKind(resolved) ? artifact : undefined;\n if (drawn) return <div data-testid=\"file-viewer\" data-kind=\"artifact\" data-path={path} aria-label={labels.fileViewer(drawn.title)} className=\"flex h-full min-h-0 flex-col overflow-hidden [&>*]:min-h-0 [&>*]:flex-1\">\n <ArtifactPanel active={{ descriptor: drawn, follow: true }} versions={[]} options={options} onSelect={() => {}} onClose={onClose} headerStart={back} />\n </div>;\n return <div data-testid=\"file-viewer\" data-kind={kind} data-path={path} aria-label={labels.fileViewer(KIND_LABEL[kind])} className=\"flex h-full min-h-0 flex-col overflow-hidden [&>*]:min-h-0 [&>*]:flex-1\">\n {custom ? custom({ target: locator, title: nameOf(path), frame: { titleTestId: 'file-title', onClose, target: { file: path }, headerStart: back } })\n : isEditorKind(resolved) ? <FileDocument {...common} editor={resolved} />\n // The resource client supplies bytes, not a streaming URL. Unlike images, videos must not be buffered to make an object URL.\n : kind === 'video' ? <ViewerFrame title={nameOf(path)} subtitle={subtitleOf(locator)} onClose={onClose} headerStart={back}><p role=\"status\" className=\"m-0 p-4 text-sm text-muted-foreground\">{labels.fileNotText}</p></ViewerFrame>\n : kind === 'canvas' ? <ViewerFrame title={nameOf(path)} subtitle={subtitleOf(locator)} onClose={onClose} headerStart={back}><p role=\"status\" className=\"m-0 p-4 text-sm text-muted-foreground\">{labels.noCanvasViewer}</p></ViewerFrame>\n : <FileBytes {...common} media={isMediaKind(resolved) ? resolved : undefined} />}\n </div>;\n}\n",
|
|
56
|
+
"content": "'use client';\n\n// Opens one workspace file in the viewer its kind names (`ViewerOptions.kinds`), always in the standard viewer frame (viewer-frame item):\n// Markdown and HTML in their editors (revisioned through the host's resource client, so saves go back to the file and an edit made behind\n// the editor's back is a conflict), images and PDFs read-only from the file's bytes, a drawing kind in the artifact panel, anything else as\n// text. Each viewer loads the first time its kind opens. A host viewer (`viewers[kind]`) wins over a document kind.\nimport { Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';\nimport type { ReactNode } from 'react';\nimport { DownloadIcon } from '../_shared/icons';\nimport { Button } from '@/components/ui/button';\nimport { randomUUID } from '@hachej/boring-files/platform';\nimport type { ResourceLocator, ResourceSnapshot } from '@hachej/boring-files';\nimport { createMarkdownController } from '@hachej/boring-ui-kit/markdown';\nimport type { MarkdownController } from '@hachej/boring-ui-kit/markdown';\nimport { createHtmlController } from '@hachej/boring-ui-kit/html';\nimport type { HtmlController } from '@hachej/boring-ui-kit/html';\nimport { ViewerFrame } from '../viewer-frame/viewer-frame';\nimport { downloadFile } from '../viewer-frame/download';\nimport { formatBytes } from '../viewer-frame/media';\nimport { copyText } from '../utils/utils';\nimport { mediaTypeOf, viewerKindOf } from '@hachej/boring-files/file-types';\nimport { isBlobPointerPath, resolveBlobPointer } from '@hachej/boring-files/blob';\nimport type { BlobItem } from '@hachej/boring-files/blob';\nimport type { ViewerKind as FileKind } from '@hachej/boring-files/file-types';\nimport { KIND_LABEL, decodeText } from './file-kinds';\nimport { ArtifactPanel, useReportShown } from './artifact-panel';\nimport { artifactKind } from '../pi-artifacts/artifact';\nimport type { ArtifactDescriptor, ArtifactType } from '../pi-artifacts/artifact';\nimport { LazyPane, Mounted, ViewLoading, drawsText, isEditorKind, isMediaKind, noKinds } from './viewer-kinds';\nimport type { EditorKind, MediaKind, PanelKind } from './viewer-kinds';\nimport type { ViewerOptions } from './artifact-panel';\nimport { useSaved } from './use-saved';\nimport { useAppText } from './app-labels';\n\nconst POLL_MS = 1500;\nconst nameOf = (path: string) => path.split('/').pop() || path;\n\ninterface Common {\n readonly path: string;\n readonly kind: FileKind;\n readonly locator: ResourceLocator;\n readonly options: ViewerOptions;\n readonly onClose: () => void;\n readonly back: ReactNode;\n readonly onUnsavedChange?: ((unsaved: boolean) => void) | undefined;\n readonly onBeforeLeaveChange?: ((flush: (() => Promise<boolean>) | null) => void) | undefined;\n}\n\nconst FILE_TYPE: Partial<Record<FileKind, ArtifactType>> = { markdown: 'markdown', html: 'html' };\n/** A file as the artifact its kind sees: the same descriptor a `present` of it would carry, so one kind match serves every way of opening a file. */\nconst fileArtifact = (path: string, locator: ResourceLocator): ArtifactDescriptor => ({ schema: 'boring.artifact', version: 1, title: nameOf(path), type: FILE_TYPE[viewerKindOf(path)] ?? 'code', mediaType: mediaTypeOf(path), target: locator, revision: 'latest' });\n/** The image or PDF kind of a media type (a file's, or a large file's behind a pointer). */\nconst mediaKindOf = (kinds: readonly PanelKind[], artifact: ArtifactDescriptor): MediaKind | undefined => artifactKind(artifact, kinds.filter(isMediaKind));\n\nconst subtitleOf = (locator: ResourceLocator) => <span data-testid=\"file-path\" className=\"truncate\">{locator.resource.path}</span>;\n\n/** Markdown or HTML: a revisioned controller over the file. A new revision remounts the pane; it starts in the mode the person chose. */\nfunction FileDocument({ path, editor, locator, options, onClose, back, onUnsavedChange, onBeforeLeaveChange }: Common & { readonly editor: EditorKind }) {\n const { client, identity } = options;\n const markdown = editor.editor === 'markdown';\n const create = useMemo(() => (snapshot: ResourceSnapshot): MarkdownController | HtmlController => (markdown ? createMarkdownController : createHtmlController)({\n identity, client, instanceId: randomUUID(), epoch: 'workspace', source: { kind: 'saved', snapshot } }), [markdown, client, identity]);\n const autosave = useRef<{ controller: MarkdownController | HtmlController; flush: () => Promise<boolean> } | null>(null);\n const saved = useSaved({ client, target: locator, create, beforeDispose: controller => autosave.current?.controller === controller ? autosave.current.flush() : undefined });\n const registerAutosave = useCallback((flush: (() => Promise<boolean>) | null) => {\n autosave.current = flush && saved.controller ? { controller: saved.controller, flush } : null;\n }, [saved.controller]);\n useLayoutEffect(() => {\n const controller = saved.controller;\n if (!controller) return;\n const notify = () => {\n const snapshot = controller.getSnapshot();\n onUnsavedChange?.(snapshot.dirty || snapshot.save.kind === 'pending'\n || (snapshot.save.kind === 'settled' && snapshot.save.result.kind === 'unknown'));\n };\n notify();\n const unsubscribe = controller.subscribe(notify);\n return () => { unsubscribe(); onUnsavedChange?.(false); };\n }, [saved.controller, onUnsavedChange]);\n const { labels } = useAppText();\n const [mode, setMode] = useState<string>(markdown ? 'rich' : 'preview');\n const [formattingToolbar, setFormattingToolbar] = useState<'selection' | 'pinned'>('selection');\n // The editor loads the first time a document opens: the file counts as shown once it has mounted.\n const [mounted, setMounted] = useState<string>();\n const opened = saved.kind === 'open' && saved.controller ? saved.snapshot.ref.revision : undefined;\n useReportShown(options.onShown, path, opened !== undefined && mounted === opened ? opened : undefined);\n const common = { title: nameOf(path), subtitle: subtitleOf(locator), target: { file: path }, onClose, titleTestId: 'file-title', headerStart: back, ...(options.share ? { onShare: options.share } : {}) };\n // Until the document is open the bar offers no Share, Copy or menu actions of its own: they would act on a document that is not shown.\n if (saved.kind !== 'open' || !saved.controller) {\n return <ViewerFrame title={common.title} subtitle={common.subtitle} onClose={onClose} headerStart={back}>\n <p role=\"status\" className=\"m-0 p-4 text-sm text-muted-foreground\">{saved.kind === 'loading' ? labels.loading : saved.kind === 'missing' ? labels.fileMissing : labels.fileNotText}</p>\n </ViewerFrame>;\n }\n const revision = saved.snapshot.ref.revision;\n // The preview selector the journeys use keeps working: the wrapper holds the document's text.\n let pane: ReactNode;\n if (editor.editor === 'markdown') {\n pane = <LazyPane load={editor.load} key={revision} controller={saved.controller as MarkdownController} revision={revision} initialMode={mode as 'rich'} onModeChange={setMode} formattingToolbar={formattingToolbar} onFormattingToolbarChange={setFormattingToolbar} onAutosaveChange={registerAutosave} onBeforeLeaveChange={onBeforeLeaveChange} {...common} />;\n } else {\n pane = <LazyPane load={editor.load} key={revision} controller={saved.controller as HtmlController} revision={revision} initialMode={mode as 'preview'} onModeChange={setMode} {...(options.interactive ? { interactive: options.interactive } : {})} {...common} />;\n }\n return <div data-testid=\"file-preview\" className=\"h-full min-h-0\" data-revision={revision}>\n <Suspense fallback={<ViewerFrame testId=\"viewer-pending\" title={common.title} subtitle={common.subtitle} onClose={onClose} headerStart={back}><ViewLoading label={labels.loading} /></ViewerFrame>}>\n {pane}<Mounted value={revision} onMount={setMounted} />\n </Suspense>\n </div>;\n}\n\n/** Native controls only. A failed codec leaves a download instead of a broken video box. */\nfunction VideoPreview({ item }: { readonly item: BlobItem }) {\n const [failed, setFailed] = useState(false);\n const { labels } = useAppText();\n return <div className=\"flex min-h-0 min-w-0 flex-1 items-center justify-center\">\n {failed ? <div className=\"flex flex-col items-center gap-3 p-4\">\n <p role=\"status\" data-testid=\"video-unplayable\" className=\"text-muted-foreground\">{labels.videoUnreadable}</p>\n <Button nativeButton={false} render={<a href={item.url} download={item.name} />} data-testid=\"video-download\">{labels.download}</Button>\n </div> : <video src={item.url} aria-label={item.name} controls playsInline preload=\"metadata\" onError={() => setFailed(true)} className=\"h-full max-h-full w-full min-w-0 object-contain\" />}\n </div>;\n}\n\n/**\n * A large file behind a pointer, from the host's URL: images, PDFs and videos inline, anything else as a download.\n * The viewers get a URL; none reads the media bytes into the page.\n */\nfunction BlobFile({ item, subtitle, media, ...common }: { readonly item: BlobItem; readonly subtitle: ReactNode; readonly media: MediaKind | undefined; readonly onClose: () => void; readonly onRefresh: () => unknown; readonly target: unknown; readonly titleTestId: string; readonly headerStart: ReactNode; readonly onShare?: NonNullable<ViewerOptions['share']> }) {\n const { labels } = useAppText();\n const details = <>{subtitle}<span aria-hidden=\"true\">·</span><span>{formatBytes(item.size)}</span></>;\n if (media?.media === 'image') return <LazyPane load={media.load} name={item.name} mediaType={item.type} url={item.url} subtitle={details} {...common} />;\n if (media?.media === 'pdf') return <LazyPane load={media.load} name={item.name} url={item.url} subtitle={subtitle} {...common} />;\n if (item.type.startsWith('video/')) return <ViewerFrame title={item.name} subtitle={details} {...common} onDownload={() => {\n const link = Object.assign(document.createElement('a'), { href: item.url, download: item.name });\n document.body.append(link); link.click(); link.remove();\n }}><VideoPreview key={item.url} item={item} /></ViewerFrame>;\n return <ViewerFrame title={item.name} subtitle={details} {...common}>\n <div data-testid=\"blob-file\" data-blob-type={item.type} className=\"m-auto flex flex-col items-center gap-3 p-6 text-center\">\n <p className=\"m-0 text-sm text-muted-foreground\">{item.type}</p>\n <Button nativeButton={false} render={<a href={item.url} download={item.name} />} data-testid=\"blob-download\"><DownloadIcon data-icon=\"inline-start\" aria-hidden=\"true\" />{labels.download}</Button>\n </div>\n </ViewerFrame>;\n}\n\ntype Loaded = { readonly kind: 'loading' | 'missing' } | { readonly kind: 'ready'; readonly bytes: Uint8Array; readonly revision: string; readonly text: string | undefined };\nconst LOADING: Loaded = { kind: 'loading' };\n\n/** Image, PDF and text files: read through the resource client, refreshed on demand (and followed while open, for text). */\nfunction FileBytes({ path, kind, locator, options, onClose, back, media }: Common & { readonly media: MediaKind | undefined }) {\n const { labels } = useAppText();\n // An image or PDF pane loads the first time one opens: the file counts as shown once it has mounted.\n const [mounted, setMounted] = useState<string>();\n const targetKey = JSON.stringify(locator);\n const binding = useMemo(() => ({ client: options.client, locator, path }), [options.client, targetKey, path]); // eslint-disable-line react-hooks/exhaustive-deps\n const [stored, setStored] = useState<{ readonly binding: typeof binding; readonly value: Loaded }>();\n const file: Loaded = stored?.binding === binding ? stored.value : LOADING;\n const running = useRef<AbortController | undefined>(undefined);\n const load = useCallback(async () => {\n running.current?.abort();\n const abort = new AbortController(); running.current = abort;\n try {\n const read = await binding.client.read({ target: binding.locator, revision: { kind: 'latest' } }, abort.signal);\n if (abort.signal.aborted || running.current !== abort) return;\n if (read.kind !== 'available') {\n setStored(current => current?.binding === binding && current.value.kind === 'ready' ? current : { binding, value: { kind: 'missing' } });\n return;\n }\n const { bytes, ref } = read.snapshot;\n setStored({ binding, value: { kind: 'ready', bytes, revision: ref.revision, text: decodeText(binding.path, bytes) } });\n } catch {\n if (!abort.signal.aborted && running.current === abort) setStored(current => current?.binding === binding && current.value.kind === 'ready' ? current : { binding, value: { kind: 'missing' } });\n }\n }, [binding]);\n useEffect(() => {\n // eslint-disable-next-line react-hooks/set-state-in-effect -- load is async: it sets state when the read settles, not during the effect\n void load();\n const timer = kind === 'text' ? setInterval(() => { void load(); }, POLL_MS) : undefined;\n return () => { running.current?.abort(); clearInterval(timer); };\n }, [load, kind]);\n // A pointer file is resolved here, where it is read, once per read: the viewers below receive the item, never the pointer.\n const blob = useMemo(() => file.kind === 'ready' && isBlobPointerPath(path) ? resolveBlobPointer(file.bytes, options.blobBase) : undefined, [file, path, options.blobBase]);\n // A lazily loaded pane (an image, a PDF, a large file's viewer) counts as shown once it has mounted; the text view at once.\n const lazyPane = blob?.kind === 'blob' || !blob && !!media;\n useReportShown(options.onShown, path, file.kind === 'ready' && (!lazyPane || mounted === file.revision) ? file.revision : undefined);\n const common = { subtitle: subtitleOf(locator), target: { file: path }, onClose, titleTestId: 'file-title', headerStart: back, ...(options.share ? { onShare: options.share } : {}) };\n // Until the file is read the bar offers no Share: the image and PDF panes bring their own frame, so this one is replaced (remounted)\n // when the bytes arrive, and a Share pressed just then would copy the link with its \"Link copied\" notice lost with the old frame.\n if (file.kind !== 'ready') {\n return <ViewerFrame title={nameOf(path)} subtitle={common.subtitle} onClose={onClose} titleTestId=\"file-title\" headerStart={back}>\n <p role=\"status\" className=\"m-0 p-4 text-sm text-muted-foreground\">{file.kind === 'loading' ? labels.loading : labels.fileMissing}</p>\n </ViewerFrame>;\n }\n // The bar while a view loads: `viewer-pending-*` test ids, so `viewer-more` is the view's own menu.\n const loading = <ViewerFrame testId=\"viewer-pending\" title={nameOf(path)} subtitle={common.subtitle} onClose={onClose} titleTestId=\"file-title\" headerStart={back}><ViewLoading label={labels.loading} /></ViewerFrame>;\n const shown = <Mounted value={file.revision} onMount={setMounted} />;\n if (blob?.kind === 'blob') {\n const blobMedia = mediaKindOf(options.kinds ?? noKinds, { ...fileArtifact(path, locator), mediaType: blob.item.type });\n return <Suspense fallback={loading}><BlobFile item={blob.item} media={blobMedia} onRefresh={load} {...common} />{shown}</Suspense>;\n }\n if (media?.media === 'image') return <Suspense fallback={loading}><LazyPane load={media.load} name={nameOf(path)} mediaType={mediaTypeOf(path)} bytes={file.bytes} onRefresh={load} {...common} />{shown}</Suspense>;\n if (media?.media === 'pdf') return <Suspense fallback={loading}><LazyPane load={media.load} name={nameOf(path)} bytes={file.bytes} onRefresh={load} {...common} />{shown}</Suspense>;\n const { text } = file, size = file.bytes.byteLength;\n return <ViewerFrame title={nameOf(path)} {...common} subtitle={<>{subtitleOf(locator)}<span aria-hidden=\"true\">·</span><span>{formatBytes(size)}</span></>}\n onRefresh={load} {...(text === undefined ? {} : { onCopy: () => copyText(text), onDownload: () => downloadFile(nameOf(path), text, 'text/plain;charset=utf-8') })}>\n {blob?.kind === 'unsupported-version' && <p role=\"note\" data-testid=\"blob-newer-format\" className=\"m-0 px-3 pt-3 text-sm text-muted-foreground\">{labels.newerBlobFormat}</p>}\n {text === undefined\n ? <p role=\"status\" className=\"m-0 p-4 text-sm text-muted-foreground\">{labels.binaryFile(formatBytes(size))}</p>\n : <div className=\"min-h-0 flex-1 overflow-auto p-3\"><pre data-testid=\"file-preview\" className=\"m-0 font-mono text-sm leading-relaxed break-words whitespace-pre-wrap\">{text}</pre></div>}\n </ViewerFrame>;\n}\n\n/**\n * One file in the panel. `path` is the host's name for it (shown in the share target and `data-path`); `locator` is the resource the\n * client reads. Remount per file with a `key`. `onBack` adds a back button to the bar (for example to the host's file list).\n */\nexport function FileViewer({ path, locator, options, onClose, onBack, backLabel, onUnsavedChange, onBeforeLeaveChange }: {\n readonly path: string;\n readonly locator: ResourceLocator;\n readonly options: ViewerOptions;\n readonly onClose: () => void;\n readonly onBack?: (() => void) | undefined;\n readonly backLabel?: string | undefined;\n /** Built-in editable viewers report unsaved and unconfirmed changes for navigation guards. */\n readonly onUnsavedChange?: ((unsaved: boolean) => void) | undefined;\n /** The Markdown autosave barrier, for flush-before-navigation guards. Null for explicit-save viewers. */\n readonly onBeforeLeaveChange?: ((flush: (() => Promise<boolean>) | null) => void) | undefined;\n}) {\n const kind = viewerKindOf(path);\n const { labels, icons } = useAppText();\n const back = onBack ? <Button variant=\"ghost\" size=\"sm\" data-testid=\"file-back\" aria-label={backLabel ?? labels.back} title={backLabel ?? labels.back} onClick={onBack} className=\"shrink-0\"><icons.back data-icon=\"inline-start\" aria-hidden=\"true\" /><span className=\"max-md:sr-only\">{backLabel ?? labels.back}</span></Button> : undefined;\n const common: Common = { path, kind, locator, options, onClose, back, onUnsavedChange, onBeforeLeaveChange };\n const locatorKey = JSON.stringify(locator);\n const artifact = useMemo(() => fileArtifact(path, locator), [path, locatorKey]); // eslint-disable-line react-hooks/exhaustive-deps\n // One resolution through the kinds, whoever opened the file: a host viewer for the file kind wins over a document kind (a `fallback`\n // one), not over any other kind. A drawing kind (a maquette, a diagram, an app's) shows the file in the artifact panel.\n const kinds = options.kinds ?? noKinds;\n const found = artifactKind(artifact, kinds.filter(entry => drawsText(entry) || isMediaKind(entry)));\n const custom = found && !found.fallback ? undefined : options.viewers?.[kind];\n const resolved = custom ? undefined : found;\n const drawn = resolved && !isEditorKind(resolved) && !isMediaKind(resolved) ? artifact : undefined;\n if (drawn) return <div data-testid=\"file-viewer\" data-kind=\"artifact\" data-path={path} aria-label={labels.fileViewer(drawn.title)} className=\"flex h-full min-h-0 flex-col overflow-hidden [&>*]:min-h-0 [&>*]:flex-1\">\n <ArtifactPanel active={{ descriptor: drawn, follow: true }} versions={[]} options={options} onSelect={() => {}} onClose={onClose} headerStart={back} />\n </div>;\n return <div data-testid=\"file-viewer\" data-kind={kind} data-path={path} aria-label={labels.fileViewer(KIND_LABEL[kind])} className=\"flex h-full min-h-0 flex-col overflow-hidden [&>*]:min-h-0 [&>*]:flex-1\">\n {custom ? custom({ target: locator, title: nameOf(path), frame: { titleTestId: 'file-title', onClose, target: { file: path }, headerStart: back } })\n : isEditorKind(resolved) ? <FileDocument {...common} editor={resolved} />\n // The resource client supplies bytes, not a streaming URL. Unlike images, videos must not be buffered to make an object URL.\n : kind === 'video' ? <ViewerFrame title={nameOf(path)} subtitle={subtitleOf(locator)} onClose={onClose} headerStart={back}><p role=\"status\" className=\"m-0 p-4 text-sm text-muted-foreground\">{labels.fileNotText}</p></ViewerFrame>\n : kind === 'canvas' ? <ViewerFrame title={nameOf(path)} subtitle={subtitleOf(locator)} onClose={onClose} headerStart={back}><p role=\"status\" className=\"m-0 p-4 text-sm text-muted-foreground\">{labels.noCanvasViewer}</p></ViewerFrame>\n : <FileBytes {...common} media={isMediaKind(resolved) ? resolved : undefined} />}\n </div>;\n}\n",
|
|
57
57
|
"type": "registry:component",
|
|
58
58
|
"target": "components/pi-app/file-viewer.tsx"
|
|
59
59
|
},
|
|
@@ -83,7 +83,7 @@
|
|
|
83
83
|
},
|
|
84
84
|
{
|
|
85
85
|
"path": "registry/pi-app/app-labels.ts",
|
|
86
|
-
"content": "/*\n * The words and icons of the pi-app block as plain data: its own (sessions pane, artifact panel, file viewer) and the viewers' it composes\n * (`defaultViewerLabels`). `AgentWorkspace` takes a partial `labels` and `icons`; the chat's go through `chat.labels` and `chat.icons`.\n */\nimport { createContext, createElement, useContext } from 'react';\nimport type { ReactNode } from 'react';\nimport { ArrowLeftIcon, HistoryIcon, LibraryIcon, MessageSquareIcon, PanelLeftIcon, PictureInPicture2Icon, SquarePenIcon, XIcon } from '
|
|
86
|
+
"content": "/*\n * The words and icons of the pi-app block as plain data: its own (sessions pane, artifact panel, file viewer) and the viewers' it composes\n * (`defaultViewerLabels`). `AgentWorkspace` takes a partial `labels` and `icons`; the chat's go through `chat.labels` and `chat.icons`.\n */\nimport { createContext, createElement, useContext } from 'react';\nimport type { ReactNode } from 'react';\nimport { ArrowLeftIcon, HistoryIcon, LibraryIcon, MessageSquareIcon, PanelLeftIcon, PictureInPicture2Icon, SquarePenIcon, XIcon } from '../_shared/icons';\nimport type { BlockIcon } from '../button/actions';\nimport { defaultViewerIcons, defaultViewerLabels } from '../viewer-frame/viewer-window';\n\nexport const defaultAppLabels = {\n ...defaultViewerLabels,\n sessionsTitle: 'Chats',\n workspaceNavigation: 'Workspace navigation',\n openNavigation: 'Open navigation',\n closeNavigation: 'Close navigation',\n hideNavigation: 'Hide navigation',\n showNavigation: 'Show navigation',\n chat: 'Chat',\n library: 'Library',\n agent: 'Agent',\n backToChat: 'Back to chat',\n newChat: 'New chat',\n closeSessions: 'Close chats',\n openSessions: 'Open chats',\n hideSessions: 'Hide chats',\n showSessions: 'Show chats',\n history: 'History',\n showHistory: 'Show history',\n hideHistory: 'Hide history',\n floatChat: 'Float chat',\n floatHint: 'Release to float the chat',\n resizePanel: 'Resize artifact panel',\n artifactPanel: 'Artifact panel',\n back: 'Back',\n savedAt: (when: string) => `Saved ${when}`,\n latestSavedAt: (when: string) => `Latest, saved ${when}`,\n olderVersion: 'Older version',\n earlierVersion: 'Earlier version',\n artifactNotText: 'This artifact cannot be shown as text.',\n versionUnavailable: 'This version is not available.',\n noCanvasViewer: 'This host has no canvas viewer (pass one in `viewers.canvas`).',\n fileMissing: 'This file no longer exists.',\n attachmentTooLarge: (limit: string) => `Larger than ${limit}. Not saved.`,\n fileNotText: 'This file cannot be shown as text.',\n videoUnreadable: \"This video can't be played here\",\n binaryFile: (size: string) => `This is a binary file (${size}); it cannot be shown as text.`,\n fileViewer: (kind: string) => `${kind} viewer`,\n newVersion: 'A new version is available.',\n newerBlobFormat: 'This file points to a large file in a newer format than this page reads. It is shown as it is saved.',\n invalidBlobPointer: (name: string) => `${name}: the upload did not return a valid file pointer.`,\n};\nexport type AppLabels = typeof defaultAppLabels;\n\nexport const defaultAppIcons = {\n ...defaultViewerIcons,\n sessionsToggle: PanelLeftIcon as BlockIcon,\n history: HistoryIcon as BlockIcon,\n library: LibraryIcon as BlockIcon,\n chat: MessageSquareIcon as BlockIcon,\n newChat: SquarePenIcon as BlockIcon,\n closeSessions: XIcon as BlockIcon,\n floatChat: PictureInPicture2Icon as BlockIcon,\n back: ArrowLeftIcon as BlockIcon,\n};\nexport type AppIcons = typeof defaultAppIcons;\n\nexport interface AppText { readonly labels: AppLabels; readonly icons: AppIcons }\nconst AppTextContext = createContext<AppText>({ labels: defaultAppLabels, icons: defaultAppIcons });\n/** The block's labels and icons (the defaults outside an `AgentWorkspace`). */\nexport const useAppText = (): AppText => useContext(AppTextContext);\nexport function AppTextProvider({ value, children }: { readonly value: AppText; readonly children: ReactNode }) {\n return createElement(AppTextContext.Provider, { value }, children);\n}\n",
|
|
87
87
|
"type": "registry:lib",
|
|
88
88
|
"target": "components/pi-app/app-labels.ts"
|
|
89
89
|
},
|
|
@@ -107,7 +107,7 @@
|
|
|
107
107
|
},
|
|
108
108
|
{
|
|
109
109
|
"path": "registry/pi-app/viewer-kinds.tsx",
|
|
110
|
-
"content": "'use client';\n\n// What a kind is to the artifact panel and the file viewer (D-257): an app's `ArtifactKind`, or a document kind (Markdown and HTML edited\n// over a revisioned controller; image and PDF read as bytes). These are contracts only: the kinds themselves come from the host, through\n// `AgentWorkspace`'s `artifactKinds` or `ViewerOptions.kinds`. The pi-app-kinds item exports the default list (`defaultKinds`), whose\n// kinds (the viewer items' `markdownKind`, `imageKind`...) satisfy these types structurally; none of the viewers is in this item.\nimport { lazy, useEffect } from 'react';\nimport type { ComponentType, ReactNode } from 'react';\nimport { LoaderCircleIcon } from '
|
|
110
|
+
"content": "'use client';\n\n// What a kind is to the artifact panel and the file viewer (D-257): an app's `ArtifactKind`, or a document kind (Markdown and HTML edited\n// over a revisioned controller; image and PDF read as bytes). These are contracts only: the kinds themselves come from the host, through\n// `AgentWorkspace`'s `artifactKinds` or `ViewerOptions.kinds`. The pi-app-kinds item exports the default list (`defaultKinds`), whose\n// kinds (the viewer items' `markdownKind`, `imageKind`...) satisfy these types structurally; none of the viewers is in this item.\nimport { lazy, useEffect } from 'react';\nimport type { ComponentType, ReactNode } from 'react';\nimport { LoaderCircleIcon } from '../_shared/icons';\nimport type { MarkdownController } from '@hachej/boring-ui-kit/markdown';\nimport type { HtmlController } from '@hachej/boring-ui-kit/html';\nimport type { MarkdownEditorProps } from '@hachej/boring-ui-kit/markdown-editor';\nimport type { ArtifactKind, ArtifactKindViewProps } from '../pi-artifacts/artifact';\nimport type { ViewerShare } from '../viewer-frame/viewer-frame';\nimport type { InteractiveHtml } from '../viewer-frame/interactive-html';\nimport type { MediaSource } from '../viewer-frame/media';\n\n/** The viewer bar's props the panel and the file viewer give every pane a document or media kind loads. */\nexport interface PaneBarProps {\n readonly title?: string;\n readonly subtitle?: ReactNode;\n readonly target?: unknown;\n readonly revision?: string;\n readonly headerStart?: ReactNode;\n readonly controls?: ReactNode;\n readonly onRefresh?: () => unknown;\n readonly onShare?: ViewerShare;\n readonly onClose?: () => unknown;\n readonly testId?: string;\n readonly titleTestId?: string;\n}\n\n/** What a Markdown kind's pane receives (the viewer-markdown item's `MarkdownPane` takes these and more). */\nexport interface MarkdownPaneInput extends PaneBarProps {\n readonly controller: MarkdownController;\n readonly initialMode?: 'rich' | 'source';\n readonly onModeChange?: (mode: 'rich' | 'source') => void;\n readonly formattingToolbar?: MarkdownEditorProps['formattingToolbar'];\n readonly onFormattingToolbarChange?: (toolbar: 'selection' | 'pinned') => void;\n readonly onAutosaveChange?: MarkdownEditorProps['onAutosaveChange'];\n readonly onBeforeLeaveChange?: MarkdownEditorProps['onAutosaveChange'];\n}\n\n/** What an HTML kind's pane receives (the viewer-html item's `HtmlPane`). */\nexport interface HtmlPaneInput extends PaneBarProps {\n readonly controller: HtmlController;\n readonly initialMode?: 'preview' | 'source';\n readonly onModeChange?: (mode: 'preview' | 'source') => void;\n readonly interactive?: InteractiveHtml;\n}\n\n/** What an image kind's pane receives (the viewer-image item's `ImagePane`): the bytes, a blob or the host's URL of a large file. */\nexport interface ImagePaneInput extends PaneBarProps, MediaSource { readonly name: string; readonly mediaType: string }\n\n/** What a PDF kind's pane receives (the viewer-pdf item's `PdfPane`). */\nexport interface PdfPaneInput extends PaneBarProps, MediaSource { readonly name: string }\n\n/** What a code kind's view receives (the viewer-code item's SVG and CSV views): of the panel's kind props, these. */\nexport interface CodeViewInput {\n readonly text: string;\n readonly artifact: { readonly title: string };\n readonly setControls?: ((controls: ReactNode) => void) | undefined;\n}\n\ntype KindHead = Pick<ArtifactKind, 'match' | 'label' | 'icon' | 'fallback'>;\ntype Loads<Props> = { readonly load: () => Promise<{ readonly default: ComponentType<Props> }> };\n\n/** A kind drawn by a view of the panel's text that reads fewer props than an `ArtifactKind` view (SVG, CSV). */\nexport type CodeKind = KindHead & Loads<CodeViewInput>;\n/** A document kind edited over a revisioned controller (Markdown, HTML): the panel and the file viewer create the controller. */\nexport type EditorKind = KindHead & ((Loads<MarkdownPaneInput> & { readonly editor: 'markdown' }) | (Loads<HtmlPaneInput> & { readonly editor: 'html' }));\n/** A kind that reads a file's bytes rather than its text (image, PDF): the file viewer draws it. */\nexport type MediaKind = KindHead & ((Loads<ImagePaneInput> & { readonly media: 'image' }) | (Loads<PdfPaneInput> & { readonly media: 'pdf' }));\n/** Any kind the panel and the file viewer take: an app's `ArtifactKind`, a code kind, an editor kind or a media kind. */\nexport type PanelKind = ArtifactKind | CodeKind | EditorKind | MediaKind;\n\n/** What the panel and the file viewer resolve through when the host passes no kinds: none, so every file shows as text. */\nexport const noKinds: readonly PanelKind[] = [];\n\nexport const isEditorKind = (kind: PanelKind | undefined): kind is EditorKind => !!kind && 'editor' in kind;\nexport const isMediaKind = (kind: PanelKind | undefined): kind is MediaKind => !!kind && 'media' in kind;\n\n/** A kind that draws an artifact's text in the panel: an editor kind, or one with a renderer or a lazily loaded view (not image or PDF). */\nexport const drawsText = (kind: PanelKind): boolean => !isMediaKind(kind) && (isEditorKind(kind) || ('renderer' in kind && !!kind.renderer) || !!kind.load);\n\n// One lazy component per loader, for the life of the page: a kind recreated on each render (a fresh comments object) keeps its loader\n// identity when it is a module constant, and a loader seen again never re-suspends.\nconst views = new WeakMap<object, unknown>();\n\n/** The `React.lazy` component of a kind's `load`, created once per loader. */\nexport function lazyView<Props>(load: () => Promise<{ readonly default: ComponentType<Props> }>): ComponentType<Props> {\n let view = views.get(load) as ComponentType<Props> | undefined;\n if (!view) { view = lazy(load) as unknown as ComponentType<Props>; views.set(load, view); }\n return view;\n}\n\n/** A kind's lazy view as an element (`lazyView` gives one component per loader, so its identity holds across renders). */\nconst lazyElement = <Props extends object>(load: () => Promise<{ readonly default: ComponentType<Props> }>, props: Props): ReactNode => { const View = lazyView(load); return <View {...props} />; };\n/** Draws a kind's `load` with the given props; a host can key it like any element. */\nexport function LazyPane<Props extends object>({ load, ...props }: { readonly load: () => Promise<{ readonly default: ComponentType<Props> }> } & Props) { return lazyElement(load, props as unknown as Props); }\n\nconst renderers = new WeakMap<object, (props: ArtifactKindViewProps) => ReactNode>();\n\n/** A kind's `load` as a renderer (one per loader): the panel draws it like a kind's own `renderer`, inside its `Suspense`. */\n// A view takes the panel's props or fewer of them (the code views read only `text`, `artifact` and `setControls`).\nexport function lazyRenderer(load: () => Promise<{ readonly default: unknown }>): (props: ArtifactKindViewProps) => ReactNode {\n let render = renderers.get(load);\n if (!render) { const View = lazyView(load as () => Promise<{ readonly default: ComponentType<ArtifactKindViewProps> }>); render = props => <View {...props} />; renderers.set(load, render); }\n return render;\n}\n\n/** The spinner a viewer bar shows while its view loads. */\nexport function ViewLoading({ label }: { readonly label: string }) {\n return <p data-testid=\"viewer-loading\" role=\"status\" aria-label={label} className=\"m-0 flex flex-1 items-center justify-center p-6 text-muted-foreground\">\n <LoaderCircleIcon className=\"size-5 animate-spin\" aria-hidden=\"true\" />\n </p>;\n}\n\n/** Calls `onMount(value)` once it commits: inside a `Suspense`, that is only after the lazy view beside it has loaded and mounted. */\nexport function Mounted({ value, onMount }: { readonly value: string; readonly onMount: (value: string) => void }) {\n useEffect(() => { onMount(value); }, [value, onMount]);\n return null;\n}\n",
|
|
111
111
|
"type": "registry:component",
|
|
112
112
|
"target": "components/pi-app/viewer-kinds.tsx"
|
|
113
113
|
}
|
package/r/pi-artifacts.json
CHANGED
|
@@ -4,14 +4,14 @@
|
|
|
4
4
|
"title": "Pi artifacts",
|
|
5
5
|
"description": "Artifact descriptors, kinds and the artifact card that opens generated content in the host's panel. Plug it in with `artifacts={{ ...artifactCards, open, isOpen, kinds }}`; without it pi-chat offers no artifact. pi-app and the viewers import their artifact kinds and types from it.",
|
|
6
6
|
"dependencies": [
|
|
7
|
-
"@hachej/boring-ui-kit@^0.4.
|
|
7
|
+
"@hachej/boring-ui-kit@^0.4.59",
|
|
8
8
|
"react@^19.0.0",
|
|
9
|
-
"lucide-react@^1.51.0",
|
|
10
9
|
"@earendil-works/pi-ai@~1.4.0",
|
|
11
10
|
"@earendil-works/pi-durable@~1.4.0"
|
|
12
11
|
],
|
|
13
12
|
"registryDependencies": [
|
|
14
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
13
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/pi-chat.json",
|
|
14
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/_shared.json"
|
|
15
15
|
],
|
|
16
16
|
"files": [
|
|
17
17
|
{
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
},
|
|
23
23
|
{
|
|
24
24
|
"path": "registry/pi-artifacts/artifact-card.tsx",
|
|
25
|
-
"content": "'use client';\n\nimport type { ComponentType } from 'react';\nimport { CheckIcon, ChevronRightIcon, FileCodeIcon, FileTextIcon, GlobeIcon, ImageIcon, PenToolIcon, ListTodoIcon } from '
|
|
25
|
+
"content": "'use client';\n\nimport type { ComponentType } from 'react';\nimport { CheckIcon, ChevronRightIcon, FileCodeIcon, FileTextIcon, GlobeIcon, ImageIcon, PenToolIcon, ListTodoIcon } from '../_shared/icons';\nimport { Button } from '@/components/ui/button';\nimport { Item, ItemContent, ItemDescription, ItemMedia, ItemTitle } from '@/components/ui/item';\nimport { Shimmer } from '../pi-chat/shimmer';\nimport { artifactKey, artifactKind, recogniseArtifact, typeLabel } from './artifact';\nimport type { PresentedArtifact, ArtifactType, KindMatch } from './artifact';\nimport { useChatText } from '../pi-chat/labels';\nimport type { ChatArtifactCardProps } from '../pi-chat/config';\n\nconst ICONS: Record<ArtifactType, ComponentType<{ readonly 'aria-hidden'?: boolean | 'true' }>> = {\n markdown: FileTextIcon, html: GlobeIcon, svg: ImageIcon, code: FileCodeIcon, canvas: PenToolIcon,\n};\n\n/**\n * An artifact in the transcript, as in Claude's chat apps: a compact card with a type icon, the title, the type and the\n * version. Clicking it asks the host to open the artifact. While the `present` call that makes it is still running it is a quiet\n * \"opening\" card with a shimmer. Rendered outside the activity block, like the question card.\n */\nexport function ArtifactCard({ artifact, pending, title, open = false, onOpen, kinds }: {\n readonly artifact?: PresentedArtifact | undefined;\n readonly pending?: 'presenting';\n readonly title?: string | undefined;\n readonly open?: boolean;\n readonly onOpen?: ((artifact: PresentedArtifact) => void) | undefined;\n /** The app's artifact kinds (`ArtifactsConfig.kinds`): their label and icon win over the type's. */\n readonly kinds?: readonly KindMatch[] | undefined;\n}) {\n const { labels } = useChatText();\n if (!artifact) {\n return <Item variant=\"outline\" size=\"sm\" data-testid=\"artifact-card\" data-state={pending ?? 'presenting'} role=\"status\" aria-busy=\"true\" className=\"my-2 text-start\">\n <ItemMedia variant=\"image\"><Item variant=\"muted\" size=\"xs\" className=\"min-h-full justify-center\"><ItemMedia variant=\"icon\"><FileTextIcon aria-hidden=\"true\" /></ItemMedia></Item></ItemMedia>\n <ItemContent className=\"min-w-0 basis-0\">\n <ItemTitle data-testid=\"artifact-title\"><Shimmer>{title ?? labels.artifact}</Shimmer></ItemTitle>\n <ItemDescription><Shimmer>{labels.opening}</Shimmer></ItemDescription>\n </ItemContent>\n </Item>;\n }\n const kind = artifactKind(artifact, kinds);\n const Icon = kind?.icon ?? (artifact.type === 'task' ? ListTodoIcon : ICONS[artifact.type]);\n const ordinal = artifact.type === 'task' ? undefined : artifact.ordinal;\n return <Button variant={open ? 'secondary' : 'outline'} disabled={!onOpen} onClick={() => onOpen?.(artifact)}\n data-testid=\"artifact-card\" data-state=\"ready\" data-artifact-id={artifactKey(artifact)} data-artifact-revision={artifact.type === 'task' ? undefined : artifact.revision} data-artifact-version={ordinal} data-artifact-type={artifact.type} data-artifact-kind={kind?.label}\n data-open={open ? 'true' : undefined} aria-pressed={onOpen ? open : undefined} aria-label={labels.openArtifact(artifact.title, ordinal)}\n className=\"my-2 h-auto min-h-12! w-full max-w-md justify-start gap-2.5 text-left\">\n <ItemMedia variant=\"image\"><Item variant=\"muted\" size=\"xs\" className=\"min-h-full justify-center\"><Icon aria-hidden=\"true\" /></Item></ItemMedia>\n <span className=\"flex min-w-0 flex-1 flex-col\">\n <span data-testid=\"artifact-title\" className=\"truncate\">{artifact.title}</span>\n <span className=\"truncate text-xs text-muted-foreground\"><span data-testid=\"artifact-type\">{typeLabel(artifact, kinds, labels.artifactTypes)}</span>{ordinal !== undefined && <> · <span data-testid=\"artifact-version\">{labels.version(ordinal)}</span></>}</span>\n </span>\n {open ? <CheckIcon data-icon=\"inline-end\" aria-hidden=\"true\" /> : <ChevronRightIcon data-icon=\"inline-end\" aria-hidden=\"true\" />}\n </Button>;\n}\n\n/** `ArtifactCard` as the chat's `artifacts.Card`: the chat hands back the descriptor `recogniseArtifact` found. */\nexport function PluggedArtifactCard({ artifact, open, onOpen, kinds }: ChatArtifactCardProps) {\n const descriptor = artifact.state === 'ready' ? artifact.descriptor as PresentedArtifact : undefined;\n return <ArtifactCard artifact={descriptor} title={artifact.title} {...(artifact.state === 'ready' ? {} : { pending: artifact.state })}\n open={open} onOpen={onOpen ? () => onOpen() : undefined} kinds={kinds as readonly KindMatch[] | undefined} />;\n}\n\n/** What makes `PiChat` offer artifacts: `<PiChat artifacts={{ open, isOpen, kinds, ...artifactCards }} />` (`withChatPlugins` adds it). */\nexport const artifactCards = { recognise: recogniseArtifact, Card: PluggedArtifactCard } as const;\n",
|
|
26
26
|
"type": "registry:component",
|
|
27
27
|
"target": "components/pi-artifacts/artifact-card.tsx"
|
|
28
28
|
}
|
package/r/pi-chat-approval.json
CHANGED
|
@@ -4,18 +4,18 @@
|
|
|
4
4
|
"title": "Pi chat approval card",
|
|
5
5
|
"description": "The Approve / Deny card for host tools wrapped with `requireApproval`. Plug it in with `cards={{ approval: ApprovalCard }}`; without it pi-chat shows a \"waiting for your answer\" notice with no answer UI.",
|
|
6
6
|
"dependencies": [
|
|
7
|
-
"@hachej/boring-ui-kit@^0.4.
|
|
7
|
+
"@hachej/boring-ui-kit@^0.4.59",
|
|
8
8
|
"react@^19.0.0",
|
|
9
|
-
"lucide-react@^1.51.0",
|
|
10
9
|
"@earendil-works/pi-ai@~1.4.0"
|
|
11
10
|
],
|
|
12
11
|
"registryDependencies": [
|
|
13
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
12
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/pi-chat.json",
|
|
13
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/_shared.json"
|
|
14
14
|
],
|
|
15
15
|
"files": [
|
|
16
16
|
{
|
|
17
17
|
"path": "registry/pi-chat-approval/approval-card.tsx",
|
|
18
|
-
"content": "'use client';\n\nimport { useEffect, useRef, useState } from 'react';\nimport type { ToolCall, ToolResultMessage } from '@earendil-works/pi-ai';\nimport { CheckIcon, ShieldAlertIcon, XIcon } from '
|
|
18
|
+
"content": "'use client';\n\nimport { useEffect, useRef, useState } from 'react';\nimport type { ToolCall, ToolResultMessage } from '@earendil-works/pi-ai';\nimport { CheckIcon, ShieldAlertIcon, XIcon } from '../_shared/icons';\nimport { waits, type ApprovalDecision } from '@hachej/boring-ui-kit/chat-view';\nimport { Badge } from '@/components/ui/badge';\nimport { Button } from '@/components/ui/button';\nimport { Item, ItemActions, ItemContent, ItemDescription, ItemFooter, ItemMedia, ItemTitle } from '@/components/ui/item';\nimport type { ApprovalCardProps } from '../pi-chat/config';\nimport { useChatText } from '../pi-chat/labels';\n\n// The strings `requireApproval` (`@hachej/boring-agent/approval`) asks with and puts at the start of a denial result.\nexport const APPROVE = 'Approve';\nexport const DENY = 'Deny';\nexport const DENIED_PREFIX = 'Denied by the person.';\n\n\n/** The arguments the call would run with, one line each. They come from the model's call itself: the card shows what will actually execute. */\nexport function describeArguments(call: ToolCall): { readonly name: string; readonly value: string }[] {\n return Object.entries(call.arguments as Record<string, unknown>).map(([name, value]) => {\n const text = typeof value === 'string' ? value : JSON.stringify(value) ?? '';\n return { name, value: text.length > 600 ? `${text.slice(0, 600)}…` : text };\n });\n}\n\n/**\n * The decision, once there is one. The wrapper records it in the call's details as soon as it is made, before the call finishes; a call\n * that settled without one was never decided (the run was stopped while it waited), so it is not shown as approved.\n */\nfunction decisionOf(result: ToolResultMessage | undefined, decision: ApprovalDecision | undefined): ApprovalDecision | undefined {\n if (decision) return decision;\n if (!result) return undefined;\n const text = result.content.map(part => part.type === 'text' ? part.text : '').join('');\n return result.isError && text.startsWith(DENIED_PREFIX) ? 'denied' : 'cancelled';\n}\n\nexport type { ApprovalCardProps };\n\n/** Human approval for a gated tool call: pending (Approve and Deny), then approved or denied. Answering uses the host's `answer`. */\nexport function ApprovalCard({ call, result, answer, questionId, summary, decision, live = false }: ApprovalCardProps) {\n const { labels } = useChatText();\n const [sent, setSent] = useState<string | undefined>();\n const [sending, setSending] = useState<string | undefined>();\n const [refusal, setRefusal] = useState<string | undefined>();\n const mounted = useRef(true);\n useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []);\n // `pending` is the kit's waiting rule (`waits`, as the chat's status line uses); `writing` means the model is still generating the call.\n const waiting = waits({ kind: 'approval', key: call.id, call, result, live, questionId, summary: summary ?? '', ...(decision ? { decision } : {}) });\n const state = decisionOf(result, decision) ?? (sent === APPROVE ? 'approved-sent' : sent === DENY ? 'denied-sent' : waiting ? 'pending' : 'writing');\n const open = state === 'pending' || state === 'writing';\n const args = describeArguments(call);\n const busy = sending !== undefined || live;\n\n async function decide(value: string) {\n if (!answer || !questionId || busy) return;\n setRefusal(undefined); setSending(value);\n try {\n const outcome = await answer(questionId, value);\n if (outcome.kind === 'answered') setSent(value);\n else setRefusal(outcome.reason || labels.decisionRefused(outcome.kind));\n } catch (cause) { setRefusal(cause instanceof Error ? cause.message : labels.decisionNotSent); }\n finally { if (mounted.current) setSending(undefined); }\n }\n const approved = state === 'approved' || state === 'approved-sent', denied = state === 'denied' || state === 'denied-sent';\n\n const status = open ? labels.needsApproval : approved ? labels.approved : denied ? labels.denied : labels.notDecided;\n const unavailable = !answer || !questionId || busy;\n\n // shadcn's Confirmation recipe: an outline Item with the question and Approve/Deny actions; pending approvals are prompts, not activity.\n return <Item variant=\"outline\" size=\"sm\" render={<section />} data-testid=\"approval-card\" data-state={state} data-call-id={call.id} data-tool={call.name} aria-label={labels.approvalNeeded} className=\"my-2 text-start\">\n <ItemMedia variant=\"icon\">\n {approved ? <CheckIcon aria-hidden=\"true\" /> : denied ? <XIcon aria-hidden=\"true\" /> : <ShieldAlertIcon aria-hidden=\"true\" />}\n </ItemMedia>\n <ItemContent className=\"min-w-0 basis-0\">\n <ItemDescription className=\"line-clamp-none\"><Badge variant={open ? 'outline' : denied ? 'destructive' : 'secondary'}>{status}</Badge></ItemDescription>\n {summary && <ItemTitle data-testid=\"approval-summary\" className=\"line-clamp-none [overflow-wrap:anywhere]\">{summary}</ItemTitle>}\n <ItemDescription data-testid=\"approval-tool\" className=\"line-clamp-none [overflow-wrap:anywhere]\">{labels.wantsToRun} <code className=\"font-mono\">{call.name}</code></ItemDescription>\n {args.length > 0 && (() => {\n const list = <dl data-testid=\"approval-args\" className=\"m-0 grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-xs\">\n {args.map(item => [<dt key={`${item.name}:n`} className=\"text-muted-foreground\">{item.name}</dt>, <dd key={`${item.name}:v`} className=\"m-0 font-mono whitespace-pre-wrap [overflow-wrap:anywhere]\">{item.value}</dd>])}\n </dl>;\n // With a summary the raw arguments are the detail behind it; without one they are all the person has to decide on.\n return summary ? <details data-testid=\"approval-details\" className=\"text-xs\"><summary className=\"inline-flex min-h-8 cursor-pointer items-center text-muted-foreground select-none max-sm:min-h-11 pointer-coarse:min-h-11\">{labels.arguments}</summary>{list}</details> : list;\n })()}\n </ItemContent>\n {open && <ItemActions role=\"group\" aria-label={labels.decision}>\n <Button variant=\"default\" data-testid=\"approval-approve\" disabled={unavailable} onClick={() => { void decide(APPROVE); }}><CheckIcon data-icon=\"inline-start\" aria-hidden=\"true\" />{labels.approve}</Button>\n <Button variant=\"outline\" data-testid=\"approval-deny\" disabled={unavailable} onClick={() => { void decide(DENY); }}><XIcon data-icon=\"inline-start\" aria-hidden=\"true\" />{labels.deny}</Button>\n </ItemActions>}\n {(open && (live || !answer || !questionId)) && <ItemFooter><ItemDescription>{live ? labels.stillWritingCall : labels.decidingUnavailable}</ItemDescription></ItemFooter>}\n {/* Decided, the gated call not finished yet: whoever decided, the run carries on by itself. */}\n {(approved || denied) && !result && <ItemFooter><ItemDescription>{labels.decisionSent(approved)}</ItemDescription></ItemFooter>}\n {refusal && open && <ItemFooter><p role=\"alert\" data-testid=\"approval-refusal\" className=\"m-0 text-sm text-destructive\">{refusal}</p></ItemFooter>}\n </Item>;\n}\n",
|
|
19
19
|
"type": "registry:component",
|
|
20
20
|
"target": "components/pi-chat-approval/approval-card.tsx"
|
|
21
21
|
}
|
|
@@ -5,16 +5,16 @@
|
|
|
5
5
|
"description": "The full-screen phone camera the composer's + menu opens (Take a photo). Plug it in with `attachments={{ ...config, Camera: CameraSheet }}`; without it the menu offers file upload only.",
|
|
6
6
|
"dependencies": [
|
|
7
7
|
"react@^19.0.0",
|
|
8
|
-
"react-dom@^19.0.0"
|
|
9
|
-
"lucide-react@^1.51.0"
|
|
8
|
+
"react-dom@^19.0.0"
|
|
10
9
|
],
|
|
11
10
|
"registryDependencies": [
|
|
12
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
11
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/pi-chat.json",
|
|
12
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/_shared.json"
|
|
13
13
|
],
|
|
14
14
|
"files": [
|
|
15
15
|
{
|
|
16
16
|
"path": "registry/pi-chat-attachments/camera-sheet.tsx",
|
|
17
|
-
"content": "'use client';\n\nimport { useEffect, useLayoutEffect, useRef, useState } from 'react';\nimport { createPortal } from 'react-dom';\nimport { CameraIcon, XIcon } from '
|
|
17
|
+
"content": "'use client';\n\nimport { useEffect, useLayoutEffect, useRef, useState } from 'react';\nimport { createPortal } from 'react-dom';\nimport { CameraIcon, XIcon } from '../_shared/icons';\nimport { Button } from '@/components/ui/button';\nimport { Input } from '@/components/ui/input';\nimport { useChatText } from '../pi-chat/labels';\nimport type { CameraSheetProps } from '../pi-chat/config';\n\nexport type { CameraSheetProps } from '../pi-chat/config';\n\n// The in-page camera behind the composer's \"Take a photo\" (phones). getUserMedia is secure-context only and needs permission, so it is\n// opened only when this sheet mounts (the person's tap on the menu item), through an injectable `getUserMedia` whose default\n// feature-detects `navigator.mediaDevices.getUserMedia`. Where the stream cannot open (refused, unsupported, a Permissions-Policy, an\n// insecure page) the sheet offers the phone's own camera through a file input instead. Same shape as feedback/voice/capture.ts.\n\n/** The longest side of a captured photo, in pixels. */\nexport const CAMERA_MAX_SIDE = 1600;\nconst JPEG_QUALITY = 0.85;\n\ntype GetUserMedia = NonNullable<CameraSheetProps['getUserMedia']>;\n\n/** The browser's camera, or null where the page cannot have one (insecure origin, no media devices). */\nfunction browserGetUserMedia(): GetUserMedia | null {\n if (typeof globalThis.navigator?.mediaDevices?.getUserMedia !== 'function') return null;\n const devices = globalThis.navigator.mediaDevices;\n return constraints => devices.getUserMedia(constraints);\n}\n\n/** Full-screen camera above the agent layer (the app shell's point layer + 10): a live preview, a shutter and a close button. */\nexport function CameraSheet({ onCapture, onClose, getUserMedia }: CameraSheetProps) {\n const { labels } = useChatText();\n const video = useRef<HTMLVideoElement>(null);\n const stream = useRef<MediaStream | null>(null);\n const fallback = useRef<HTMLInputElement>(null);\n const [ready, setReady] = useState(false);\n const [failed, setFailed] = useState(false);\n const [encoding, setEncoding] = useState(false);\n // The latest callbacks, so the stream effect runs once per mount.\n const latest = useRef({ onClose, getUserMedia });\n useLayoutEffect(() => { latest.current = { onClose, getUserMedia }; });\n\n const release = () => { stream.current?.getTracks().forEach(track => track.stop()); stream.current = null; };\n useEffect(() => {\n let live = true;\n const open = latest.current.getUserMedia ?? browserGetUserMedia();\n (async () => {\n if (!open) throw new Error('no camera');\n // The rear camera if there is one; any camera otherwise.\n const media = await open({ video: { facingMode: 'environment' } }).catch(() => open({ video: true }));\n if (!live) { media.getTracks().forEach(track => track.stop()); return; }\n stream.current = media;\n const element = video.current;\n if (!element) return;\n element.srcObject = media;\n await element.play();\n if (live) setReady(true);\n })().catch(() => { if (live) { release(); setFailed(true); } });\n return () => { live = false; release(); };\n }, []);\n // Escape closes the camera only: captured before the drawer or the dropdown behind it see it.\n useEffect(() => {\n const onKey = (event: KeyboardEvent) => { if (event.key === 'Escape') { event.stopPropagation(); event.preventDefault(); release(); latest.current.onClose(); } };\n document.addEventListener('keydown', onKey, true);\n return () => document.removeEventListener('keydown', onKey, true);\n }, []);\n\n const close = () => { release(); onClose(); };\n const shoot = () => {\n const element = video.current;\n if (!element || !ready || encoding || !element.videoWidth || !element.videoHeight) return;\n const scale = Math.min(1, CAMERA_MAX_SIDE / Math.max(element.videoWidth, element.videoHeight));\n const canvas = document.createElement('canvas');\n canvas.width = Math.max(1, Math.round(element.videoWidth * scale));\n canvas.height = Math.max(1, Math.round(element.videoHeight * scale));\n const context = canvas.getContext('2d');\n if (!context) { setFailed(true); return; }\n context.drawImage(element, 0, 0, canvas.width, canvas.height);\n setEncoding(true);\n canvas.toBlob(blob => {\n setEncoding(false);\n if (!blob) { setFailed(true); return; }\n release();\n onCapture(new File([blob], `photo-${Date.now()}.jpg`, { type: 'image/jpeg' }));\n }, 'image/jpeg', JPEG_QUALITY);\n };\n\n return createPortal(\n <div role=\"dialog\" aria-modal=\"true\" aria-label={labels.takePhoto} data-boring-overlay=\"\" data-testid=\"camera-sheet\"\n style={{ zIndex: 'calc(var(--app-shell-z-point, 60) + 10)' }}\n className=\"fixed inset-0 flex flex-col bg-background text-foreground\">\n <div className=\"flex min-h-0 flex-1 items-center justify-center overflow-hidden\">\n {failed\n ? <div className=\"flex flex-col items-center gap-3 p-6 text-center\">\n <p role=\"alert\" data-testid=\"camera-unavailable\" className=\"text-sm text-muted-foreground\">{labels.cameraUnavailable}</p>\n {/* Synchronous click: the browser opens the phone camera only from the person's tap. */}\n <Button type=\"button\" data-testid=\"camera-fallback\" className=\"min-h-11\" onClick={() => fallback.current?.click()}>\n <CameraIcon data-icon=\"inline-start\" aria-hidden=\"true\" />{labels.cameraFallback}</Button>\n <Input ref={fallback} type=\"file\" accept=\"image/*\" capture=\"environment\" tabIndex={-1} aria-hidden=\"true\" className=\"sr-only\" data-testid=\"camera-fallback-input\"\n onChange={event => { const file = event.currentTarget.files?.[0]; event.currentTarget.value = ''; if (file) onCapture(file); }} />\n </div>\n : <video ref={video} playsInline muted autoPlay data-testid=\"camera-video\" className=\"size-full object-cover\" />}\n </div>\n <div className=\"flex items-center justify-between p-4 pb-[max(1rem,env(safe-area-inset-bottom))]\">\n <Button type=\"button\" variant=\"ghost\" size=\"icon-lg\" data-testid=\"camera-close\" aria-label={labels.cameraClose} title={labels.cameraClose} onClick={close}>\n <XIcon aria-hidden=\"true\" /></Button>\n {!failed && <Button type=\"button\" size=\"icon-lg\" data-testid=\"camera-shoot\" aria-label={labels.cameraShoot} title={labels.cameraShoot}\n disabled={!ready || encoding} onClick={shoot}><CameraIcon aria-hidden=\"true\" /></Button>}\n <span className=\"w-9\" aria-hidden=\"true\" />\n </div>\n </div>, document.body);\n}\n",
|
|
18
18
|
"type": "registry:component",
|
|
19
19
|
"target": "components/pi-chat-attachments/camera-sheet.tsx"
|
|
20
20
|
}
|
package/r/pi-chat-markdown.json
CHANGED
|
@@ -4,13 +4,13 @@
|
|
|
4
4
|
"title": "Pi chat Markdown",
|
|
5
5
|
"description": "Markdown for the chat's assistant replies: safe token-by-token rendering (raw HTML shown as text, no images fetched, http(s) links only), code blocks, tables, task lists and /command buttons. Plug it in with `renderText={markdownText}`; without it pi-chat shows replies as plain text.",
|
|
6
6
|
"dependencies": [
|
|
7
|
-
"@hachej/boring-ui-kit@^0.4.
|
|
7
|
+
"@hachej/boring-ui-kit@^0.4.59",
|
|
8
8
|
"react@^19.0.0",
|
|
9
9
|
"marked@^17.0.6",
|
|
10
|
-
"entities@^
|
|
10
|
+
"entities@^9.0.0"
|
|
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.59/r/pi-chat.json"
|
|
14
14
|
],
|
|
15
15
|
"files": [
|
|
16
16
|
{
|
package/r/pi-chat-mentions.json
CHANGED
|
@@ -4,16 +4,16 @@
|
|
|
4
4
|
"title": "Pi chat mentions",
|
|
5
5
|
"description": "The composer's `@` picker for files and other mentionable things. Plug it in with `mentions={{ ...config, Picker: MentionPicker }}`; without it typing `@` opens nothing (sent mentions still show as chips).",
|
|
6
6
|
"dependencies": [
|
|
7
|
-
"react@^19.0.0"
|
|
8
|
-
"lucide-react@^1.51.0"
|
|
7
|
+
"react@^19.0.0"
|
|
9
8
|
],
|
|
10
9
|
"registryDependencies": [
|
|
11
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
10
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/pi-chat.json",
|
|
11
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/_shared.json"
|
|
12
12
|
],
|
|
13
13
|
"files": [
|
|
14
14
|
{
|
|
15
15
|
"path": "registry/pi-chat-mentions/mention-menu.tsx",
|
|
16
|
-
"content": "'use client';\n\nimport { useEffect, useRef, useState } from 'react';\nimport { FileTextIcon, FolderIcon } from '
|
|
16
|
+
"content": "'use client';\n\nimport { useEffect, useRef, useState } from 'react';\nimport { FileTextIcon, FolderIcon } from '../_shared/icons';\nimport { Item, ItemContent, ItemDescription, ItemMedia, ItemTitle } from '@/components/ui/item';\nimport { usePickerKeyboard } from '../pi-chat/picker-keyboard';\nimport { ComposerSuggestions } from '../pi-chat/suggestions';\nimport type { MentionPickerProps, MentionResult, MentionsConfig } from '../pi-chat/config';\nimport { useChatText } from '../pi-chat/labels';\n\nfunction highlight(text: string, query: string) {\n const at = query ? text.toLowerCase().indexOf(query.toLowerCase()) : -1;\n if (at < 0) return <>{text}</>;\n return <>{text.slice(0, at)}<mark className=\"bg-transparent text-foreground underline underline-offset-2\">{text.slice(at, at + query.length)}</mark>{text.slice(at + query.length)}</>;\n}\n\n/** The `@` file picker, ported from v2's `MentionPicker`: debounced search, one highlighted row, name over path. */\nexport function MentionPicker({ query, search, generation, listLabel, renderOption, header, onSelect, onDismiss }: MentionPickerProps) {\n const { labels } = useChatText();\n // Results are tagged with the search function that produced them: a binding switch hides the previous binding's rows at once.\n const [outcome, setOutcome] = useState<{ readonly search: MentionsConfig['search']; readonly results: readonly MentionResult[]; readonly failure: string | null }>();\n const shown = outcome?.search === search ? outcome : undefined;\n const results = shown?.results ?? [];\n const failure = shown?.failure ?? null;\n const [active, setActive] = useState(0);\n const list = useRef<HTMLUListElement>(null);\n useEffect(() => {\n // eslint-disable-next-line react-hooks/set-state-in-effect -- a new query, search or generation starts at the first row, together with the search it starts\n setActive(0);\n const abort = new AbortController();\n const timer = setTimeout(() => {\n search(query, abort.signal).then(found => { if (!abort.signal.aborted) setOutcome({ search, results: found.slice(0, 8), failure: null }); },\n cause => { if (!abort.signal.aborted) setOutcome({ search, results: [], failure: cause instanceof Error ? cause.message : labels.searchFailed }); });\n }, 120);\n return () => { clearTimeout(timer); abort.abort(); };\n // eslint-disable-next-line react-hooks/exhaustive-deps -- the failure text is read when a search fails; a new label must not search again\n }, [query, search, generation]);\n usePickerKeyboard({ count: results.length, activeIdx: active, setActiveIdx: setActive, listRef: list, onSelect: index => { const item = results[index]; if (item) onSelect(item.path); }, onDismiss });\n\n if (results.length === 0 && !failure && !header) return null;\n return <ComposerSuggestions testId=\"mention-menu\" onDismiss={onDismiss}>\n {header}\n {failure ? <div role=\"alert\" data-testid=\"mention-error\" className=\"text-xs text-destructive\">{failure}</div>\n : <ul ref={list} role=\"listbox\" aria-label={listLabel ?? labels.workspaceFiles} className=\"m-0 flex max-h-[min(14rem,40dvh)] list-none flex-col overflow-y-auto p-0\">\n {results.map((item, index) => {\n const name = item.label ?? item.path.split('/').pop() ?? item.path;\n const Icon = item.kind === 'directory' ? FolderIcon : FileTextIcon;\n return <Item key={item.path} render={<li />} size=\"xs\" variant={index === active ? 'muted' : 'default'} role=\"option\" aria-selected={index === active} data-testid=\"mention-item\" data-path={item.path} title={item.label ?? item.path}\n onMouseEnter={() => setActive(index)} onMouseDown={event => { event.preventDefault(); onSelect(item.path); }}\n className=\"cursor-pointer flex-nowrap max-sm:min-h-11 pointer-coarse:min-h-11\">\n {renderOption ? renderOption(item, query) : <><ItemMedia variant=\"icon\"><Icon aria-hidden=\"true\" /></ItemMedia>\n <ItemContent className=\"min-w-0\"><ItemTitle>{highlight(name, query)}</ItemTitle>\n <ItemDescription className=\"m-0 truncate\">{item.path}</ItemDescription></ItemContent></>}\n </Item>;\n })}\n </ul>}\n </ComposerSuggestions>;\n}\n",
|
|
17
17
|
"type": "registry:component",
|
|
18
18
|
"target": "components/pi-chat-mentions/mention-menu.tsx"
|
|
19
19
|
}
|