@djangocfg/ui-tools 2.1.430 → 2.1.432

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 (188) hide show
  1. package/dist/tree/index.cjs +43 -8
  2. package/dist/tree/index.cjs.map +1 -1
  3. package/dist/tree/index.d.cts +31 -1
  4. package/dist/tree/index.d.ts +31 -1
  5. package/dist/tree/index.mjs +43 -8
  6. package/dist/tree/index.mjs.map +1 -1
  7. package/package.json +28 -14
  8. package/src/common/CodePanelHeader/CodePanelHeader.tsx +121 -0
  9. package/src/common/CodePanelHeader/index.ts +1 -0
  10. package/src/common/blocks/BlockError.tsx +46 -0
  11. package/src/common/blocks/index.ts +34 -0
  12. package/src/common/blocks/schemas.ts +143 -0
  13. package/src/common/blocks/types.ts +76 -0
  14. package/src/common/chips/README.md +67 -0
  15. package/src/common/chips/UrlChip.tsx +105 -0
  16. package/src/common/chips/index.ts +20 -0
  17. package/src/common/link-preview/LinkPreviewCard.tsx +178 -0
  18. package/src/common/link-preview/index.ts +12 -0
  19. package/src/common/link-preview/types.ts +44 -0
  20. package/src/common/tiptap/index.ts +10 -0
  21. package/src/common/tiptap/useNodeAttrs.ts +128 -0
  22. package/src/lib/page-snapshot/react/PageSnapshotChip.tsx +1 -1
  23. package/src/tools/chat/README.md +109 -2
  24. package/src/tools/chat/composer/Composer.tsx +45 -12
  25. package/src/tools/chat/composer/ComposerRichTextarea.tsx +16 -4
  26. package/src/tools/chat/composer/PastedTextDialog.tsx +88 -0
  27. package/src/tools/chat/composer/index.ts +9 -0
  28. package/src/tools/chat/composer/textToAttachment.ts +69 -0
  29. package/src/tools/chat/composer/types.ts +13 -0
  30. package/src/tools/chat/composer/useComposerAttach.tsx +47 -0
  31. package/src/tools/chat/constants.ts +9 -2
  32. package/src/tools/chat/core/extractFirstUrl.ts +45 -0
  33. package/src/tools/chat/core/reducer.ts +32 -8
  34. package/src/tools/chat/core/transport/mappers/pydantic-ai.ts +1 -1
  35. package/src/tools/chat/core/transport/mock.ts +4 -1
  36. package/src/tools/chat/hooks/useChat.ts +20 -20
  37. package/src/tools/chat/lazy.tsx +10 -0
  38. package/src/tools/chat/messages/Attachments.tsx +335 -46
  39. package/src/tools/chat/messages/BubbleContextMenu.tsx +203 -0
  40. package/src/tools/chat/messages/MessageBubble.tsx +175 -31
  41. package/src/tools/chat/messages/MessageList.tsx +107 -21
  42. package/src/tools/chat/messages/ToolCalls.tsx +28 -13
  43. package/src/tools/chat/messages/blocks/MessageBlocks.tsx +70 -17
  44. package/src/tools/chat/messages/blocks/builtin.tsx +6 -3
  45. package/src/tools/chat/messages/blocks/renderers/AudioBlock.tsx +6 -0
  46. package/src/tools/chat/messages/blocks/renderers/ChatImageAlbum.tsx +154 -0
  47. package/src/tools/chat/messages/blocks/renderers/CodeBlock.tsx +94 -6
  48. package/src/tools/chat/messages/blocks/renderers/DiffBlock.tsx +29 -0
  49. package/src/tools/chat/messages/blocks/renderers/GalleryBlock.tsx +17 -10
  50. package/src/tools/chat/messages/blocks/renderers/ImageBlock.tsx +37 -16
  51. package/src/tools/chat/messages/blocks/renderers/JsonBlock.tsx +11 -6
  52. package/src/tools/chat/messages/blocks/renderers/LinkBlock.tsx +32 -0
  53. package/src/tools/chat/messages/blocks/renderers/MapBlock.tsx +181 -19
  54. package/src/tools/chat/messages/blocks/renderers/VideoBlock.tsx +2 -5
  55. package/src/tools/chat/messages/index.ts +13 -0
  56. package/src/tools/chat/public.ts +5 -1
  57. package/src/tools/chat/shell/ChatRoot.tsx +29 -0
  58. package/src/tools/chat/styles/bubbleTokens.ts +18 -0
  59. package/src/tools/chat/styles/index.ts +1 -0
  60. package/src/tools/chat/types/attachment.ts +38 -3
  61. package/src/tools/chat/types/block.ts +56 -9
  62. package/src/tools/chat/types/blockSchemas.ts +197 -0
  63. package/src/tools/chat/types/config.ts +33 -0
  64. package/src/tools/chat/types/events.ts +14 -7
  65. package/src/tools/chat/types/index.ts +4 -2
  66. package/src/tools/chat/types/message.ts +7 -0
  67. package/src/tools/data/JsonTree/JsonViewer.tsx +7 -7
  68. package/src/tools/data/Tree/README.md +30 -0
  69. package/src/tools/data/Tree/components/TreeRow.tsx +11 -2
  70. package/src/tools/data/Tree/context/menu/render.tsx +56 -27
  71. package/src/tools/data/Tree/data/appearance.ts +65 -3
  72. package/src/tools/data/Tree/data/index.ts +2 -0
  73. package/src/tools/data/Tree/index.tsx +2 -0
  74. package/src/tools/dev/Map/README.md +30 -345
  75. package/src/tools/dev/Map/cards/index.ts +6 -0
  76. package/src/tools/dev/Map/cards/types.ts +67 -0
  77. package/src/tools/dev/Map/components/CompassChip.tsx +67 -0
  78. package/src/tools/dev/Map/components/DraggableMarkers.tsx +79 -0
  79. package/src/tools/dev/Map/components/GeocoderControl.tsx +148 -63
  80. package/src/tools/dev/Map/components/GeocoderInput.tsx +181 -0
  81. package/src/tools/dev/Map/components/MapContainer/FullscreenDialog.tsx +140 -0
  82. package/src/tools/dev/Map/components/MapContainer/GeolocateHandle.tsx +70 -0
  83. package/src/tools/dev/Map/components/MapContainer/MapInner.tsx +326 -0
  84. package/src/tools/dev/Map/components/MapContainer/MapToolbar.tsx +286 -0
  85. package/src/tools/dev/Map/components/MapContainer/RotateHint.tsx +25 -0
  86. package/src/tools/dev/Map/components/MapContainer/ScrollLockOverlay.tsx +52 -0
  87. package/src/tools/dev/Map/components/MapContainer/constants.ts +15 -0
  88. package/src/tools/dev/Map/components/MapContainer/hooks/useAutoReset.ts +51 -0
  89. package/src/tools/dev/Map/components/MapContainer/hooks/useBasemapState.ts +48 -0
  90. package/src/tools/dev/Map/components/MapContainer/hooks/useExternalMapsUrl.ts +29 -0
  91. package/src/tools/dev/Map/components/MapContainer/index.tsx +29 -0
  92. package/src/tools/dev/Map/components/MapContainer/types.ts +198 -0
  93. package/src/tools/dev/Map/components/MapControls.tsx +27 -0
  94. package/src/tools/dev/Map/components/MapMarkers.tsx +94 -0
  95. package/src/tools/dev/Map/components/MarkerCard.tsx +102 -0
  96. package/src/tools/dev/Map/components/MeasureControl.tsx +219 -0
  97. package/src/tools/dev/Map/components/index.ts +35 -1
  98. package/src/tools/dev/Map/geocode/index.ts +2 -0
  99. package/src/tools/dev/Map/geocode/photon.ts +265 -0
  100. package/src/tools/dev/Map/hooks/index.ts +37 -0
  101. package/src/tools/dev/Map/hooks/useCompass.ts +44 -0
  102. package/src/tools/dev/Map/hooks/useDraggableMarkers.ts +112 -0
  103. package/src/tools/dev/Map/hooks/useGeocoder.ts +155 -0
  104. package/src/tools/dev/Map/hooks/useGeolocation.ts +174 -0
  105. package/src/tools/dev/Map/hooks/useMapControl.ts +5 -1
  106. package/src/tools/dev/Map/hooks/useMapImages.ts +112 -0
  107. package/src/tools/dev/Map/hooks/useMapScrollProtection.ts +155 -0
  108. package/src/tools/dev/Map/hooks/useMapTouchMode.ts +21 -0
  109. package/src/tools/dev/Map/hooks/useMarkerCard.ts +39 -0
  110. package/src/tools/dev/Map/hooks/useMarkers.ts +9 -3
  111. package/src/tools/dev/Map/hooks/useMeasure.ts +169 -0
  112. package/src/tools/dev/Map/hooks/useTerrain.ts +123 -0
  113. package/src/tools/dev/Map/index.ts +59 -2
  114. package/src/tools/dev/Map/layers/index.ts +1 -0
  115. package/src/tools/dev/Map/layers/symbol.ts +93 -0
  116. package/src/tools/dev/Map/lazy.tsx +120 -0
  117. package/src/tools/dev/Map/protocols/pmtiles.ts +73 -0
  118. package/src/tools/dev/Map/styles/index.ts +62 -0
  119. package/src/tools/dev/Map/styles/terrain.ts +29 -0
  120. package/src/tools/dev/Map/types.ts +31 -3
  121. package/src/tools/dev/api/OpenapiViewer/components/DocsLayout/EndpointDoc/Header/MetaActions.tsx +1 -1
  122. package/src/tools/dev/api/OpenapiViewer/components/DocsLayout/EndpointDoc/Responses/StatusTag.tsx +5 -12
  123. package/src/tools/dev/api/OpenapiViewer/components/DocsLayout/EndpointDoc/Section/SectionHeader.tsx +1 -1
  124. package/src/tools/dev/api/OpenapiViewer/components/DocsLayout/SchemaCopyMenu.tsx +1 -1
  125. package/src/tools/dev/api/OpenapiViewer/components/shared/SendButton.tsx +1 -1
  126. package/src/tools/dev/api/OpenapiViewer/components/shared/http-colors.ts +61 -0
  127. package/src/tools/dev/api/OpenapiViewer/components/shared/ui.tsx +4 -21
  128. package/src/tools/dev/code/MarkdownMessage/MarkdownMessage.tsx +150 -58
  129. package/src/tools/dev/code/MarkdownMessage/README.md +38 -0
  130. package/src/tools/dev/code/MarkdownMessage/builtinRules.tsx +92 -0
  131. package/src/tools/dev/code/MarkdownMessage/index.ts +3 -0
  132. package/src/tools/dev/code/MarkdownMessage/streamingBlocks.ts +164 -0
  133. package/src/tools/dev/code/MarkdownMessage/types.ts +20 -0
  134. package/src/tools/dev/code/PrettyCode/PrettyCode.client.tsx +27 -30
  135. package/src/tools/dev/code/PrettyCode/index.tsx +4 -1
  136. package/src/tools/dev/code/PrettyCode/lazy.tsx +3 -1
  137. package/src/tools/forms/MarkdownEditor/MarkdownEditor.tsx +168 -3
  138. package/src/tools/forms/MarkdownEditor/README.md +84 -0
  139. package/src/tools/forms/MarkdownEditor/chip/ChipNode.ts +454 -0
  140. package/src/tools/forms/MarkdownEditor/chip/syncChips.ts +80 -0
  141. package/src/tools/forms/MarkdownEditor/filePath/FilePathChip.tsx +68 -0
  142. package/src/tools/forms/MarkdownEditor/filePath/__tests__/detect.test.ts +161 -0
  143. package/src/tools/forms/MarkdownEditor/filePath/detect.ts +289 -0
  144. package/src/tools/forms/MarkdownEditor/filePath/index.ts +15 -0
  145. package/src/tools/forms/MarkdownEditor/index.ts +19 -0
  146. package/src/tools/forms/MarkdownEditor/lazy.tsx +10 -0
  147. package/src/tools/forms/MarkdownEditor/styles.css +134 -2
  148. package/src/tools/forms/MarkdownEditor/url/__tests__/detect.test.ts +121 -0
  149. package/src/tools/forms/MarkdownEditor/url/detect.ts +227 -0
  150. package/src/tools/forms/NotionEditor/BookmarkNode.ts +158 -0
  151. package/src/tools/forms/NotionEditor/BookmarkView.tsx +45 -0
  152. package/src/tools/forms/NotionEditor/MapEditLayer.tsx +199 -0
  153. package/src/tools/forms/NotionEditor/MapNode.ts +218 -0
  154. package/src/tools/forms/NotionEditor/MapView.tsx +103 -0
  155. package/src/tools/forms/NotionEditor/NotionEditor.tsx +7 -2
  156. package/src/tools/forms/NotionEditor/README.md +109 -188
  157. package/src/tools/forms/NotionEditor/extensions.ts +15 -0
  158. package/src/tools/forms/NotionEditor/slashItems.ts +19 -0
  159. package/src/tools/forms/NotionEditor/styles.css +25 -0
  160. package/src/tools/forms/NotionEditor/types.ts +9 -0
  161. package/src/tools/input/SpeechRecognition/core/engine/external.ts +5 -0
  162. package/src/tools/input/SpeechRecognition/types.ts +10 -0
  163. package/src/tools/media/Gallery/components/lightbox/GalleryLightbox.tsx +39 -22
  164. package/src/tools/media/ImageViewer/README.md +9 -0
  165. package/src/tools/media/ImageViewer/components/ImageViewer.tsx +165 -79
  166. package/src/tools/media/ImageViewer/components/LightboxChrome.tsx +137 -0
  167. package/src/tools/media/ImageViewer/components/MiddleEllipsis.tsx +46 -0
  168. package/src/tools/media/ImageViewer/components/index.ts +2 -0
  169. package/src/tools/media/ImageViewer/hooks/index.ts +3 -0
  170. package/src/tools/media/ImageViewer/hooks/useIdleChrome.ts +73 -0
  171. package/src/tools/media/ImageViewer/types.ts +10 -0
  172. package/src/tools/media/ImageViewer/utils/constants.ts +28 -0
  173. package/src/tools/media/ImageViewer/utils/index.ts +4 -0
  174. package/src/tools/media/VideoPlayer/VideoPlayer.tsx +34 -40
  175. package/src/tools/media/VideoPlayer/canvas/youtube-canvas.tsx +13 -13
  176. package/src/tools/media/VideoPlayer/parts/center-play.tsx +26 -0
  177. package/src/tools/media/VideoPlayer/parts/index.ts +2 -0
  178. package/src/tools/media/VideoPlayer/styles/video-player.css +102 -53
  179. package/src/tools/visual/charts/SmoothLine/README.md +97 -0
  180. package/src/tools/visual/charts/SmoothLine/SmoothLine.tsx +177 -0
  181. package/src/tools/visual/charts/SmoothLine/index.ts +21 -0
  182. package/src/tools/visual/charts/SmoothLine/lazy.tsx +23 -0
  183. package/src/tools/visual/charts/SmoothLine/smoothPath.ts +209 -0
  184. package/src/tools/visual/charts/SmoothLine/types.ts +98 -0
  185. package/src/tools/visual/charts/SmoothLine/useSmoothSeries.ts +429 -0
  186. package/src/tools/visual/indicators/Fps/types.ts +1 -1
  187. package/src/tools/chat/messages/blocks/renderers/LottieBlock.tsx +0 -11
  188. package/src/tools/dev/Map/components/MapContainer.tsx +0 -198
@@ -0,0 +1,105 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * `<UrlChip>` — a compact, clickable "URL chip" (Chrome-omnibox style):
5
+ * a favicon + the domain with a middle-ellipsised path
6
+ * (`github.com/wailsapp/…/README.md`), monospace, with the full href on
7
+ * hover. Opens in a new tab.
8
+ *
9
+ * This is the SURFACE-AGNOSTIC, React counterpart of the TipTap composer's
10
+ * `editorChip` (`kind: 'url'`, see `forms/MarkdownEditor/chip/ChipNode.ts`).
11
+ * It matches that chip's look pixel-for-pixel using semantic tokens, so a
12
+ * URL reads identically whether it sits in the composer (being typed) or in
13
+ * a rendered chat bubble (`MarkdownMessage`'s `urlChipRule`).
14
+ *
15
+ * Presentational + self-contained: it imports only the pure URL detector
16
+ * helpers (`url/detect`) — no TipTap / ProseMirror — so it can render a
17
+ * known URL anywhere (chat messages, link lists, etc).
18
+ *
19
+ * Favicon degradation: the favicon `<img>` falls back to a lucide `Globe`
20
+ * glyph on `onError` (offline / blocked / unknown host) so the chip never
21
+ * shows a broken-image marker — mirroring the composer chip's behaviour.
22
+ */
23
+
24
+ import { useState } from 'react';
25
+ import { Globe } from 'lucide-react';
26
+
27
+ import { cn } from '@djangocfg/ui-core/lib';
28
+ import {
29
+ splitUrl,
30
+ truncateUrlLabel,
31
+ faviconUrl,
32
+ } from '../../tools/forms/MarkdownEditor/url/detect';
33
+
34
+ export interface UrlChipProps {
35
+ /** The full, navigable href (`https://…`). */
36
+ href: string;
37
+ /**
38
+ * Approximate max characters for the truncated label. The domain is
39
+ * always preserved; only the path's middle collapses. Default 40.
40
+ */
41
+ maxChars?: number;
42
+ className?: string;
43
+ }
44
+
45
+ /**
46
+ * Render a URL as an inline, clickable chip. Favicon → globe glyph on
47
+ * favicon load error.
48
+ */
49
+ export function UrlChip({ href, maxChars = 40, className }: UrlChipProps) {
50
+ const { host } = splitUrl(href);
51
+ const favicon = faviconUrl(host);
52
+ const label = truncateUrlLabel(href, { maxChars });
53
+
54
+ // Favicon load failure → swap to the globe glyph. Also covers the empty-
55
+ // favicon case (unparseable host → faviconUrl returns '').
56
+ const [imgFailed, setImgFailed] = useState(false);
57
+ const showGlobe = imgFailed || !favicon;
58
+
59
+ return (
60
+ <a
61
+ href={href}
62
+ target="_blank"
63
+ rel="noopener noreferrer"
64
+ title={href}
65
+ className={cn(
66
+ // Inline pill matching the composer's `editor-chip--url`: muted
67
+ // surface + border, monospace, semantic tokens only. `max-w` +
68
+ // `truncate` (on the label) is the CSS safety net under the
69
+ // precomputed middle-ellipsis label. No raw blue link styling —
70
+ // the primary accent only lights up the border on hover + the
71
+ // globe glyph.
72
+ 'inline-flex max-w-[22rem] items-center gap-1 rounded align-baseline',
73
+ 'border border-border bg-muted px-1.5 py-0.5',
74
+ 'font-mono text-[0.8125em] leading-tight text-foreground no-underline',
75
+ 'cursor-pointer transition-colors hover:border-primary',
76
+ 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
77
+ className,
78
+ )}
79
+ >
80
+ <span
81
+ className={cn(
82
+ 'flex size-3.5 shrink-0 items-center justify-center',
83
+ showGlobe && 'text-primary',
84
+ )}
85
+ aria-hidden="true"
86
+ >
87
+ {showGlobe ? (
88
+ <Globe className="size-3.5" />
89
+ ) : (
90
+ <img
91
+ src={favicon}
92
+ alt=""
93
+ width={14}
94
+ height={14}
95
+ className="size-3.5 rounded-[2px]"
96
+ onError={() => setImgFailed(true)}
97
+ />
98
+ )}
99
+ </span>
100
+ <span className="truncate">{label}</span>
101
+ </a>
102
+ );
103
+ }
104
+
105
+ export default UrlChip;
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Surface-agnostic presentational "chips" — compact inline pills that
3
+ * render a URL or a file path with a favicon/icon + middle-ellipsis label.
4
+ *
5
+ * These match the TipTap composer's `editorChip` look (see
6
+ * `forms/MarkdownEditor/chip/ChipNode.ts`) but are plain React, so the same
7
+ * visual is reusable across every surface (composer, chat bubble, lists).
8
+ *
9
+ * - `UrlChip` — clickable URL chip (favicon → globe fallback).
10
+ * - `FilePathChip` — local file-path chip (re-exported from the editor's
11
+ * standalone path chip; it's already presentational/dependency-light, so
12
+ * we wrap rather than duplicate).
13
+ */
14
+ export { UrlChip, default as UrlChipDefault } from './UrlChip';
15
+ export type { UrlChipProps } from './UrlChip';
16
+
17
+ export {
18
+ FilePathChip,
19
+ type FilePathChipProps,
20
+ } from '../../tools/forms/MarkdownEditor/filePath/FilePathChip';
@@ -0,0 +1,178 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * LinkPreviewCard — a presentational URL-unfurl card (favicon + og:image +
5
+ * title / description / site name), Notion / Telegram style.
6
+ *
7
+ * It NEVER fetches the page itself — the browser can't read cross-origin
8
+ * meta tags (CORS). It either renders pre-resolved `data`, or calls the
9
+ * host-provided `resolver(url)` (a Wails/Go method, a Next.js API route)
10
+ * once on mount, showing a skeleton until it resolves. A `null` result
11
+ * (or a thrown resolver) renders nothing.
12
+ */
13
+
14
+ import { useEffect, useRef, useState } from 'react';
15
+
16
+ import { cn } from '@djangocfg/ui-core/lib';
17
+
18
+ import type { LinkPreviewData, ResolveLinkPreview } from './types';
19
+
20
+ export interface LinkPreviewCardProps {
21
+ url: string;
22
+ /** Pre-resolved metadata. When present, no resolver call is made. */
23
+ data?: LinkPreviewData;
24
+ /** Host resolver used when `data` is absent. */
25
+ resolver?: ResolveLinkPreview;
26
+ /** Layout — `compact` (default) is a tight horizontal card; `full` is a
27
+ * roomier card with a larger cover image. */
28
+ appearance?: 'compact' | 'full';
29
+ className?: string;
30
+ }
31
+
32
+ /** Hostname for the fallback site label / favicon. */
33
+ function hostnameOf(url: string): string {
34
+ try {
35
+ return new URL(url).hostname.replace(/^www\./, '');
36
+ } catch {
37
+ return url;
38
+ }
39
+ }
40
+
41
+ type LoadState =
42
+ | { status: 'ready'; data: LinkPreviewData }
43
+ | { status: 'loading' }
44
+ | { status: 'empty' };
45
+
46
+ export function LinkPreviewCard({
47
+ url,
48
+ data,
49
+ resolver,
50
+ appearance = 'compact',
51
+ className,
52
+ }: LinkPreviewCardProps) {
53
+ // If data is supplied we're immediately ready; else loading (resolver) or
54
+ // empty (nothing to do).
55
+ const [state, setState] = useState<LoadState>(() =>
56
+ data ? { status: 'ready', data } : resolver ? { status: 'loading' } : { status: 'empty' },
57
+ );
58
+
59
+ // Keep the latest resolver without re-running the effect on identity churn.
60
+ const resolverRef = useRef(resolver);
61
+ resolverRef.current = resolver;
62
+
63
+ useEffect(() => {
64
+ if (data) {
65
+ setState({ status: 'ready', data });
66
+ return;
67
+ }
68
+ const resolve = resolverRef.current;
69
+ if (!resolve) {
70
+ setState({ status: 'empty' });
71
+ return;
72
+ }
73
+
74
+ let alive = true;
75
+ const controller = new AbortController();
76
+ setState({ status: 'loading' });
77
+
78
+ resolve(url, controller.signal)
79
+ .then((result) => {
80
+ if (!alive) return;
81
+ setState(result ? { status: 'ready', data: result } : { status: 'empty' });
82
+ })
83
+ .catch(() => {
84
+ if (alive) setState({ status: 'empty' });
85
+ });
86
+
87
+ return () => {
88
+ alive = false;
89
+ controller.abort();
90
+ };
91
+ }, [url, data]);
92
+
93
+ if (state.status === 'empty') return null;
94
+
95
+ const host = hostnameOf(url);
96
+
97
+ // Shared frame — matches the calm media-card surface used elsewhere.
98
+ const frame = cn(
99
+ 'block w-full overflow-hidden rounded-2xl border border-border bg-card shadow-sm',
100
+ 'transition-colors hover:bg-accent/40',
101
+ 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
102
+ className,
103
+ );
104
+
105
+ if (state.status === 'loading') {
106
+ return (
107
+ <div className={cn(frame, 'pointer-events-none')} aria-busy="true">
108
+ <div className="flex items-center gap-3 p-3">
109
+ <div className="size-9 shrink-0 animate-pulse rounded-md bg-muted" />
110
+ <div className="min-w-0 flex-1 space-y-2">
111
+ <div className="h-3 w-2/3 animate-pulse rounded bg-muted" />
112
+ <div className="h-2.5 w-1/3 animate-pulse rounded bg-muted" />
113
+ </div>
114
+ </div>
115
+ </div>
116
+ );
117
+ }
118
+
119
+ const meta = state.data;
120
+ const title = meta.title || host;
121
+ const siteName = meta.siteName || host;
122
+ const isFull = appearance === 'full';
123
+
124
+ return (
125
+ <a href={url} target="_blank" rel="noopener noreferrer" className={frame}>
126
+ {/* Cover image (full appearance, when present) — top banner. */}
127
+ {isFull && meta.image ? (
128
+ // eslint-disable-next-line @next/next/no-img-element
129
+ <img
130
+ src={meta.image}
131
+ alt=""
132
+ loading="lazy"
133
+ // Fixed banner height (not just max-h) so `object-cover` actually
134
+ // crops to fill — with only `max-h` the image keeps its aspect and
135
+ // letterboxes. `h-44` is a sensible cover banner.
136
+ className="h-44 w-full border-b border-border object-cover"
137
+ />
138
+ ) : null}
139
+
140
+ <div className="flex items-stretch gap-3 p-3">
141
+ <div className="min-w-0 flex-1">
142
+ {/* Site row — favicon + site name. */}
143
+ <div className="mb-1 flex items-center gap-1.5 text-xs text-muted-foreground">
144
+ {meta.favicon ? (
145
+ // eslint-disable-next-line @next/next/no-img-element
146
+ <img
147
+ src={meta.favicon}
148
+ alt=""
149
+ loading="lazy"
150
+ className="size-4 shrink-0 rounded-sm"
151
+ />
152
+ ) : null}
153
+ <span className="truncate">{siteName}</span>
154
+ </div>
155
+
156
+ <div className="line-clamp-2 text-sm font-medium text-foreground">{title}</div>
157
+
158
+ {meta.description ? (
159
+ <div className="mt-1 line-clamp-2 text-xs text-muted-foreground">
160
+ {meta.description}
161
+ </div>
162
+ ) : null}
163
+ </div>
164
+
165
+ {/* Compact thumbnail (compact appearance, when present) — side. */}
166
+ {!isFull && meta.image ? (
167
+ // eslint-disable-next-line @next/next/no-img-element
168
+ <img
169
+ src={meta.image}
170
+ alt=""
171
+ loading="lazy"
172
+ className="size-16 shrink-0 self-center rounded-lg object-cover"
173
+ />
174
+ ) : null}
175
+ </div>
176
+ </a>
177
+ );
178
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Surface-agnostic link-preview (URL unfurl) primitives.
3
+ *
4
+ * `LinkPreviewCard` is a pure presentational card (favicon + og:image +
5
+ * title / description / site name) that NEVER fetches the page — it renders
6
+ * pre-resolved `data` or calls a host-provided `resolver(url)`. It is shared
7
+ * across every surface: the chat `LinkBlock` and the NotionEditor `bookmark`
8
+ * node both import it and supply their own resolver. No chat coupling.
9
+ */
10
+
11
+ export { LinkPreviewCard, type LinkPreviewCardProps } from './LinkPreviewCard';
12
+ export type { LinkPreviewData, ResolveLinkPreview } from './types';
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Link-preview (URL unfurl) types — the data contract between the host's
3
+ * metadata fetcher and the presentational card.
4
+ *
5
+ * The browser CANNOT fetch arbitrary URLs to scrape OpenGraph/meta tags
6
+ * (CORS blocks cross-origin HTML reads), so resolution is ALWAYS a host
7
+ * concern: a server route (Next.js) or a native backend (Wails/Go). The
8
+ * UI never fetches — it receives already-resolved metadata, or a resolver
9
+ * callback the host implements.
10
+ */
11
+
12
+ /**
13
+ * Resolved metadata for one URL — the unfurl result. All fields except
14
+ * `url` are optional: a card renders from whatever subset is available
15
+ * (favicon + title is enough; a bare `url` falls back to the hostname).
16
+ * Plain JSON so it survives history persistence + SSE transport.
17
+ */
18
+ export interface LinkPreviewData {
19
+ /** The canonical/resolved URL the card links to. */
20
+ url: string;
21
+ /** og:title → twitter:title → <title>. */
22
+ title?: string;
23
+ /** og:description → twitter:description → <meta name=description>. */
24
+ description?: string;
25
+ /** og:image → twitter:image (absolute URL; host resolves relative ones). */
26
+ image?: string;
27
+ /** Favicon URL (host resolves <link rel=icon> / /favicon.ico). */
28
+ favicon?: string;
29
+ /** og:site_name → hostname. */
30
+ siteName?: string;
31
+ }
32
+
33
+ /**
34
+ * Host-provided resolver. Given a URL, returns its preview metadata, or
35
+ * `null` when nothing useful could be fetched (so the card is skipped).
36
+ * Implementations:
37
+ * - Wails/Go desktop → a bound `FetchLinkMetadata(url)` service method.
38
+ * - Next.js web → a `/api/unfurl?url=` route.
39
+ * The host owns caching, SSRF protection, timeouts and bot User-Agents.
40
+ */
41
+ export type ResolveLinkPreview = (
42
+ url: string,
43
+ signal?: AbortSignal,
44
+ ) => Promise<LinkPreviewData | null>;
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Shared TipTap helpers — reusable across every NotionEditor block NodeView.
3
+ *
4
+ * `useNodeAttrs` is the write-back seam an interactive NodeView uses to push UI
5
+ * changes (drag a pin, switch a basemap) back into the document's node attrs,
6
+ * so the change survives the markdown round-trip.
7
+ */
8
+
9
+ export { useNodeAttrs } from './useNodeAttrs';
10
+ export type { UseNodeAttrsResult } from './useNodeAttrs';
@@ -0,0 +1,128 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * `useNodeAttrs` — the single write-back seam every interactive block NodeView
5
+ * uses to persist UI changes back into the document (→ TipTap node attrs →
6
+ * markdown round-trip).
7
+ *
8
+ * A NodeView is normally READ-ONLY: it renders `node.attrs` but has no path to
9
+ * push a user action (drag a pin, toggle a basemap) back into the node. This
10
+ * hook wraps TipTap's `updateAttributes` so an interactive component can do:
11
+ *
12
+ * const { attrs, patch } = useNodeAttrs<MapBlockPayload>(props);
13
+ * // …on drag-end:
14
+ * patch({ markers: nextMarkers });
15
+ *
16
+ * and the new attrs land in the document — `getMarkdown()` then serialises the
17
+ * updated payload.
18
+ *
19
+ * ## API
20
+ *
21
+ * - `attrs` — `props.node.attrs` typed as `T`. The current committed payload.
22
+ * - `patch(next)` — commit a partial attr update IMMEDIATELY. `updateAttributes`
23
+ * is a single ProseMirror transaction, so this is cheap; use it for DISCRETE
24
+ * edits (drag-END, add/remove pin, basemap pick) — which is the recommended
25
+ * design: the caller fires `patch` only when a value is final, never per
26
+ * drag-frame, so no transaction spam.
27
+ * - `patchDebounced(next, ms?)` — coalesce HIGH-FREQUENCY updates (a live edit
28
+ * firing many times: text input, a continuous slider). Rapid calls collapse
29
+ * into one trailing transaction after `ms` of quiet (default 250ms). Pending
30
+ * patches are MERGED so partial fields accumulate. Auto-flushes on unmount so
31
+ * the last edit is never lost.
32
+ * - `flush()` — commit any pending debounced patch immediately (e.g. on blur).
33
+ *
34
+ * Pure + reusable: no editor-specific coupling, only the `NodeViewProps`
35
+ * contract (`node`, `updateAttributes`). Node attrs stay plain-JSON
36
+ * serializable — pass only JSON-safe values through `patch`.
37
+ */
38
+
39
+ import { useCallback, useEffect, useMemo, useRef } from 'react';
40
+ import type { NodeViewProps } from '@tiptap/react';
41
+
42
+ /** Default debounce window (ms) for `patchDebounced`. */
43
+ const DEFAULT_DEBOUNCE_MS = 250;
44
+
45
+ export interface UseNodeAttrsResult<T extends Record<string, unknown>> {
46
+ /** The node's current committed attrs, typed as `T`. */
47
+ attrs: T;
48
+ /** Commit a partial attr update immediately (one transaction). */
49
+ patch: (next: Partial<T>) => void;
50
+ /**
51
+ * Commit a partial attr update on a trailing debounce; rapid calls merge
52
+ * into a single transaction. For live/high-frequency edits.
53
+ */
54
+ patchDebounced: (next: Partial<T>, ms?: number) => void;
55
+ /** Flush any pending debounced patch now (e.g. on blur). */
56
+ flush: () => void;
57
+ }
58
+
59
+ /**
60
+ * Two-way binding between a NodeView's UI and its node attrs.
61
+ *
62
+ * @param props The `NodeViewProps` TipTap hands the React NodeView component.
63
+ */
64
+ export function useNodeAttrs<T extends Record<string, unknown>>(
65
+ props: NodeViewProps,
66
+ ): UseNodeAttrsResult<T> {
67
+ const { node, updateAttributes } = props;
68
+ const attrs = node.attrs as T;
69
+
70
+ // Keep `updateAttributes` reachable from the debounce timer without making
71
+ // the debounced callback re-create on every render (it can change identity).
72
+ const updateRef = useRef(updateAttributes);
73
+ updateRef.current = updateAttributes;
74
+
75
+ // Pending merged patch + its timer, held across renders.
76
+ const pendingRef = useRef<Partial<T> | null>(null);
77
+ const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
78
+
79
+ const flush = useCallback(() => {
80
+ if (timerRef.current != null) {
81
+ clearTimeout(timerRef.current);
82
+ timerRef.current = null;
83
+ }
84
+ const pending = pendingRef.current;
85
+ if (pending) {
86
+ pendingRef.current = null;
87
+ updateRef.current(pending);
88
+ }
89
+ }, []);
90
+
91
+ const patch = useCallback(
92
+ (next: Partial<T>) => {
93
+ // A discrete edit supersedes any queued debounced patch: merge the
94
+ // pending fields in, then commit the lot as one transaction.
95
+ const pending = pendingRef.current;
96
+ if (timerRef.current != null) {
97
+ clearTimeout(timerRef.current);
98
+ timerRef.current = null;
99
+ }
100
+ pendingRef.current = null;
101
+ updateRef.current(pending ? { ...pending, ...next } : next);
102
+ },
103
+ [],
104
+ );
105
+
106
+ const patchDebounced = useCallback(
107
+ (next: Partial<T>, ms: number = DEFAULT_DEBOUNCE_MS) => {
108
+ // Merge so partial fields accumulate across rapid calls.
109
+ pendingRef.current = { ...(pendingRef.current ?? {}), ...next };
110
+ if (timerRef.current != null) clearTimeout(timerRef.current);
111
+ timerRef.current = setTimeout(() => {
112
+ timerRef.current = null;
113
+ const pending = pendingRef.current;
114
+ pendingRef.current = null;
115
+ if (pending) updateRef.current(pending);
116
+ }, ms);
117
+ },
118
+ [],
119
+ );
120
+
121
+ // Never drop the last edit: flush on unmount.
122
+ useEffect(() => () => flush(), [flush]);
123
+
124
+ return useMemo(
125
+ () => ({ attrs, patch, patchDebounced, flush }),
126
+ [attrs, patch, patchDebounced, flush],
127
+ );
128
+ }
@@ -35,7 +35,7 @@ export function PageSnapshotChip({ className }: PageSnapshotChipProps) {
35
35
  className={cn(
36
36
  'inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-xs',
37
37
  isStale
38
- ? 'border-amber-500/40 text-amber-600 dark:text-amber-400'
38
+ ? 'border-warning-border/40 text-warning'
39
39
  : 'border-border text-muted-foreground',
40
40
  className,
41
41
  )}
@@ -48,7 +48,10 @@ export function MyChat() {
48
48
  - **Facebook-style unread notifier.** `useChatUnreadNotifier()` prefixes `document.title` with `(N)` and paints a small red dot over the favicon while the tab is in background. Sober by default — the favicon is the attention signal, the title stays readable. Title rotation is available as opt-in (`title: { mode: 'rotate' }`) for hosts without a favicon. Cross-tab coordinated via `useActiveTab` from `@djangocfg/ui-core`: only the elected leader tab mutates title/favicon, the count is broadcast so every tab's FAB badge stays in sync. Pluggable `ChatNotifier` interface lets Wails / Electron hosts route to a native dock badge instead.
49
49
  - **Personas.** `config.user` + `config.assistant` for default identity; `message.sender` for per-message overrides.
50
50
  - **Audio.** Pass `audio: ChatAudioConfig` (sounds map) to `<ChatLauncher>` — the hook runs internally. Built on `@djangocfg/ui-core/hooks/useNotificationSounds`: Safari unlock, persisted mute, per-event toggles, reduced-motion respect. Slack/Linear-style per-event volume scale (error ≈ 0.25, mention ≈ 1.0). **Built-in sounds bundled as base64 data URLs** in the lazy chat chunk (~136KB) — `audio={{}}` just works. `silenced` + `onSoundEvent` for native hosts (cmdop_go / Tauri).
51
- - **Rich attachments.** `AttachmentsGrid` for thumbnails, `AttachmentsList` for custom renderers; `onAttachmentOpen` for lightbox.
51
+ - **Rich attachments.** `AttachmentsGrid` for thumbnails, `AttachmentsList` for custom renderers; `onAttachmentOpen` for lightbox. Image tiles open a built-in fullscreen `ImageViewer` lightbox (with prev/next across the message's images) **by default** — no host wiring — and a host `onAttachmentOpen` still overrides.
52
+ - **URL chips in bubbles.** `config.linkChips` (off by default) renders bare `http(s)` URLs in the message markdown bubble as compact favicon chips (`github.com/…/README.md`), matching the composer's URL chip. `[labeled](url)` links stay normal styled links; `www.` hosts are promoted and chipped.
53
+ - **Unified attach pipeline.** Paperclip, `+` menu, drag-drop and ⌘V/Ctrl+V all funnel through one validated path (`useComposerAttach`); set `showAttachmentButton` / `attach` and the composer ships its own picker. Optional `uploadFn` is the only web/Wails seam.
54
+ - **Paste-as-chunk.** A long plain-text paste (over `attach.pasteTextThreshold`, default 2000 chars — ChatGPT/Claude behaviour) becomes a `type:'text'` "Pasted text" attachment chip instead of flooding the textarea. The chip opens a read-only `PastedTextDialog` preview (rendered via `MarkdownMessage`); the exact payload rides in `ChatAttachment.text` to `onSubmit(content, attachments)`. Toggle with `attach.pasteTextAsChunk`.
52
55
  - **Tool-payload dispatcher.** `dispatchToolPayload(matchers, fallback)` — pluggable predicates render `<LazyJsonTree>` / `<LazyMap>` / etc.
53
56
  - **Persisted dock prefs.** `headerSlots.modeToggle.persistAs: 'my.key'` stores `mode` / `side` / `width` in localStorage; the toggle lets users flip popover ↔ side and survives reloads. (`useChatDockPrefs()` is now owned by the launcher internally — no longer a consumer-facing hook.)
54
57
  - **UX guards.** Auto-focus composer on dock open, two-step Escape (textarea blur → close), click-to-focus on empty message area **and** on the composer surface padding (Slack / Linear / ChatGPT style), `useHotkey('mod+/')` toggle.
@@ -535,12 +538,36 @@ function MyError() {
535
538
  }
536
539
  ```
537
540
 
541
+ ## URL chips in bubbles — `config.linkChips`
542
+
543
+ Off by default. When `config.linkChips` is `true`, bare web URLs inside a
544
+ message's **markdown bubble** render as compact `<UrlChip>`s (favicon + domain
545
+ + middle-ellipsis path, e.g. `github.com/wailsapp/…/README.md`) instead of a
546
+ plain blue link — matching the chip the composer shows while you type a URL.
547
+
548
+ ```tsx
549
+ <ChatRoot transport={transport} config={{ linkChips: true }} />
550
+ ```
551
+
552
+ It plugs in via `MessageBubble`: when on, it passes `linkRules={[urlChipRule]}`
553
+ to `MarkdownMessage` (`urlChipRule` is the built-in `LinkRule` from
554
+ `dev/code/MarkdownMessage`). The rule is appended **after** any host-supplied
555
+ `linkRules`, so host rules still win. Scope and behaviour:
556
+
557
+ - Only **bare** `http(s)` autolinks are chipped (link text === href).
558
+ - `[labeled](url)` links keep their author-chosen text as a normal styled link.
559
+ - `www.` hosts are promoted to `https://www.…` (so GFM autolinks them) and
560
+ then chipped — matching the composer's autolink whitelist.
561
+
562
+ The chip is the same surface-agnostic `UrlChip` the composer's chip mirrors —
563
+ see [`common/chips`](../../common/chips/README.md).
564
+
538
565
  ## Message blocks
539
566
 
540
567
  A message can carry typed, serializable rich content beyond its markdown
541
568
  `content`: an optional `blocks: MessageBlock[]`. `MessageBlock` is a
542
569
  discriminated union on `kind` — `text`, `markdown`, `audio`, `video`,
543
- `image`, `gallery`, `map`, `json`, `mermaid`, `code`, `lottie`, plus a
570
+ `image`, `gallery`, `map`, `json`, `mermaid`, `code`, plus a
544
571
  `custom` escape hatch. Payloads are plain JSON, so blocks survive history
545
572
  persistence and SSE transport.
546
573
 
@@ -587,6 +614,55 @@ Exports (`@djangocfg/ui-tools/chat`): `MessageBlocks`,
587
614
  `createBlockRegistry`, `BUILTIN_BLOCK_REGISTRY`, and the `MessageBlock` /
588
615
  `BlockRegistry` / `BlockRenderer` / `BlockRenderContext` types.
589
616
 
617
+ ### The `map` block
618
+
619
+ `MapBlock` wraps the lazy MapLibre renderer. Beyond `center` / `zoom` /
620
+ `routes` / `polygons`, all of its tuning is plain JSON so a map survives
621
+ history persistence and SSE transport:
622
+
623
+ - **`basemap?: string`** — a built-in style key (`'light'` / `'dark'` /
624
+ `'streets'` · `'liberty'` / `'bright'` / `'positron'`) or a full
625
+ style-JSON URL. Omit for the default basemap.
626
+ - **`terrain?: boolean`** — 3D terrain + hillshade (free AWS Terrarium DEM,
627
+ no key).
628
+ - **`userLocation?: boolean`** — opt in to the toolbar "locate-me" chip
629
+ (default off). A plain boolean, **not** coordinates — the browser supplies
630
+ the live position only after the user grants permission; nothing is baked
631
+ into the block. Needs a secure context (HTTPS / localhost).
632
+ - **per-marker `icon?` / `color?`** — `icon` is an image URL rendered as a
633
+ rounded thumbnail pin; `color` is a CSS color for the default pin (ignored
634
+ when `icon` is set).
635
+ - **per-marker `card?`** — an info-card popup opened above the pin on click
636
+ (Google-Maps style), rendered by the map's default `MarkerCard`. Pure
637
+ JSON — strings/URLs only:
638
+
639
+ ```ts
640
+ card?: {
641
+ title: string;
642
+ description?: string;
643
+ image?: string;
644
+ badge?: string;
645
+ actions?: Array<{ label: string; href?: string }>; // href-only links
646
+ }
647
+ ```
648
+
649
+ `actions` are `href`-only links (no `onClick` / `icon` — those can't cross
650
+ the wire). When a marker has no `card`, clicking it does nothing extra.
651
+
652
+ ```ts
653
+ const map: MapBlock = {
654
+ kind: 'map', id: 'b3', center: { lat: 52.52, lng: 13.405 }, zoom: 12,
655
+ basemap: 'dark', terrain: true, userLocation: true,
656
+ markers: [{
657
+ id: 'm1', lat: 52.52, lng: 13.405, color: '#22c55e',
658
+ card: {
659
+ title: 'Berlin office', description: 'Mitte', badge: 'HQ',
660
+ actions: [{ label: 'Directions', href: 'https://maps.example/berlin' }],
661
+ },
662
+ }],
663
+ };
664
+ ```
665
+
590
666
  ## Transport contract
591
667
 
592
668
  A single I/O seam.
@@ -690,6 +766,18 @@ function — there is no separate `renderHeader` / `renderEmpty` prop and no
690
766
  `<Composer>`; it receives `{ composer, config }` — the live composer hook
691
767
  plus the same config object. See **[Slots inventory](#slots-inventory)** below.
692
768
 
769
+ ### Image attachments — default lightbox
770
+
771
+ When you **don't** wire `messages.onAttachmentOpen` (or a tile's `onClick`),
772
+ image attachments are still clickable: each `AttachmentsGrid` / `AttachmentsList`
773
+ mounts an `ImageLightboxProvider` that renders a single shared `ImageViewer`
774
+ (`LazyImageViewer`) dialog for the group. Clicking an image opens a fullscreen
775
+ gallery with prev/next across **all** the image attachments of that message,
776
+ starting at the clicked index (non-image files are skipped). A host-supplied
777
+ `onAttachmentOpen` / `onClick` always wins (see `useImageTileClick` in
778
+ `messages/Attachments.tsx`), so existing lightbox wiring is unaffected. See the
779
+ [`ImageViewer` docs](../media/ImageViewer/README.md).
780
+
693
781
  ## Composer slot system
694
782
 
695
783
  `<Composer>` is a single bordered input surface — textarea on top, an
@@ -1023,6 +1111,14 @@ type ComposerModelPickerProps, type ComposerModelOption,
1023
1111
  type ComposerBannerProps, type ComposerBannerAction, type ComposerRichTextareaProps,
1024
1112
  type ComposerTextareaProps, type SendButtonProps, type AttachButtonProps
1025
1113
 
1114
+ // Attach pipeline — picker + drag-drop + ⌘V (files) + paste-as-chunk (text)
1115
+ useComposerAttach, useComposerAttachContext,
1116
+ fileToAttachment, revokeAttachmentUrl,
1117
+ textToAttachment, deriveTextTitle, DEFAULT_PASTE_TEXT_THRESHOLD,
1118
+ PastedTextDialog,
1119
+ type ComposerAttachConfig, type ComposerAttachHandle, type ComposerAcceptType,
1120
+ type ComposerUploadFn, type ComposerUploadResult, type PastedTextDialogProps
1121
+
1026
1122
  // Message blocks
1027
1123
  MessageBlocks, createBlockRegistry, BUILTIN_BLOCK_REGISTRY
1028
1124
  type MessageBlock, type BlockAppearance, type BlockRegistry,
@@ -1095,3 +1191,14 @@ Recommended pairing for a "vehicle cards" / "tax breakdown" / "chart" widget on
1095
1191
  - `Shift+Enter` — newline.
1096
1192
  - `Esc` — cancel current streaming turn (when focused inside the composer).
1097
1193
  - Launcher hotkey: configurable via `<ChatLauncher hotkey={…}>` (e.g. `⌘/`).
1194
+
1195
+ ## Related — conversation-history sidebar
1196
+
1197
+ `ChatRoot` renders a single conversation; the **list of past sessions**
1198
+ (the left sidebar in desktop chat apps) is app-owned, not part of this
1199
+ tool. The recommended way to render it is the **`Tree`** tool with
1200
+ `appearance={{ variant: 'list' }}` — a macOS-sidebar look (no file icons,
1201
+ auto-height rows for *title + meta*, quiet selection) with collapsible
1202
+ group branches (e.g. Today / Yesterday / … or by topic) and a built-in
1203
+ right-click menu (rename / delete). See `data/Tree/README.md` → *Variant —
1204
+ explorer vs list*. cmdop's `SessionsTree` is the reference consumer.