@adecore/agents-react 0.24.2 → 0.25.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 (103) hide show
  1. package/dist/chat/index.d.ts +4 -7
  2. package/dist/chat/index.js +4 -7
  3. package/dist/chat/logic/continue-after-stop.d.ts +3 -0
  4. package/dist/chat/logic/continue-after-stop.js +28 -0
  5. package/dist/chat/logic/thread-cards.d.ts +0 -1
  6. package/dist/chat/logic/thread-cards.js +0 -4
  7. package/dist/chat/preferences.d.ts +0 -1
  8. package/dist/chat/preferences.js +0 -3
  9. package/dist/chat/stash.d.ts +6 -0
  10. package/dist/chat/stash.js +13 -0
  11. package/dist/chat/ui/Composer.js +26 -5
  12. package/dist/chat/ui/Pickers.js +1 -1
  13. package/dist/chat/ui/composer/ComposerControls.d.ts +1 -0
  14. package/dist/chat/ui/composer/ComposerControls.js +3 -3
  15. package/dist/chat/ui/composer/use-composer-submit.d.ts +3 -1
  16. package/dist/chat/ui/composer/use-composer-submit.js +22 -4
  17. package/dist/chat/ui/composer/use-continuable-turn.d.ts +2 -0
  18. package/dist/chat/ui/composer/use-continuable-turn.js +12 -0
  19. package/dist/chat/ui/composer/use-notice.d.ts +3 -1
  20. package/dist/chat/ui/composer/use-notice.js +8 -6
  21. package/dist/chat/ui/index.d.ts +9 -28
  22. package/dist/chat/ui/index.js +8 -27
  23. package/dist/chat/ui/intelligent-ui/UiReply.js +20 -2
  24. package/dist/chat/ui/intelligent-ui/node-text.d.ts +3 -1
  25. package/dist/chat/ui/intelligent-ui/node-text.js +32 -0
  26. package/dist/chat/ui/intelligent-ui/registry.js +15 -5
  27. package/dist/chat/ui/intelligent-ui/render-context.d.ts +6 -0
  28. package/dist/chat/ui/intelligent-ui/renderers/base-land.d.ts +1 -0
  29. package/dist/chat/ui/intelligent-ui/renderers/base-land.js +6 -0
  30. package/dist/chat/ui/intelligent-ui/renderers/binding.d.ts +6 -0
  31. package/dist/chat/ui/intelligent-ui/renderers/binding.js +10 -0
  32. package/dist/chat/ui/intelligent-ui/renderers/content.d.ts +2 -0
  33. package/dist/chat/ui/intelligent-ui/renderers/content.js +68 -6
  34. package/dist/chat/ui/intelligent-ui/renderers/inputs.d.ts +2 -0
  35. package/dist/chat/ui/intelligent-ui/renderers/inputs.js +40 -13
  36. package/dist/chat/ui/intelligent-ui/renderers/map-slots.d.ts +20 -0
  37. package/dist/chat/ui/intelligent-ui/renderers/map-slots.js +87 -0
  38. package/dist/chat/ui/intelligent-ui/renderers/map-style.d.ts +18 -0
  39. package/dist/chat/ui/intelligent-ui/renderers/map-style.js +72 -0
  40. package/dist/chat/ui/intelligent-ui/renderers/map-view.d.ts +15 -0
  41. package/dist/chat/ui/intelligent-ui/renderers/map-view.js +213 -0
  42. package/dist/chat/ui/intelligent-ui/renderers/map.d.ts +3 -0
  43. package/dist/chat/ui/intelligent-ui/renderers/map.js +113 -0
  44. package/dist/chat/ui/intelligent-ui/renderers/place-point.d.ts +3 -0
  45. package/dist/chat/ui/intelligent-ui/renderers/place-point.js +13 -0
  46. package/dist/chat/ui/intelligent-ui/renderers/text.d.ts +2 -0
  47. package/dist/chat/ui/intelligent-ui/renderers/text.js +14 -1
  48. package/dist/chat/ui/intelligent-ui/use-in-view.d.ts +1 -0
  49. package/dist/chat/ui/intelligent-ui/use-in-view.js +17 -0
  50. package/dist/host.d.ts +16 -0
  51. package/dist/index.d.ts +2 -2
  52. package/dist/index.js +1 -1
  53. package/dist/locales/en/agent-chat.d.ts +47 -0
  54. package/dist/locales/en/agent-chat.js +48 -1
  55. package/dist/locales/nl/agent-chat.d.ts +47 -0
  56. package/dist/locales/nl/agent-chat.js +48 -1
  57. package/dist/prompts/index.d.ts +3 -5
  58. package/dist/prompts/index.js +3 -5
  59. package/dist/providers/index.d.ts +3 -9
  60. package/dist/providers/index.js +3 -9
  61. package/dist/state/index.d.ts +2 -2
  62. package/dist/state/index.js +2 -2
  63. package/dist/state/usage.d.ts +0 -1
  64. package/dist/state/usage.js +0 -3
  65. package/dist/theme.css +10 -0
  66. package/package.json +9 -8
  67. package/src/chat/index.ts +2 -23
  68. package/src/chat/logic/continue-after-stop.ts +32 -0
  69. package/src/chat/logic/thread-cards.ts +0 -5
  70. package/src/chat/preferences.ts +0 -4
  71. package/src/chat/stash.ts +17 -0
  72. package/src/chat/ui/Composer.tsx +33 -5
  73. package/src/chat/ui/Pickers.tsx +1 -0
  74. package/src/chat/ui/composer/ComposerControls.tsx +13 -3
  75. package/src/chat/ui/composer/use-composer-submit.ts +25 -5
  76. package/src/chat/ui/composer/use-continuable-turn.ts +14 -0
  77. package/src/chat/ui/composer/use-notice.ts +14 -7
  78. package/src/chat/ui/index.ts +9 -49
  79. package/src/chat/ui/intelligent-ui/UiReply.tsx +24 -1
  80. package/src/chat/ui/intelligent-ui/node-text.ts +34 -1
  81. package/src/chat/ui/intelligent-ui/registry.ts +33 -5
  82. package/src/chat/ui/intelligent-ui/render-context.ts +14 -0
  83. package/src/chat/ui/intelligent-ui/renderers/base-land.ts +7 -0
  84. package/src/chat/ui/intelligent-ui/renderers/binding.ts +14 -0
  85. package/src/chat/ui/intelligent-ui/renderers/content.tsx +144 -9
  86. package/src/chat/ui/intelligent-ui/renderers/inputs.tsx +79 -17
  87. package/src/chat/ui/intelligent-ui/renderers/map-slots.ts +105 -0
  88. package/src/chat/ui/intelligent-ui/renderers/map-style.ts +96 -0
  89. package/src/chat/ui/intelligent-ui/renderers/map-view.tsx +251 -0
  90. package/src/chat/ui/intelligent-ui/renderers/map.tsx +226 -0
  91. package/src/chat/ui/intelligent-ui/renderers/place-point.ts +16 -0
  92. package/src/chat/ui/intelligent-ui/renderers/text.tsx +28 -1
  93. package/src/chat/ui/intelligent-ui/use-in-view.ts +18 -0
  94. package/src/host.ts +28 -0
  95. package/src/index.ts +2 -12
  96. package/src/locales/en/agent-chat.ts +48 -1
  97. package/src/locales/nl/agent-chat.ts +48 -1
  98. package/src/prompts/index.ts +3 -18
  99. package/src/providers/index.ts +3 -9
  100. package/src/state/index.ts +1 -16
  101. package/src/state/usage.ts +0 -4
  102. package/src/stylesheets.d.ts +2 -0
  103. package/src/theme.css +10 -0
@@ -0,0 +1,251 @@
1
+ import 'maplibre-gl/dist/maplibre-gl.css';
2
+ import { useEffect, useRef, useState } from 'react';
3
+ import { Map as MapLibreMap, Marker, setWorkerUrl } from 'maplibre-gl';
4
+ import { Minus, Plus } from 'lucide-react';
5
+ import { useTranslation } from 'react-i18next';
6
+ import { ButtonGroup, IconButton } from '@adecore/ui';
7
+ import { useChatHost, type UiMapSource } from '../../../../host';
8
+ import { baseMapStyle, boundsOf, localizedTextField, MAP_MAX_ZOOM, type MapColors, type MapPoint } from './map-style';
9
+
10
+ /*
11
+ * What the view is drawing: `base` by the source's choice, `offline` and `unavailable` as a fallback from tiles that
12
+ * cannot be had. `failed` is a browser that cannot start the map, `lost` one that took its graphics context back.
13
+ */
14
+ export type MapViewStatus = 'loading' | 'tiles' | 'base' | 'offline' | 'unavailable' | 'failed' | 'lost';
15
+
16
+ export interface MapMarker extends MapPoint {
17
+ number: number;
18
+ }
19
+
20
+ export interface MapViewProps {
21
+ markers: readonly MapMarker[];
22
+ source: UiMapSource;
23
+ /* The id of the marker drawn as picked. */
24
+ selected: string | null;
25
+ /* Called with a marker's id when it is clicked; null where the places cannot be picked. */
26
+ onPick: ((id: string) => void) | null;
27
+ onStatus: (status: MapViewStatus) => void;
28
+ }
29
+
30
+ const MARKER = 'flex size-5 items-center justify-center rounded-full bg-map-marker text-xs text-map-marker-text tabular-nums shadow-raised';
31
+ const PICKED = 'z-1 bg-accent text-accent-text outline-2 outline-surface';
32
+
33
+ // A tile that fails this many times with none loading in between leaves the map empty, so the outline stands in.
34
+ const TILE_ERRORS_IN_A_ROW = 4;
35
+
36
+ let workerReady = false;
37
+
38
+ /* The worker is a file of the package, so the bundler of the app has to serve it: the default address beside the chunk would not exist. */
39
+ function ensureBundledWorker(): void {
40
+ if (!workerReady) {
41
+ setWorkerUrl(new URL('maplibre-gl/dist/maplibre-gl-worker.mjs', import.meta.url).href);
42
+ workerReady = true;
43
+ }
44
+ }
45
+
46
+ /* The grounds of the built-in map are theme tokens, which the page resolves; the style needs plain colors. */
47
+ function readColors(element: HTMLElement): MapColors {
48
+ const style = getComputedStyle(element);
49
+ return { land: style.getPropertyValue('--map-land').trim() || 'transparent', water: style.getPropertyValue('--map-water').trim() || 'transparent' };
50
+ }
51
+
52
+ /*
53
+ * The map itself, in the chunk that holds MapLibre. It draws the host's tiles when the source has them
54
+ * and the outline of the world otherwise, also when the tiles turn out to be out of reach. Its
55
+ * canvas is hidden from assistive technology, since the list of places beside it says the same.
56
+ */
57
+ export function MapView({ markers, source, selected, onPick, onStatus }: MapViewProps) {
58
+ const { t, i18n } = useTranslation('agent-chat');
59
+ const mode = useChatHost().code.useMode();
60
+ const element = useRef<HTMLDivElement>(null);
61
+ const [map, setMap] = useState<MapLibreMap | null>(null);
62
+ const [outline, setOutline] = useState(false);
63
+ const latest = useRef({ source, onStatus, onPick });
64
+ useEffect(() => {
65
+ latest.current = { source, onStatus, onPick };
66
+ });
67
+ const language = i18n.language.split('-')[0] ?? 'en';
68
+ const styleKey = `${mode}:${source.state === 'tiles' ? source.styleUrl(mode) : 'outline'}`;
69
+ const pointsKey = JSON.stringify(markers.map((marker) => [marker.id, marker.lat, marker.lon]));
70
+
71
+ useEffect(() => {
72
+ const container = element.current;
73
+ if (container === null) {
74
+ return;
75
+ }
76
+ const current = latest.current.source;
77
+ const colors = readColors(container);
78
+ const offline = current.state === 'tiles' && typeof navigator !== 'undefined' && navigator.onLine === false;
79
+ const tiles = current.state === 'tiles' && !offline;
80
+ let created: MapLibreMap;
81
+ try {
82
+ ensureBundledWorker();
83
+ created = new MapLibreMap({
84
+ container,
85
+ style: current.state === 'tiles' && tiles ? current.styleUrl(mode) : baseMapStyle(colors),
86
+ center: [0, 20],
87
+ zoom: 1,
88
+ keyboard: false,
89
+ cooperativeGestures: true,
90
+ attributionControl: false,
91
+ dragRotate: false,
92
+ pitchWithRotate: false,
93
+ locale: {
94
+ 'CooperativeGesturesHandler.WindowsHelpText': t('blocks.map.scrollHint'),
95
+ 'CooperativeGesturesHandler.MacHelpText': t('blocks.map.scrollHint'),
96
+ 'CooperativeGesturesHandler.MobileHelpText': t('blocks.map.touchHint')
97
+ },
98
+ transformRequest: (url) => {
99
+ const now = latest.current.source;
100
+ return now.state === 'tiles' && now.transformRequest ? now.transformRequest(url) : { url };
101
+ }
102
+ });
103
+ } catch {
104
+ latest.current.onStatus('failed');
105
+ return;
106
+ }
107
+ created.touchZoomRotate.disableRotation();
108
+ const canvas = created.getCanvas();
109
+ canvas.tabIndex = -1;
110
+ const wantsTiles = current.state === 'tiles';
111
+ let drawing: 'tiles' | 'outline' = tiles ? 'tiles' : 'outline';
112
+ // Why the outline is drawn in place of tiles that were asked for; null while the tiles are.
113
+ let reason: 'offline' | 'unavailable' | null = wantsTiles && !tiles ? 'offline' : null;
114
+ let styleLoaded = false;
115
+ let restoring = false;
116
+ let tileErrors = 0;
117
+ setOutline(!tiles);
118
+ latest.current.onStatus(tiles ? 'loading' : wantsTiles ? 'offline' : 'base');
119
+
120
+ const drawOutline = (why: 'offline' | 'unavailable'): void => {
121
+ if (drawing === 'outline' || !wantsTiles) {
122
+ return;
123
+ }
124
+ drawing = 'outline';
125
+ reason = why;
126
+ setOutline(true);
127
+ created.setStyle(baseMapStyle(colors));
128
+ latest.current.onStatus(why);
129
+ };
130
+ const drawTiles = (): void => {
131
+ if (current.state !== 'tiles') {
132
+ return;
133
+ }
134
+ drawing = 'tiles';
135
+ reason = null;
136
+ styleLoaded = false;
137
+ restoring = true;
138
+ tileErrors = 0;
139
+ setOutline(false);
140
+ created.setStyle(current.styleUrl(mode));
141
+ latest.current.onStatus('loading');
142
+ };
143
+ const unreachable = (): 'offline' | 'unavailable' => (typeof navigator !== 'undefined' && navigator.onLine === false ? 'offline' : 'unavailable');
144
+
145
+ created.on('style.load', () => {
146
+ if (drawing !== 'tiles') {
147
+ return;
148
+ }
149
+ styleLoaded = true;
150
+ if (restoring) {
151
+ restoring = false;
152
+ latest.current.onStatus('tiles');
153
+ }
154
+ const field = current.state === 'tiles' ? current.labelField?.(language) : undefined;
155
+ if (field === undefined) {
156
+ return;
157
+ }
158
+ for (const layer of created.getStyle().layers) {
159
+ const textField = layer.type === 'symbol' ? layer.layout?.['text-field'] : undefined;
160
+ if (textField !== undefined) {
161
+ created.setLayoutProperty(layer.id, 'text-field', localizedTextField(textField, field) as never);
162
+ }
163
+ }
164
+ });
165
+ created.on('load', () => {
166
+ if (drawing === 'tiles') {
167
+ latest.current.onStatus('tiles');
168
+ }
169
+ });
170
+ created.on('sourcedata', (event) => {
171
+ if ('tile' in event) {
172
+ tileErrors = 0;
173
+ }
174
+ });
175
+ created.on('error', (event) => {
176
+ if (drawing !== 'tiles') {
177
+ return;
178
+ }
179
+ // Before the style is here there is nothing to draw. After it, a tile the host refuses (a ticket
180
+ // that ran out) or several in a row leave an empty map; a single one the map retries itself.
181
+ const status = (event.error as { status?: number } | undefined)?.status;
182
+ if (!styleLoaded || status === 401 || status === 403 || ++tileErrors >= TILE_ERRORS_IN_A_ROW) {
183
+ drawOutline(unreachable());
184
+ }
185
+ });
186
+ const goOffline = (): void => drawOutline('offline');
187
+ const goOnline = (): void => {
188
+ if (reason === 'offline') {
189
+ drawTiles();
190
+ }
191
+ };
192
+ const contextLost = (): void => latest.current.onStatus('lost');
193
+ if (wantsTiles) {
194
+ window.addEventListener('offline', goOffline);
195
+ window.addEventListener('online', goOnline);
196
+ }
197
+ canvas.addEventListener('webglcontextlost', contextLost);
198
+ setMap(created);
199
+ return () => {
200
+ window.removeEventListener('offline', goOffline);
201
+ window.removeEventListener('online', goOnline);
202
+ canvas.removeEventListener('webglcontextlost', contextLost);
203
+ setMap(null);
204
+ created.remove();
205
+ };
206
+ // The map is made again when the style it draws changes, and not for any other change of the source.
207
+ // oxlint-disable-next-line react-hooks/exhaustive-deps
208
+ }, [styleKey, language]);
209
+
210
+ useEffect(() => {
211
+ const bounds = boundsOf(markers);
212
+ if (map === null || bounds === null) {
213
+ return;
214
+ }
215
+ map.fitBounds(bounds, { padding: 32, maxZoom: outline ? MAP_MAX_ZOOM.outline : MAP_MAX_ZOOM.tiles, duration: 0 });
216
+ // The points are compared by their coordinates, so a render that rebuilt the same list does not move the map.
217
+ // oxlint-disable-next-line react-hooks/exhaustive-deps
218
+ }, [map, pointsKey, outline]);
219
+
220
+ const pickable = onPick !== null;
221
+ useEffect(() => {
222
+ if (map === null) {
223
+ return;
224
+ }
225
+ const drawn = markers.map((marker) => {
226
+ const node = document.createElement('div');
227
+ node.setAttribute('aria-hidden', 'true');
228
+ node.dataset.mapMarker = marker.id;
229
+ node.className = `${MARKER} ${marker.id === selected ? PICKED : ''} ${pickable ? 'cursor-pointer' : ''}`;
230
+ node.textContent = String(marker.number);
231
+ node.addEventListener('click', () => latest.current.onPick?.(marker.id));
232
+ return new Marker({ element: node }).setLngLat([marker.lon, marker.lat]).addTo(map);
233
+ });
234
+ return () => {
235
+ for (const marker of drawn) {
236
+ marker.remove();
237
+ }
238
+ };
239
+ // oxlint-disable-next-line react-hooks/exhaustive-deps
240
+ }, [map, pointsKey, selected, pickable]);
241
+
242
+ return (
243
+ <div className="absolute inset-0">
244
+ <div ref={element} aria-hidden className="size-full" />
245
+ <ButtonGroup className="absolute top-2 right-2 rounded-md border border-border bg-surface-raised shadow-raised">
246
+ <IconButton icon={Plus} size="sm" label={t('blocks.map.zoomIn')} onClick={() => map?.zoomIn()} />
247
+ <IconButton icon={Minus} size="sm" label={t('blocks.map.zoomOut')} onClick={() => map?.zoomOut()} />
248
+ </ButtonGroup>
249
+ </div>
250
+ );
251
+ }
@@ -0,0 +1,226 @@
1
+ import { useId, useState, type ReactNode } from 'react';
2
+ import type { TFunction } from 'i18next';
3
+ import clsx from 'clsx';
4
+ import { useTranslation } from 'react-i18next';
5
+ import type { UiProps, UiViewNode } from '@adecore/intelligent-ui';
6
+ import { Button, LazyBoundary, Spinner } from '@adecore/ui';
7
+ import { useChatHost, type UiMapSource } from '../../../../host';
8
+ import { lazyNamed } from '../../../../lazy';
9
+ import { DiffFallbackBoundary } from '../../diff/DiffFallbackBoundary';
10
+ import { uiChildrenOf, uiNodeLabel } from '../node-text';
11
+ import type { UiPlacePoint, UiRendererProps } from '../render-context';
12
+ import { placePoint } from './place-point';
13
+ import { useInView } from '../use-in-view';
14
+ import { useLater } from '../use-later';
15
+ import { bindingOf, PENDING } from './binding';
16
+ import { useMapSlot } from './map-slots';
17
+ import type { MapMarker, MapViewStatus } from './map-view';
18
+
19
+ // MapLibre and its stylesheet load with the first map that is drawn, never with the chat.
20
+ const MapView = lazyNamed(() => import('./map-view'), 'MapView');
21
+
22
+ /* A map that is still loading shows a spinner only once the wait is noticeable. */
23
+ const LOADING_SPINNER_MS = 1000;
24
+
25
+ interface ListedPlace {
26
+ node: UiViewNode<UiProps<'Place'>>;
27
+ name: string;
28
+ point: UiPlacePoint | null | undefined;
29
+ }
30
+
31
+ /* The number that ties a row to its marker. The list numbers itself for a screen reader, so the chip is only for the eye. */
32
+ function NumberChip({ number, selected }: { number: number; selected: boolean }) {
33
+ return (
34
+ <span
35
+ aria-hidden
36
+ className={clsx(
37
+ 'mt-px flex size-5 shrink-0 items-center justify-center rounded-full text-xs tabular-nums',
38
+ selected ? 'bg-accent text-accent-text' : 'bg-map-marker text-map-marker-text'
39
+ )}
40
+ >
41
+ {number}
42
+ </span>
43
+ );
44
+ }
45
+
46
+ function PlaceRow({ listed, number, selected, onPick }: { listed: ListedPlace; number: number; selected: boolean; onPick: (() => void) | null }) {
47
+ const { t } = useTranslation('agent-chat');
48
+ const { detail, address } = listed.node.props;
49
+ const body = (
50
+ <>
51
+ <NumberChip number={number} selected={selected} />
52
+ <span className="flex min-w-0 grow flex-col gap-0.5">
53
+ <span className="text-sm wrap-break-word text-text">{listed.name}</span>
54
+ {detail !== undefined && detail !== '' && <span className="text-xs wrap-break-word text-text-muted">{detail}</span>}
55
+ {address !== undefined && address !== '' && <span className="text-xs wrap-break-word text-text-faint">{address}</span>}
56
+ {listed.point === undefined && <span className="text-xs text-text-faint">{t('blocks.map.lookingUp')}</span>}
57
+ {listed.point === null && <span className="text-xs text-text-faint">{t('blocks.map.notFound')}</span>}
58
+ </span>
59
+ </>
60
+ );
61
+ const row = 'flex w-full min-h-8 items-start gap-2 rounded-md px-2 py-1.5 text-left';
62
+ return (
63
+ <li>
64
+ {onPick === null ? (
65
+ <div className={clsx(row, selected && 'bg-surface-hover')}>{body}</div>
66
+ ) : (
67
+ <button type="button" aria-pressed={selected} className={clsx(row, 'hover:bg-surface-hover', selected && 'bg-surface-hover')} onClick={onPick}>
68
+ {body}
69
+ </button>
70
+ )}
71
+ </li>
72
+ );
73
+ }
74
+
75
+ /* Why the map is not the host's: its own reason when the source has none, or what the view found out on the way. */
76
+ function noteFor(t: TFunction<'agent-chat'>, source: UiMapSource | null, status: MapViewStatus): string | null {
77
+ if (status === 'offline') {
78
+ return t('blocks.map.offline');
79
+ }
80
+ if (status === 'unavailable' || status === 'failed' || status === 'lost') {
81
+ return t('blocks.map.unavailable');
82
+ }
83
+ if (source?.state === 'off') {
84
+ return t(source.reason === 'disabled' ? 'blocks.map.disabled' : source.reason === 'signed-out' ? 'blocks.map.signedOut' : 'blocks.map.unavailable');
85
+ }
86
+ return source?.state === 'tiles' && status === 'tiles' ? (source.attribution ?? null) : null;
87
+ }
88
+
89
+ interface FrameProps {
90
+ markers: readonly MapMarker[];
91
+ /* Null where the host offers no tiles at all, which needs no excuse. */
92
+ source: UiMapSource | null;
93
+ selected: string | null;
94
+ onPick: ((id: string) => void) | null;
95
+ }
96
+
97
+ /*
98
+ * The picture of the places and the line under it that says whose tiles these are, or why there are none.
99
+ * The map is only drawn while the frame is near the viewport and holds one of the page's few map slots
100
+ * (`MAX_LIVE_MAPS`); one that waits for a slot, or whose graphics context the browser took back, offers a button.
101
+ */
102
+ function MapFrame({ markers, source, selected, onPick }: FrameProps) {
103
+ const { t } = useTranslation('agent-chat');
104
+ const id = useId();
105
+ const [element, setElement] = useState<HTMLDivElement | null>(null);
106
+ const [status, setStatus] = useState<MapViewStatus>('loading');
107
+ const [attempt, setAttempt] = useState(0);
108
+ const inView = useInView(element);
109
+ const broken = status === 'failed' || status === 'lost';
110
+ const slot = useMapSlot(id, inView && !broken);
111
+ const drawn = slot.state === 'granted' && !broken;
112
+ const waiting = useLater(drawn && status === 'loading', LOADING_SPINNER_MS);
113
+ const note = noteFor(t, source, status);
114
+ const quiet = (text: string, action?: { label: string; run(): void }) => (
115
+ <div className="grid h-full place-items-center content-center justify-items-center gap-2 p-3 text-center">
116
+ <p className="text-xs text-text-faint">{text}</p>
117
+ {action !== undefined && (
118
+ <Button size="sm" variant="secondary" onClick={action.run}>
119
+ {action.label}
120
+ </Button>
121
+ )}
122
+ </div>
123
+ );
124
+ const retry = () => {
125
+ setStatus('loading');
126
+ setAttempt((previous) => previous + 1);
127
+ };
128
+ let body: ReactNode = null;
129
+ if (status === 'failed') {
130
+ body = quiet(t('blocks.map.unavailable'));
131
+ } else if (status === 'lost') {
132
+ body = quiet(t('blocks.map.contextLost'), { label: t('blocks.map.show'), run: retry });
133
+ } else if (slot.state === 'queued') {
134
+ body = quiet(t('blocks.map.waiting'), { label: t('blocks.map.show'), run: slot.take });
135
+ } else if (drawn) {
136
+ body = (
137
+ <DiffFallbackBoundary fallback={quiet(t('blocks.map.unavailable'))} resetKeys={[]}>
138
+ <LazyBoundary>
139
+ <MapView
140
+ key={attempt}
141
+ markers={markers}
142
+ source={source ?? { state: 'off', reason: 'unavailable' }}
143
+ selected={selected}
144
+ onPick={onPick}
145
+ onStatus={setStatus}
146
+ />
147
+ </LazyBoundary>
148
+ </DiffFallbackBoundary>
149
+ );
150
+ }
151
+ return (
152
+ <>
153
+ <div ref={setElement} className="relative h-50 overflow-hidden rounded-lg border border-border bg-surface-sunken @min-[420px]:h-60">
154
+ {body}
155
+ {waiting && (
156
+ <div className="pointer-events-none absolute inset-0 grid place-items-center">
157
+ <Spinner size={16} />
158
+ </div>
159
+ )}
160
+ </div>
161
+ {note !== null && <p className="px-0.5 text-xs text-text-faint">{note}</p>}
162
+ </>
163
+ );
164
+ }
165
+
166
+ /* Asks the host for its tiles in a component of its own, since a host that offers none has no hook to call. */
167
+ function HostedFrame({ maps, scopeId, ...frame }: Omit<FrameProps, 'source'> & { maps: NonNullable<ReturnType<typeof useChatHost>['maps']>; scopeId: string }) {
168
+ return <MapFrame {...frame} source={maps.useSource(scopeId)} />;
169
+ }
170
+
171
+ /*
172
+ * The places of a map as a numbered list, which is also the form a screen reader, an offline page and a
173
+ * map without tiles all rely on. A place that carries a `value` is a button when the map is bound, and
174
+ * picking it sets the binding. A place the lookup could not place stays in the list without a marker.
175
+ */
176
+ export function MapRenderer({ node, children, context }: UiRendererProps<UiProps<'Map'>>) {
177
+ const { t } = useTranslation('agent-chat');
178
+ const binding = bindingOf(node, context);
179
+ const current = binding?.value ?? node.props.value;
180
+ const maps = useChatHost().maps;
181
+ const places: ListedPlace[] = uiChildrenOf(node, 'Place').map((place) => ({
182
+ node: place,
183
+ name: uiNodeLabel(place),
184
+ point: placePoint(place.props, place.id, context)
185
+ }));
186
+ const markers = places.flatMap((listed, index) =>
187
+ listed.point ? [{ id: listed.node.id, number: index + 1, lat: listed.point.lat, lon: listed.point.lon }] : []
188
+ );
189
+ const selectedId = places.find((listed) => listed.node.props.value !== undefined && listed.node.props.value === current)?.node.id ?? null;
190
+ const pick = (id: string): void => {
191
+ const value = places.find((listed) => listed.node.id === id)?.node.props.value;
192
+ if (value !== undefined) {
193
+ binding?.onValueChange(value);
194
+ }
195
+ };
196
+ const onPick = binding !== null ? pick : null;
197
+ const frame = maps ? (
198
+ <HostedFrame maps={maps} scopeId={context.scopeId} markers={markers} selected={selectedId} onPick={onPick} />
199
+ ) : (
200
+ <MapFrame markers={markers} source={null} selected={selectedId} onPick={onPick} />
201
+ );
202
+ return (
203
+ <div className={clsx('@container flex flex-col gap-1.5 px-2', !node.complete && PENDING)}>
204
+ {frame}
205
+ <ol aria-label={t('blocks.map.label', { count: places.length })} className="flex flex-col">
206
+ {places.map((listed, index) => {
207
+ const { value } = listed.node.props;
208
+ return (
209
+ <PlaceRow
210
+ key={listed.node.id}
211
+ listed={listed}
212
+ number={index + 1}
213
+ selected={value !== undefined && value === current}
214
+ onPick={binding !== null && value !== undefined ? () => binding.onValueChange(value) : null}
215
+ />
216
+ );
217
+ })}
218
+ </ol>
219
+ {node.omitted !== undefined && node.omitted > 0 && (
220
+ <p className="px-2 text-xs text-text-faint">{t('blocks.map.omitted', { count: node.omitted })}</p>
221
+ )}
222
+ {/* A place the catalog refused draws as its text, which is all its node renders. */}
223
+ <div className="chat-ui-nodes flex flex-col gap-1.5 empty:hidden">{children}</div>
224
+ </div>
225
+ );
226
+ }
@@ -0,0 +1,16 @@
1
+ import type { UiProps } from '@adecore/intelligent-ui';
2
+ import type { UiPlacePoint, UiRenderContext } from '../render-context';
3
+
4
+ /*
5
+ * What the host found for the address of a place wins over the coordinates the agent wrote, which stand
6
+ * while the lookup is on its way and when it finds nothing. An address alone waits for the host's reading.
7
+ */
8
+ export function placePoint(props: UiProps<'Place'>, nodeId: string, context: Pick<UiRenderContext, 'place'>): UiPlacePoint | null | undefined {
9
+ const { lat, lon, address } = props;
10
+ const written = lat !== undefined && lon !== undefined ? { lat, lon } : undefined;
11
+ if (address === undefined || address.trim() === '') {
12
+ return written ?? null;
13
+ }
14
+ const found = context.place === undefined ? null : context.place(nodeId, address);
15
+ return found ?? written ?? found;
16
+ }
@@ -6,7 +6,7 @@ import type { UiProps } from '@adecore/intelligent-ui';
6
6
  import { Icon, Meter, Pill, Spinner } from '@adecore/ui';
7
7
  import { formatNumber, formatPercent } from '@adecore/ui/format';
8
8
  import { UiHeadContext, type UiRenderContext, type UiRendererProps } from '../render-context';
9
- import { uiNodeLabel } from '../node-text';
9
+ import { uiEventTime, uiNodeLabel } from '../node-text';
10
10
  import { UI_TONE_ICONS, UI_TONE_PILL, UI_TONE_SURFACE, UI_TONE_TEXT } from '../tones';
11
11
 
12
12
  type StepState = UiProps<'Step'>['state'];
@@ -132,3 +132,30 @@ export function StepRenderer({ node, children, context }: UiRendererProps<UiProp
132
132
  </li>
133
133
  );
134
134
  }
135
+
136
+ export function TimelineRenderer({ children }: UiRendererProps<UiProps<'Timeline'>>) {
137
+ return <ol className="chat-ui-nodes flex flex-col px-2">{children}</ol>;
138
+ }
139
+
140
+ /* Each row draws its own stretch of the line, down to the next dot, so a row that failed to draw leaves the others whole. */
141
+ export function EventRenderer({ node, children }: UiRendererProps<UiProps<'Event'>>) {
142
+ const { t } = useTranslation('agent-chat');
143
+ const { at, detail } = node.props;
144
+ const tone = node.props.tone ?? 'neutral';
145
+ const time = uiEventTime(at);
146
+ // A neutral dot says nothing the title does not, and a detail is part of what the row says.
147
+ const label = [time, uiNodeLabel(node), tone === 'neutral' ? '' : t(`blocks.event.tone.${tone}`), detail ?? ''].filter((part) => part !== '').join(', ');
148
+ return (
149
+ <li aria-label={label} className="group/event relative flex min-h-7 gap-3 pb-3 last:pb-0">
150
+ <span aria-hidden className={clsx('mt-1 size-2 shrink-0 rounded-full bg-current', UI_TONE_TEXT[tone])} />
151
+ <span aria-hidden className="absolute top-3.5 bottom-0 left-0.75 w-px bg-border group-last/event:hidden" />
152
+ <div className="flex min-w-0 grow flex-col gap-0.5">
153
+ <time dateTime={at} className="text-xs text-text-faint tabular-nums">
154
+ {time}
155
+ </time>
156
+ <div className="text-sm wrap-break-word text-text">{children}</div>
157
+ {detail !== undefined && detail !== '' && <div className="text-xs wrap-anywhere text-text-faint">{detail}</div>}
158
+ </div>
159
+ </li>
160
+ );
161
+ }
@@ -0,0 +1,18 @@
1
+ import { useEffect, useState } from 'react';
2
+
3
+ /*
4
+ * Whether `element` is near the viewport, with `margin` to spare so it is ready by the time it scrolls in.
5
+ * Without an observer (a server render, a test) it counts as in view.
6
+ */
7
+ export function useInView(element: Element | null, margin = '200px'): boolean {
8
+ const [inView, setInView] = useState(typeof IntersectionObserver === 'undefined');
9
+ useEffect(() => {
10
+ if (element === null || typeof IntersectionObserver === 'undefined') {
11
+ return;
12
+ }
13
+ const observer = new IntersectionObserver((entries) => setInView(entries.at(-1)?.isIntersecting ?? false), { rootMargin: margin });
14
+ observer.observe(element);
15
+ return () => observer.disconnect();
16
+ }, [element, margin]);
17
+ return inView;
18
+ }
package/src/host.ts CHANGED
@@ -195,6 +195,30 @@ export interface ReplyAuthor {
195
195
  mark?: ReactNode;
196
196
  }
197
197
 
198
+ /*
199
+ * Where the tiles of a Map come from. The host owns the service and whatever admits a request to it, so
200
+ * a chat never knows an address, a key or a vendor: it asks for the style of the theme and lets the host
201
+ * rewrite every address the map requests. `off` says why there are no tiles, and the map then draws its
202
+ * built-in outline beside the list of places.
203
+ */
204
+ export type UiMapSource =
205
+ | {
206
+ state: 'tiles';
207
+ /* The style document of one theme. */
208
+ styleUrl(theme: 'light' | 'dark'): string;
209
+ /*
210
+ * Called for every address the map requests, such as to add a ticket or a header to those of the host's
211
+ * service. Rewrite only the addresses of your own origin and hand every other back as it came: the style
212
+ * and the tiles name other hosts too, and a ticket must not travel to them.
213
+ */
214
+ transformRequest?(url: string): { url: string; headers?: Record<string, string> };
215
+ /* The feature property that holds a label in `language` (such as `nl`); without it a label stays as the style writes it. */
216
+ labelField?(language: string): string;
217
+ /* The credit the tiles ask for, drawn under the map. */
218
+ attribution?: string;
219
+ }
220
+ | { state: 'off'; reason: 'disabled' | 'signed-out' | 'unavailable' };
221
+
198
222
  /*
199
223
  * What the app around the chat decides and the chat cannot, handed to the views by a `ChatHostProvider`.
200
224
  * Whatever an app leaves out keeps the default here, which is the chat without that part. One host may
@@ -209,6 +233,10 @@ export interface ChatHost {
209
233
  query?(scopeId: string, payload: ChatUiQueryPayload): Promise<ChatUiQueryReading>;
210
234
  sendChoice(scopeId: string, payload: ChatUiChoicePayload): Promise<'sent' | 'queued'>;
211
235
  };
236
+ /* The map tiles of a Map block; left out, a map draws its outline and its list of places only. */
237
+ maps?: {
238
+ useSource(scopeId: string): UiMapSource;
239
+ };
212
240
  /* The colors an account may wear in the order a picker offers them, the few it shows first, and their names. */
213
241
  accents: {
214
242
  all: readonly AccentChoice[];
package/src/index.ts CHANGED
@@ -14,6 +14,7 @@ export {
14
14
  type VisualHost,
15
15
  type ReplyAuthor,
16
16
  type ChatHost,
17
+ type UiMapSource,
17
18
  ChatHostProvider,
18
19
  useChatHost,
19
20
  type AccentChoice,
@@ -32,18 +33,7 @@ export {
32
33
  isConnectionError
33
34
  } from './transport';
34
35
  export { portTransport, type PortTransport } from './port-transport';
35
- export {
36
- onChatStorageConfiguration,
37
- configureChatStorage,
38
- chatStorageKey,
39
- chatStorageLegacyKeys,
40
- chatStorageAdapter,
41
- hydrateChatStorage,
42
- createChatStore,
43
- type ChatStorageRecord,
44
- type ChatStorageAdapter,
45
- type ChatStorageOptions
46
- } from './storage';
36
+ export { configureChatStorage, type ChatStorageRecord, type ChatStorageAdapter, type ChatStorageOptions } from './storage';
47
37
  export { setLazyPrefetch, onLazyOpenError } from './lazy';
48
38
  export { type MountedEntry, MountedRegistry } from './mounted-registry';
49
39
  export { type AgentsSettingsSection, PROVIDERS_SECTION, USAGE_SECTION, settingsSection } from './settings/sections';