@tangle-network/ui 11.25.0 → 11.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +22 -0
  3. package/dist/editor/editor-peers.js +1 -21
  4. package/dist/files/file-card.d.ts +30 -0
  5. package/dist/files/file-card.js +162 -0
  6. package/dist/files/file-list.d.ts +25 -0
  7. package/dist/files/file-list.js +88 -0
  8. package/dist/files/file-preview.js +16 -62
  9. package/dist/files/pdf-loader.d.ts +22 -0
  10. package/dist/files/pdf-loader.js +82 -0
  11. package/dist/files/pdf-viewer.d.ts +32 -0
  12. package/dist/files/pdf-viewer.js +567 -0
  13. package/dist/files.d.ts +5 -1
  14. package/dist/files.js +5 -1
  15. package/dist/index.d.ts +7 -3
  16. package/dist/index.js +5 -1
  17. package/dist/lib/optional-peer.js +29 -0
  18. package/dist/markdown/markdown.d.ts +3 -2
  19. package/dist/markdown/markdown.js +70 -4
  20. package/dist/primitives.d.ts +2 -2
  21. package/package.json +7 -2
  22. package/src/editor/editor-peers.ts +3 -25
  23. package/src/files/file-artifact-pane.test.tsx +12 -2
  24. package/src/files/file-card.test.tsx +62 -0
  25. package/src/files/file-card.tsx +210 -0
  26. package/src/files/file-list.stories.tsx +121 -0
  27. package/src/files/file-list.test.tsx +81 -0
  28. package/src/files/file-list.tsx +167 -0
  29. package/src/files/file-preview.tsx +25 -94
  30. package/src/files/index.ts +9 -0
  31. package/src/files/pdf-loader.test.ts +130 -0
  32. package/src/files/pdf-loader.ts +169 -0
  33. package/src/files/pdf-viewer.stories.tsx +81 -0
  34. package/src/files/pdf-viewer.test.tsx +245 -0
  35. package/src/files/pdf-viewer.tsx +677 -0
  36. package/src/files/pdfjs-worker.d.ts +3 -0
  37. package/src/lib/optional-peer.ts +30 -0
  38. package/src/markdown/markdown.test.tsx +32 -3
  39. package/src/markdown/markdown.tsx +85 -4
package/dist/index.js CHANGED
@@ -82,7 +82,11 @@ import { LiveDuration } from "./run/run-item-primitives.js";
82
82
  import { ToolCallFeed, parseToolEvent } from "./run/tool-call-feed.js";
83
83
  import { FileTree, filterFileTree } from "./files/file-tree.js";
84
84
  import { RichFileTree } from "./files/rich-file-tree.js";
85
+ import { FileCard } from "./files/file-card.js";
86
+ import { configurePdfViewer } from "./files/pdf-loader.js";
87
+ import { PdfViewer } from "./files/pdf-viewer.js";
85
88
  import { CSV_PREVIEW_ROW_LIMIT, FilePreview, parseCsv } from "./files/file-preview.js";
89
+ import { FileList } from "./files/file-list.js";
86
90
  import { FileTabs } from "./files/file-tabs.js";
87
91
  import { FileArtifactPane } from "./files/file-artifact-pane.js";
88
92
  import { AuthHeader, GitHubLoginButton, UserMenu } from "./auth/auth.js";
@@ -99,4 +103,4 @@ import { addMessage, addParts, clearChat, isStreamingAtom, messagesAtom, partMap
99
103
  import { copyText } from "./utils/copy-text.js";
100
104
  import { timeAgo } from "./utils/time-ago.js";
101
105
  import { RedactedDocument } from "./redaction/redacted-document.js";
102
- export { AgentTimeline, Alert, AlertDescription, AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogOverlay, AlertDialogPortal, AlertDialogTitle, AlertDialogTrigger, AlertTitle, ArtifactPane, AuthHeader, Avatar, AvatarFallback, AvatarImage, Badge, Button, CATEGORY_TONES, CSV_PREVIEW_ROW_LIMIT, Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, ChatContainer, ChatMessage, Checkbox, Chip, CodeBlock, Collapsible, CollapsibleContent, CollapsibleTrigger, CommandPreview, ContextMenu, ContextMenuCheckboxItem, ContextMenuContent, ContextMenuGroup, ContextMenuItem, ContextMenuLabel, ContextMenuPortal, ContextMenuRadioGroup, ContextMenuRadioItem, ContextMenuSeparator, ContextMenuShortcut, ContextMenuSub, ContextMenuSubContent, ContextMenuSubTrigger, ContextMenuTrigger, CopyButton, Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogOverlay, DialogPortal, DialogTitle, DialogTrigger, DiffPreview, DropZone, DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuPortal, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger, EmptyState, ExpandedToolDetail, FileArtifactPane, FilePreview, FileTabs, FileTree, FilterField, GitHubLoginButton, GlobResultsPreview, GrepResultsPreview, Heading, IconTile, InlineCode, InlineThinkingItem, InlineToolItem, Input, Label, LiveDuration, LoginLayout, Logo, Markdown, MessageAuthor, MessageList, Metric, MetricStrip, OPENUI_NODE_JSON_SCHEMA, OPENUI_NODE_TYPES, OpenUIArtifactRenderer, PageHeader, PageShell, Pagination, Popover, PopoverAnchor, PopoverClose, PopoverContent, PopoverTrigger, Progress, QuestionPreview, RealtimeSessionRegistry, RedactedDocument, RichFileTree, RunGroup, RunRowShell, RunRowStatusDot, SegmentedControl, Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectScrollDownButton, SelectScrollUpButton, SelectSeparator, SelectTrigger, SelectValue, Separator, Sheet, SheetClose, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetOverlay, SheetPortal, SheetTitle, SheetTrigger, SidebarDropZone, Skeleton, SkeletonCard, SkeletonTable, Slider, StatCard, StatusPill, Switch, TONE_CLASSES, TOOL_CATEGORY_ICONS, Table, TableBody, TableCaption, TableCell, TableFooter, TableHead, TableHeader, TableRow, Tabs, TabsContent, TabsList, TabsTrigger, Tag, TangleKnot, TerminalCursor, TerminalDisplay, TerminalInput, TerminalLine, TextShimmer, Textarea, ThemeToggle, ThinkingIndicator, ToastContainer, ToastProvider, ToolCallFeed, Toolbar, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, UploadProgress, UserMenu, UserMessage, WebSearchPreview, WriteFilePreview, activeSessionsAtom, addMessage, addParts, badgeVariants, bumpActiveSessionActivity, buttonVariants, clearChat, cn, copyText, createAuthFetcher, detectFileFormat, fileExtension, filterFileTree, findUnsupportedOpenUINode, focusField, focusFieldInvalid, focusFieldWithin, focusRing, focusRingInset, formatBytes, formatDuration, formatUptime, getActiveSession, getAllActiveSessions, getAllProjectActivity, getCodeLanguage, getFormatLabel, getSanitizedMarkdownHeadingIdFromRawFragment, getSessionsByActivity, getSessionsForNavbar, getSessionsForProject, getSyntaxLanguage, getToolCategory, getToolDisplayMetadata, getToolErrorText, getTotalRunningSessionCount, hasBackgroundRunningSessions, initialsOf, isStreamingAtom, isViewerMessage, messagesAtom, parseCsv, parseToolEvent, partMapAtom, registerActiveSession, resetActiveSessions, resolveFilePreviewKind, setActiveSessionAttention, setActiveSessionConnection, setActiveSessionError, setActiveSessionRunning, setForegroundActiveSession, timeAgo, toneFor, truncateText, unregisterActiveSession, updateActiveSessionMeta, updatePart, useActiveSession, useActiveSessions, useActiveSessionsState, useApiKey, useAuth, useAutoScroll, useDropdownMenu, useHasBackgroundRunningSessions, useLiveTime, useNavbarSessions, useProjectActivity, useProjectSessions, useRealtimeSession, useRunCollapseState, useRunGroups, useSSEStream, useSdkSession, useSessionsByActivity, useTheme, useToast, useToolCallStream, useTotalRunningSessions, validateOpenUIJsonArtifact, validateOpenUIJsonNode };
106
+ export { AgentTimeline, Alert, AlertDescription, AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogOverlay, AlertDialogPortal, AlertDialogTitle, AlertDialogTrigger, AlertTitle, ArtifactPane, AuthHeader, Avatar, AvatarFallback, AvatarImage, Badge, Button, CATEGORY_TONES, CSV_PREVIEW_ROW_LIMIT, Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, ChatContainer, ChatMessage, Checkbox, Chip, CodeBlock, Collapsible, CollapsibleContent, CollapsibleTrigger, CommandPreview, ContextMenu, ContextMenuCheckboxItem, ContextMenuContent, ContextMenuGroup, ContextMenuItem, ContextMenuLabel, ContextMenuPortal, ContextMenuRadioGroup, ContextMenuRadioItem, ContextMenuSeparator, ContextMenuShortcut, ContextMenuSub, ContextMenuSubContent, ContextMenuSubTrigger, ContextMenuTrigger, CopyButton, Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogOverlay, DialogPortal, DialogTitle, DialogTrigger, DiffPreview, DropZone, DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuPortal, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger, EmptyState, ExpandedToolDetail, FileArtifactPane, FileCard, FileList, FilePreview, FileTabs, FileTree, FilterField, GitHubLoginButton, GlobResultsPreview, GrepResultsPreview, Heading, IconTile, InlineCode, InlineThinkingItem, InlineToolItem, Input, Label, LiveDuration, LoginLayout, Logo, Markdown, MessageAuthor, MessageList, Metric, MetricStrip, OPENUI_NODE_JSON_SCHEMA, OPENUI_NODE_TYPES, OpenUIArtifactRenderer, PageHeader, PageShell, Pagination, PdfViewer, Popover, PopoverAnchor, PopoverClose, PopoverContent, PopoverTrigger, Progress, QuestionPreview, RealtimeSessionRegistry, RedactedDocument, RichFileTree, RunGroup, RunRowShell, RunRowStatusDot, SegmentedControl, Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectScrollDownButton, SelectScrollUpButton, SelectSeparator, SelectTrigger, SelectValue, Separator, Sheet, SheetClose, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetOverlay, SheetPortal, SheetTitle, SheetTrigger, SidebarDropZone, Skeleton, SkeletonCard, SkeletonTable, Slider, StatCard, StatusPill, Switch, TONE_CLASSES, TOOL_CATEGORY_ICONS, Table, TableBody, TableCaption, TableCell, TableFooter, TableHead, TableHeader, TableRow, Tabs, TabsContent, TabsList, TabsTrigger, Tag, TangleKnot, TerminalCursor, TerminalDisplay, TerminalInput, TerminalLine, TextShimmer, Textarea, ThemeToggle, ThinkingIndicator, ToastContainer, ToastProvider, ToolCallFeed, Toolbar, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, UploadProgress, UserMenu, UserMessage, WebSearchPreview, WriteFilePreview, activeSessionsAtom, addMessage, addParts, badgeVariants, bumpActiveSessionActivity, buttonVariants, clearChat, cn, configurePdfViewer, copyText, createAuthFetcher, detectFileFormat, fileExtension, filterFileTree, findUnsupportedOpenUINode, focusField, focusFieldInvalid, focusFieldWithin, focusRing, focusRingInset, formatBytes, formatDuration, formatUptime, getActiveSession, getAllActiveSessions, getAllProjectActivity, getCodeLanguage, getFormatLabel, getSanitizedMarkdownHeadingIdFromRawFragment, getSessionsByActivity, getSessionsForNavbar, getSessionsForProject, getSyntaxLanguage, getToolCategory, getToolDisplayMetadata, getToolErrorText, getTotalRunningSessionCount, hasBackgroundRunningSessions, initialsOf, isStreamingAtom, isViewerMessage, messagesAtom, parseCsv, parseToolEvent, partMapAtom, registerActiveSession, resetActiveSessions, resolveFilePreviewKind, setActiveSessionAttention, setActiveSessionConnection, setActiveSessionError, setActiveSessionRunning, setForegroundActiveSession, timeAgo, toneFor, truncateText, unregisterActiveSession, updateActiveSessionMeta, updatePart, useActiveSession, useActiveSessions, useActiveSessionsState, useApiKey, useAuth, useAutoScroll, useDropdownMenu, useHasBackgroundRunningSessions, useLiveTime, useNavbarSessions, useProjectActivity, useProjectSessions, useRealtimeSession, useRunCollapseState, useRunGroups, useSSEStream, useSdkSession, useSessionsByActivity, useTheme, useToast, useToolCallStream, useTotalRunningSessions, validateOpenUIJsonArtifact, validateOpenUIJsonNode };
@@ -0,0 +1,29 @@
1
+ //#region src/lib/optional-peer.ts
2
+ /**
3
+ * Shared handling for optional peers that an entry reaches through a dynamic
4
+ * `import()`: `./editor` for tiptap, yjs and Hocuspocus, `./files` for
5
+ * pdfjs-dist. `scripts/validate-dist.mjs` holds the rules these helpers serve.
6
+ */
7
+ /** The messages a bundler or a runtime gives for a module it cannot resolve. */
8
+ const RESOLUTION_FAILURE = /could not resolve|cannot find (?:module|package)|can't resolve|failed to resolve|module not found/i;
9
+ /**
10
+ * True when the rejection says the package is not installed. Only such an
11
+ * error gets the install list: a transient chunk-fetch failure that reads as
12
+ * "install the peers" sends the reader to the wrong fix. An error this
13
+ * predicate does not match keeps its own message, so it can only
14
+ * under-report.
15
+ */
16
+ function isMissingPeerError(error) {
17
+ return error instanceof Error && RESOLUTION_FAILURE.test(error.message);
18
+ }
19
+ /**
20
+ * Hands an `import()` rejection on unchanged. esbuild reports an unresolvable
21
+ * literal `import()` as a build error and defers it to run time only when the
22
+ * call carries a `.catch()`, so every peer import attaches this handler. It
23
+ * changes nothing at run time.
24
+ */
25
+ function rethrow(error) {
26
+ throw error;
27
+ }
28
+ //#endregion
29
+ export { isMissingPeerError, rethrow };
@@ -9,8 +9,9 @@ export interface MarkdownProps {
9
9
  urlTransform?: UrlTransform;
10
10
  }
11
11
  /**
12
- * Renders Markdown content with GFM support, XSS sanitisation, and
13
- * custom code block rendering via our CodeBlock component.
12
+ * Renders Markdown content with GFM support, XSS sanitisation, custom code
13
+ * block rendering via our CodeBlock component, and images that open full size
14
+ * or show a labelled placeholder when unavailable.
14
15
  */
15
16
  export declare const Markdown: import("react").MemoExoticComponent<({ children, className, urlTransform }: MarkdownProps) => import("react").JSX.Element>;
16
17
  //#endregion
@@ -1,7 +1,8 @@
1
1
  import { cn } from "../lib/utils.js";
2
2
  import { CodeBlock, CopyButton } from "./code-block.js";
3
- import { memo } from "react";
4
- import { Fragment as Fragment$1, jsx } from "react/jsx-runtime";
3
+ import { createContext, memo, useContext, useEffect, useRef, useState } from "react";
4
+ import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
5
+ import { ImageOff } from "lucide-react";
5
6
  import ReactMarkdown, { defaultUrlTransform } from "react-markdown";
6
7
  import remarkGfm from "remark-gfm";
7
8
  import rehypeSanitize from "rehype-sanitize";
@@ -27,9 +28,72 @@ function transformMarkdownUrl(url, key, node, urlTransform) {
27
28
  if (key === "href" && transformed === url && url.startsWith("#")) return safeUrl("#" + getSanitizedMarkdownHeadingIdFromRawFragment(url.slice(1)));
28
29
  return safeUrl(transformed);
29
30
  }
31
+ const MarkdownLinkContext = createContext(false);
32
+ function MarkdownLink({ node: _node, ...props }) {
33
+ return /* @__PURE__ */ jsx(MarkdownLinkContext.Provider, {
34
+ value: true,
35
+ children: /* @__PURE__ */ jsx("a", { ...props })
36
+ });
37
+ }
38
+ /**
39
+ * Renders a Markdown image that opens full size in a new tab, or a labelled
40
+ * placeholder when its URL is unsafe or fails to load, instead of the
41
+ * browser's broken-image icon.
42
+ */
43
+ function MarkdownImage({ node: _node, src, alt, className, ...props }) {
44
+ const insideLink = useContext(MarkdownLinkContext);
45
+ const imageRef = useRef(null);
46
+ const url = typeof src === "string" && src ? src : void 0;
47
+ const [failedUrl, setFailedUrl] = useState();
48
+ useEffect(() => {
49
+ const image = imageRef.current;
50
+ if (!url || !image?.complete || typeof image.decode !== "function") return;
51
+ let current = true;
52
+ image.decode().catch(() => {
53
+ if (current) setFailedUrl(url);
54
+ });
55
+ return () => {
56
+ current = false;
57
+ };
58
+ }, [url]);
59
+ if (!url || failedUrl === url) {
60
+ const label = alt ? `Image unavailable: ${alt}` : "Image unavailable";
61
+ return /* @__PURE__ */ jsxs("span", {
62
+ role: "img",
63
+ "aria-label": label,
64
+ className: "inline-flex max-w-full items-center gap-2 rounded-md border border-dashed border-border bg-muted px-3 py-2 text-sm text-muted-foreground",
65
+ children: [/* @__PURE__ */ jsx(ImageOff, {
66
+ "aria-hidden": true,
67
+ className: "size-4 shrink-0"
68
+ }), /* @__PURE__ */ jsx("span", {
69
+ className: "min-w-0 truncate",
70
+ children: label
71
+ })]
72
+ });
73
+ }
74
+ const image = /* @__PURE__ */ jsx("img", {
75
+ ...props,
76
+ ref: imageRef,
77
+ src: url,
78
+ alt: alt ?? "",
79
+ loading: "lazy",
80
+ decoding: "async",
81
+ onError: () => setFailedUrl(url),
82
+ className: cn("h-auto max-w-full rounded-md", className)
83
+ });
84
+ if (insideLink) return image;
85
+ return /* @__PURE__ */ jsx("a", {
86
+ href: url,
87
+ target: "_blank",
88
+ rel: "noopener noreferrer",
89
+ className: "inline-block max-w-full",
90
+ children: image
91
+ });
92
+ }
30
93
  /**
31
- * Renders Markdown content with GFM support, XSS sanitisation, and
32
- * custom code block rendering via our CodeBlock component.
94
+ * Renders Markdown content with GFM support, XSS sanitisation, custom code
95
+ * block rendering via our CodeBlock component, and images that open full size
96
+ * or show a labelled placeholder when unavailable.
33
97
  */
34
98
  const Markdown = memo(({ children, className, urlTransform }) => {
35
99
  return /* @__PURE__ */ jsx("div", {
@@ -39,6 +103,8 @@ const Markdown = memo(({ children, className, urlTransform }) => {
39
103
  rehypePlugins: [rehypeSlug, rehypeSanitize],
40
104
  urlTransform: (url, key, node) => transformMarkdownUrl(url, key, node, urlTransform),
41
105
  components: {
106
+ a: MarkdownLink,
107
+ img: MarkdownImage,
42
108
  pre({ children: preChildren }) {
43
109
  return /* @__PURE__ */ jsx(Fragment$1, { children: preChildren });
44
110
  },
@@ -1,5 +1,7 @@
1
1
  import { Button, ButtonProps, buttonVariants } from "./primitives/button.js";
2
2
  import { ArtifactPane, ArtifactPaneProps } from "./primitives/artifact-pane.js";
3
+ import { CATEGORY_TONES, CategoryTone, TONE_CLASSES, Tone, ToneClasses, toneFor } from "./primitives/tone.js";
4
+ import { IconTile, IconTileProps, initialsOf } from "./primitives/icon-tile.js";
3
5
  import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, CardTitleProps } from "./primitives/card.js";
4
6
  import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogOverlay, DialogPortal, DialogTitle, DialogTrigger } from "./primitives/dialog.js";
5
7
  import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogOverlay, AlertDialogPortal, AlertDialogTitle, AlertDialogTrigger } from "./primitives/alert-dialog.js";
@@ -40,10 +42,8 @@ import { PageHeader, PageHeaderProps } from "./primitives/page-header.js";
40
42
  import { StatusPill, StatusPillProps, StatusTone } from "./primitives/status-pill.js";
41
43
  import { Alert, AlertDescription, AlertProps, AlertTitle, AlertTone } from "./primitives/alert.js";
42
44
  import { Pagination, PaginationProps } from "./primitives/pagination.js";
43
- import { CATEGORY_TONES, CategoryTone, TONE_CLASSES, Tone, ToneClasses, toneFor } from "./primitives/tone.js";
44
45
  import { Tag, TagProps } from "./primitives/tag.js";
45
46
  import { Chip, ChipProps } from "./primitives/chip.js";
46
- import { IconTile, IconTileProps, initialsOf } from "./primitives/icon-tile.js";
47
47
  import { Metric, MetricProps, MetricStrip, MetricStripProps } from "./primitives/metric-strip.js";
48
48
  import { FilterField, FilterFieldProps, Toolbar, ToolbarProps } from "./primitives/toolbar.js";
49
49
  import { Logo, LogoProps, TangleKnot } from "@tangle-network/brand";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tangle-network/ui",
3
- "version": "11.25.0",
3
+ "version": "11.27.0",
4
4
  "description": "Generic React UI components for Tangle products — primitives, chat, run, files, editor, markdown.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -149,13 +149,14 @@
149
149
  "peerDependencies": {
150
150
  "@hocuspocus/provider": ">=2.15.0 <5.0.0",
151
151
  "@nanostores/react": ">=0.8.4 <3.0.0",
152
- "@tangle-network/brand": "^1.17.0",
152
+ "@tangle-network/brand": "^1.17.1",
153
153
  "@tiptap/core": ">=3.20.4 <4.0.0",
154
154
  "@tiptap/extension-collaboration": ">=3.20.4 <4.0.0",
155
155
  "@tiptap/extension-collaboration-caret": ">=3.7.0 <4.0.0",
156
156
  "@tiptap/react": ">=3.20.4 <4.0.0",
157
157
  "@tiptap/starter-kit": ">=3.20.4 <4.0.0",
158
158
  "nanostores": ">=0.11.3 <2.0.0",
159
+ "pdfjs-dist": ">=6.0.0 <7.0.0",
159
160
  "react": "^18 || ^19",
160
161
  "react-dom": "^18 || ^19",
161
162
  "react-router": "^7 || ^8",
@@ -191,6 +192,9 @@
191
192
  },
192
193
  "react-router": {
193
194
  "optional": true
195
+ },
196
+ "pdfjs-dist": {
197
+ "optional": true
194
198
  }
195
199
  },
196
200
  "devDependencies": {
@@ -208,6 +212,7 @@
208
212
  "@types/turndown": "^5.0.6",
209
213
  "ajv": "8.17.1",
210
214
  "nanostores": "^1.5.4",
215
+ "pdfjs-dist": "^6.4.299",
211
216
  "react": "^19.3.0",
212
217
  "react-dom": "^19.3.0",
213
218
  "react-router": "^8.4.0",
@@ -32,6 +32,9 @@ import type * as TiptapCollaborationCaret from "@tiptap/extension-collaboration-
32
32
  import type * as TiptapReact from "@tiptap/react";
33
33
  import type * as TiptapStarterKit from "@tiptap/starter-kit";
34
34
  import type * as Yjs from "yjs";
35
+ import { isMissingPeerError, rethrow } from "../lib/optional-peer";
36
+
37
+ export { isMissingPeerError };
35
38
 
36
39
  /** Namespaces the local markdown editor needs. */
37
40
  export interface DocumentEditorPeers {
@@ -89,31 +92,6 @@ export function isMissingEditorPeersError(error: unknown): boolean {
89
92
  return error instanceof Error && error.name === "MissingEditorPeersError";
90
93
  }
91
94
 
92
- /** The messages a bundler or a runtime gives for a module it cannot resolve. */
93
- const RESOLUTION_FAILURE =
94
- /could not resolve|cannot find (?:module|package)|can't resolve|failed to resolve|module not found/i;
95
-
96
- /**
97
- * True when the rejection says the package is not installed. Only such an
98
- * error gets the install list: a transient chunk-fetch failure that reads as
99
- * "install the peers" sends the reader to the wrong fix. An error this
100
- * predicate does not match keeps its own message, so it can only
101
- * under-report.
102
- */
103
- export function isMissingPeerError(error: unknown): boolean {
104
- return error instanceof Error && RESOLUTION_FAILURE.test(error.message);
105
- }
106
-
107
- /**
108
- * Hands an `import()` rejection on unchanged. esbuild reports an unresolvable
109
- * literal `import()` as a build error and defers it to run time only when the
110
- * call carries a `.catch()`, so every peer import attaches this handler. It
111
- * changes nothing at run time.
112
- */
113
- function rethrow(error: unknown): never {
114
- throw error;
115
- }
116
-
117
95
  /**
118
96
  * Every optional peer the `./editor` entry needs at run time. The loaders
119
97
  * import all but `@tiptap/core`, which the tiptap packages need in turn: a
@@ -2,6 +2,12 @@ import { describe, expect, it, vi } from "vitest";
2
2
  import { fireEvent, render, screen } from "@testing-library/react";
3
3
  import { FileArtifactPane } from "./file-artifact-pane";
4
4
  import { CSV_PREVIEW_ROW_LIMIT, parseCsv } from "./file-preview";
5
+ import { MissingPdfjsError, loadPdfjs } from "./pdf-loader";
6
+
7
+ vi.mock("./pdf-loader", async (importOriginal) => ({
8
+ ...(await importOriginal<typeof import("./pdf-loader")>()),
9
+ loadPdfjs: vi.fn(),
10
+ }));
5
11
 
6
12
  const BLOB_URL = "blob:https://app.tangle.tools/1f2e3d4c";
7
13
 
@@ -32,16 +38,20 @@ describe("FileArtifactPane previews", () => {
32
38
  expect(container.querySelector("img")).toHaveClass("max-w-none");
33
39
  });
34
40
 
35
- it("renders a PDF as an <object> of the blobUrl", () => {
41
+ it("renders a PDF in PdfViewer, and in an <object> of the blobUrl without pdfjs-dist", async () => {
42
+ vi.mocked(loadPdfjs).mockRejectedValue(new MissingPdfjsError());
43
+ const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
36
44
  const { container } = render(
37
45
  <FileArtifactPane filename="report.pdf" blobUrl={BLOB_URL} mimeType="application/pdf" />,
38
46
  );
39
47
 
48
+ expect(screen.getByRole("status")).toHaveTextContent("Loading report.pdf");
49
+ expect(await screen.findByText(/does not include the page viewer/)).toBeInTheDocument();
40
50
  const viewer = container.querySelector("object");
41
- expect(viewer).not.toBeNull();
42
51
  expect(viewer).toHaveAttribute("data", BLOB_URL);
43
52
  expect(viewer).toHaveAttribute("type", "application/pdf");
44
53
  expect(viewer).toHaveAttribute("title", "report.pdf");
54
+ consoleError.mockRestore();
45
55
  });
46
56
 
47
57
  it("renders video and audio with native controls", () => {
@@ -0,0 +1,62 @@
1
+ import { fireEvent, render, screen } from "@testing-library/react";
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import { FileCard } from "./file-card";
4
+
5
+ const UPLOADED = new Date("2026-10-06T15:30:00Z");
6
+
7
+ describe("FileCard", () => {
8
+ it("names the file with its size, date and uploader", () => {
9
+ const { container } = render(
10
+ <FileCard
11
+ filename="Engagement letter.pdf"
12
+ size={2_516_582}
13
+ date={UPLOADED}
14
+ uploader={{ name: "Dana Ruiz", role: "Client" }}
15
+ />,
16
+ );
17
+
18
+ expect(screen.getByText("Engagement letter.pdf")).toBeInTheDocument();
19
+ expect(screen.getByText("2.4 MB")).toBeInTheDocument();
20
+ expect(container.querySelector("time")).toHaveAttribute("datetime", UPLOADED.toISOString());
21
+ expect(screen.getByText("Uploaded by Dana Ruiz (Client)")).toBeInTheDocument();
22
+ });
23
+
24
+ it("leaves out what it does not know", () => {
25
+ const { container } = render(<FileCard filename="notes.txt" date="not a date" />);
26
+ expect(container.querySelector("time")).toBeNull();
27
+ expect(screen.queryByText(/Uploaded by/)).toBeNull();
28
+ expect(screen.queryByRole("button")).toBeNull();
29
+ });
30
+
31
+ it("takes its icon tone from the file kind, with the MIME type outranking the name", () => {
32
+ const pdf = render(<FileCard filename="brief.pdf" />);
33
+ expect(pdf.container.querySelector("[aria-hidden='true']")?.className).toContain(
34
+ "--tone-orange-bg",
35
+ );
36
+ pdf.unmount();
37
+
38
+ const sheet = render(<FileCard filename="export" mimeType="text/csv" />);
39
+ expect(sheet.container.querySelector("[aria-hidden='true']")?.className).toContain(
40
+ "--tone-lime-bg",
41
+ );
42
+ });
43
+
44
+ it("offers Download and the host's own actions", () => {
45
+ const onDownload = vi.fn();
46
+ render(
47
+ <FileCard
48
+ filename="book.xlsx"
49
+ description="Download to open this workbook in a spreadsheet app."
50
+ onDownload={onDownload}
51
+ actions={<button type="button">Open</button>}
52
+ />,
53
+ );
54
+
55
+ expect(
56
+ screen.getByText("Download to open this workbook in a spreadsheet app."),
57
+ ).toBeInTheDocument();
58
+ fireEvent.click(screen.getByRole("button", { name: "Download" }));
59
+ expect(onDownload).toHaveBeenCalledTimes(1);
60
+ expect(screen.getByRole("button", { name: "Open" })).toBeInTheDocument();
61
+ });
62
+ });
@@ -0,0 +1,210 @@
1
+ /**
2
+ * FileCard — one file, described: its kind, name, size, date and uploader,
3
+ * with Download and any actions the host adds. FilePreview shows it for a file
4
+ * it cannot render inline; a host can show it anywhere a file stands on its own.
5
+ */
6
+
7
+ import * as React from "react";
8
+ import {
9
+ Download,
10
+ File,
11
+ FileAudio,
12
+ FileCode,
13
+ FileImage,
14
+ FileJson,
15
+ FileSpreadsheet,
16
+ FileText,
17
+ FileVideo,
18
+ type LucideIcon,
19
+ } from "lucide-react";
20
+ import { cn } from "../lib/utils";
21
+ import { Button } from "../primitives/button";
22
+ import { IconTile, type IconTileProps } from "../primitives/icon-tile";
23
+ import type { CategoryTone } from "../primitives/tone";
24
+ import { formatBytes } from "../utils/format";
25
+ import { getFormatLabel, resolveFilePreviewKind, type FilePreviewKind } from "./file-format";
26
+
27
+ /** Who added a file, as a reader of the list should see them. */
28
+ export interface FileUploader {
29
+ name: string;
30
+ /** Their relationship to the work, e.g. "Client" or "Attorney". */
31
+ role?: string;
32
+ }
33
+
34
+ /** What FileCard and FileList show about one file. */
35
+ export interface FileDetails {
36
+ filename: string;
37
+ /** Wins over the extension when choosing the kind, as in FilePreview. */
38
+ mimeType?: string;
39
+ /** Size in bytes. */
40
+ size?: number;
41
+ /** When the file was added or last changed. */
42
+ date?: Date | string | number;
43
+ uploader?: FileUploader;
44
+ }
45
+
46
+ // File kinds are categories, so each takes a categorical tone and never a
47
+ // status colour: a PDF is not an error. See "Color Roles" in the brand guide.
48
+ const KIND_ICON: Record<FilePreviewKind, { icon: LucideIcon; tone: CategoryTone }> = {
49
+ pdf: { icon: FileText, tone: "orange" },
50
+ image: { icon: FileImage, tone: "pink" },
51
+ video: { icon: FileVideo, tone: "violet" },
52
+ audio: { icon: FileAudio, tone: "cyan" },
53
+ csv: { icon: FileSpreadsheet, tone: "lime" },
54
+ spreadsheet: { icon: FileSpreadsheet, tone: "lime" },
55
+ code: { icon: FileCode, tone: "blue" },
56
+ json: { icon: FileJson, tone: "blue" },
57
+ yaml: { icon: FileCode, tone: "blue" },
58
+ markdown: { icon: FileText, tone: "teal" },
59
+ text: { icon: FileText, tone: "teal" },
60
+ binary: { icon: File, tone: "brown" },
61
+ };
62
+
63
+ /** The kind's icon on its category tone. Decorative: the filename names the file. */
64
+ export function FileKindTile({
65
+ filename,
66
+ mimeType,
67
+ size = "md",
68
+ className,
69
+ }: {
70
+ filename: string;
71
+ mimeType?: string;
72
+ size?: IconTileProps["size"];
73
+ className?: string;
74
+ }) {
75
+ const { icon: Icon, tone } = KIND_ICON[resolveFilePreviewKind(filename, mimeType)];
76
+ return <IconTile tone={tone} size={size} icon={<Icon />} className={className} />;
77
+ }
78
+
79
+ function toDate(value: FileDetails["date"]): Date | undefined {
80
+ if (value === undefined) return undefined;
81
+ const date = value instanceof Date ? value : new Date(value);
82
+ return Number.isNaN(date.getTime()) ? undefined : date;
83
+ }
84
+
85
+ const DATE_FORMAT = new Intl.DateTimeFormat(undefined, { dateStyle: "medium" });
86
+
87
+ /**
88
+ * A dot between facts for the eye, a comma for a screen reader, which would
89
+ * otherwise read the facts as one run-on word or announce "middle dot".
90
+ */
91
+ export function FactSeparator() {
92
+ return (
93
+ <>
94
+ <span aria-hidden="true"> · </span>
95
+ <span className="sr-only">, </span>
96
+ </>
97
+ );
98
+ }
99
+
100
+ /**
101
+ * The kind, size and date as one muted line, each part only when known.
102
+ * The date follows the reader's locale, which a server render may not share,
103
+ * so the element tolerates a hydration difference.
104
+ */
105
+ export function FileFacts({
106
+ filename,
107
+ mimeType,
108
+ size,
109
+ date,
110
+ showKind = false,
111
+ className,
112
+ }: Pick<FileDetails, "filename" | "mimeType" | "size" | "date"> & {
113
+ showKind?: boolean;
114
+ className?: string;
115
+ }) {
116
+ const parsed = toDate(date);
117
+ const parts: React.ReactNode[] = [];
118
+ if (showKind) parts.push(getFormatLabel(resolveFilePreviewKind(filename, mimeType)));
119
+ if (size !== undefined) parts.push(formatBytes(size));
120
+ if (parsed) {
121
+ parts.push(
122
+ <time key="date" dateTime={parsed.toISOString()} suppressHydrationWarning>
123
+ {DATE_FORMAT.format(parsed)}
124
+ </time>,
125
+ );
126
+ }
127
+ if (parts.length === 0) return null;
128
+ return (
129
+ <span className={cn("text-xs text-muted-foreground", className)}>
130
+ {parts.map((part, index) => (
131
+ <React.Fragment key={index}>
132
+ {index > 0 && <FactSeparator />}
133
+ {part}
134
+ </React.Fragment>
135
+ ))}
136
+ </span>
137
+ );
138
+ }
139
+
140
+ /** "Dana Ruiz (Client)", or the name alone. */
141
+ export function uploaderLabel(uploader: FileUploader): string {
142
+ return uploader.role ? `${uploader.name} (${uploader.role})` : uploader.name;
143
+ }
144
+
145
+ export interface FileCardProps
146
+ extends FileDetails,
147
+ Omit<React.HTMLAttributes<HTMLDivElement>, "children"> {
148
+ /** One sentence under the details, such as why there is no preview. */
149
+ description?: React.ReactNode;
150
+ /** Adds a Download button. */
151
+ onDownload?: () => void;
152
+ /** Further buttons after Download, such as Open or Delete. */
153
+ actions?: React.ReactNode;
154
+ }
155
+
156
+ export const FileCard = React.forwardRef<HTMLDivElement, FileCardProps>(
157
+ (
158
+ {
159
+ filename,
160
+ mimeType,
161
+ size,
162
+ date,
163
+ uploader,
164
+ description,
165
+ onDownload,
166
+ actions,
167
+ className,
168
+ ...props
169
+ },
170
+ ref,
171
+ ) => (
172
+ <div
173
+ ref={ref}
174
+ className={cn(
175
+ "flex min-w-0 flex-col items-center justify-center rounded-[var(--radius-md)] border border-border bg-card px-6 py-12 text-center",
176
+ className,
177
+ )}
178
+ {...props}
179
+ >
180
+ <FileKindTile filename={filename} mimeType={mimeType} size="lg" />
181
+ <p
182
+ className="mt-3 line-clamp-2 max-w-full text-sm font-medium text-foreground [overflow-wrap:anywhere]"
183
+ title={filename}
184
+ >
185
+ {filename}
186
+ </p>
187
+ <FileFacts filename={filename} size={size} date={date} className="mt-1" />
188
+ {uploader && (
189
+ <p className="mt-0.5 max-w-full truncate text-xs text-muted-foreground">
190
+ Uploaded by {uploaderLabel(uploader)}
191
+ </p>
192
+ )}
193
+ {description && (
194
+ <div className="mt-3 max-w-md text-sm text-muted-foreground">{description}</div>
195
+ )}
196
+ {(onDownload || actions) && (
197
+ <div className="mt-5 flex flex-wrap items-center justify-center gap-2">
198
+ {onDownload && (
199
+ <Button type="button" variant="outline" size="sm" onClick={onDownload}>
200
+ <Download />
201
+ Download
202
+ </Button>
203
+ )}
204
+ {actions}
205
+ </div>
206
+ )}
207
+ </div>
208
+ ),
209
+ );
210
+ FileCard.displayName = "FileCard";