@adecore/agents-react 0.24.1 → 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 (113) 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/EditDiff.js +4 -3
  13. package/dist/chat/ui/Pickers.js +1 -1
  14. package/dist/chat/ui/UnifiedDiff.js +4 -3
  15. package/dist/chat/ui/composer/ComposerControls.d.ts +1 -0
  16. package/dist/chat/ui/composer/ComposerControls.js +3 -3
  17. package/dist/chat/ui/composer/use-composer-submit.d.ts +3 -1
  18. package/dist/chat/ui/composer/use-composer-submit.js +22 -4
  19. package/dist/chat/ui/composer/use-continuable-turn.d.ts +2 -0
  20. package/dist/chat/ui/composer/use-continuable-turn.js +12 -0
  21. package/dist/chat/ui/composer/use-notice.d.ts +3 -1
  22. package/dist/chat/ui/composer/use-notice.js +8 -6
  23. package/dist/chat/ui/diff/ReviewDiff.js +2 -2
  24. package/dist/chat/ui/index.d.ts +9 -28
  25. package/dist/chat/ui/index.js +8 -27
  26. package/dist/chat/ui/intelligent-ui/UiReply.js +20 -2
  27. package/dist/chat/ui/intelligent-ui/node-text.d.ts +3 -1
  28. package/dist/chat/ui/intelligent-ui/node-text.js +32 -0
  29. package/dist/chat/ui/intelligent-ui/registry.js +15 -5
  30. package/dist/chat/ui/intelligent-ui/render-context.d.ts +6 -0
  31. package/dist/chat/ui/intelligent-ui/renderers/base-land.d.ts +1 -0
  32. package/dist/chat/ui/intelligent-ui/renderers/base-land.js +6 -0
  33. package/dist/chat/ui/intelligent-ui/renderers/binding.d.ts +6 -0
  34. package/dist/chat/ui/intelligent-ui/renderers/binding.js +10 -0
  35. package/dist/chat/ui/intelligent-ui/renderers/content.d.ts +2 -0
  36. package/dist/chat/ui/intelligent-ui/renderers/content.js +68 -6
  37. package/dist/chat/ui/intelligent-ui/renderers/inputs.d.ts +2 -0
  38. package/dist/chat/ui/intelligent-ui/renderers/inputs.js +40 -13
  39. package/dist/chat/ui/intelligent-ui/renderers/map-slots.d.ts +20 -0
  40. package/dist/chat/ui/intelligent-ui/renderers/map-slots.js +87 -0
  41. package/dist/chat/ui/intelligent-ui/renderers/map-style.d.ts +18 -0
  42. package/dist/chat/ui/intelligent-ui/renderers/map-style.js +72 -0
  43. package/dist/chat/ui/intelligent-ui/renderers/map-view.d.ts +15 -0
  44. package/dist/chat/ui/intelligent-ui/renderers/map-view.js +213 -0
  45. package/dist/chat/ui/intelligent-ui/renderers/map.d.ts +3 -0
  46. package/dist/chat/ui/intelligent-ui/renderers/map.js +113 -0
  47. package/dist/chat/ui/intelligent-ui/renderers/place-point.d.ts +3 -0
  48. package/dist/chat/ui/intelligent-ui/renderers/place-point.js +13 -0
  49. package/dist/chat/ui/intelligent-ui/renderers/text.d.ts +2 -0
  50. package/dist/chat/ui/intelligent-ui/renderers/text.js +14 -1
  51. package/dist/chat/ui/intelligent-ui/use-in-view.d.ts +1 -0
  52. package/dist/chat/ui/intelligent-ui/use-in-view.js +17 -0
  53. package/dist/chat/ui/rows/WorkRows.js +3 -3
  54. package/dist/host.d.ts +16 -0
  55. package/dist/index.d.ts +2 -2
  56. package/dist/index.js +1 -1
  57. package/dist/lazy.js +5 -7
  58. package/dist/locales/en/agent-chat.d.ts +47 -0
  59. package/dist/locales/en/agent-chat.js +48 -1
  60. package/dist/locales/nl/agent-chat.d.ts +47 -0
  61. package/dist/locales/nl/agent-chat.js +48 -1
  62. package/dist/prompts/index.d.ts +3 -5
  63. package/dist/prompts/index.js +3 -5
  64. package/dist/providers/index.d.ts +3 -9
  65. package/dist/providers/index.js +3 -9
  66. package/dist/state/index.d.ts +2 -2
  67. package/dist/state/index.js +2 -2
  68. package/dist/state/usage.d.ts +0 -1
  69. package/dist/state/usage.js +0 -3
  70. package/dist/theme.css +10 -0
  71. package/package.json +9 -8
  72. package/src/chat/index.ts +2 -23
  73. package/src/chat/logic/continue-after-stop.ts +32 -0
  74. package/src/chat/logic/thread-cards.ts +0 -5
  75. package/src/chat/preferences.ts +0 -4
  76. package/src/chat/stash.ts +17 -0
  77. package/src/chat/ui/Composer.tsx +33 -5
  78. package/src/chat/ui/EditDiff.tsx +6 -5
  79. package/src/chat/ui/Pickers.tsx +1 -0
  80. package/src/chat/ui/UnifiedDiff.tsx +6 -5
  81. package/src/chat/ui/composer/ComposerControls.tsx +13 -3
  82. package/src/chat/ui/composer/use-composer-submit.ts +25 -5
  83. package/src/chat/ui/composer/use-continuable-turn.ts +14 -0
  84. package/src/chat/ui/composer/use-notice.ts +14 -7
  85. package/src/chat/ui/diff/ReviewDiff.tsx +3 -3
  86. package/src/chat/ui/index.ts +9 -49
  87. package/src/chat/ui/intelligent-ui/UiReply.tsx +24 -1
  88. package/src/chat/ui/intelligent-ui/node-text.ts +34 -1
  89. package/src/chat/ui/intelligent-ui/registry.ts +33 -5
  90. package/src/chat/ui/intelligent-ui/render-context.ts +14 -0
  91. package/src/chat/ui/intelligent-ui/renderers/base-land.ts +7 -0
  92. package/src/chat/ui/intelligent-ui/renderers/binding.ts +14 -0
  93. package/src/chat/ui/intelligent-ui/renderers/content.tsx +144 -9
  94. package/src/chat/ui/intelligent-ui/renderers/inputs.tsx +79 -17
  95. package/src/chat/ui/intelligent-ui/renderers/map-slots.ts +105 -0
  96. package/src/chat/ui/intelligent-ui/renderers/map-style.ts +96 -0
  97. package/src/chat/ui/intelligent-ui/renderers/map-view.tsx +251 -0
  98. package/src/chat/ui/intelligent-ui/renderers/map.tsx +226 -0
  99. package/src/chat/ui/intelligent-ui/renderers/place-point.ts +16 -0
  100. package/src/chat/ui/intelligent-ui/renderers/text.tsx +28 -1
  101. package/src/chat/ui/intelligent-ui/use-in-view.ts +18 -0
  102. package/src/chat/ui/rows/WorkRows.tsx +3 -3
  103. package/src/host.ts +28 -0
  104. package/src/index.ts +2 -12
  105. package/src/lazy.tsx +5 -7
  106. package/src/locales/en/agent-chat.ts +48 -1
  107. package/src/locales/nl/agent-chat.ts +48 -1
  108. package/src/prompts/index.ts +3 -18
  109. package/src/providers/index.ts +3 -9
  110. package/src/state/index.ts +1 -16
  111. package/src/state/usage.ts +0 -4
  112. package/src/stylesheets.d.ts +2 -0
  113. package/src/theme.css +10 -0
@@ -0,0 +1,96 @@
1
+ import type { LngLatBoundsLike, MapOptions } from 'maplibre-gl';
2
+ import { BASE_LAND_JSON } from './base-land';
3
+
4
+ // The package does not export its style type, only the options that take one.
5
+ export type MapStyle = Exclude<MapOptions['style'], string | undefined>;
6
+
7
+ type Expression = unknown[];
8
+
9
+ /* The two grounds of the built-in map, as the page paints them. */
10
+ export interface MapColors {
11
+ land: string;
12
+ water: string;
13
+ }
14
+
15
+ export interface MapPoint {
16
+ id: string;
17
+ lat: number;
18
+ lon: number;
19
+ }
20
+
21
+ /* How far a map zooms to fit its points: street level with tiles, and only a country or two on the outline, which has nothing finer. */
22
+ export const MAP_MAX_ZOOM = { tiles: 16, outline: 6 } as const;
23
+
24
+ /* The map a page draws without tiles: land over water, from the outline of the world that ships with the package. */
25
+ export function baseMapStyle({ land, water }: MapColors): MapStyle {
26
+ return {
27
+ version: 8,
28
+ sources: { land: { type: 'geojson', data: JSON.parse(BASE_LAND_JSON) } },
29
+ layers: [
30
+ { id: 'water', type: 'background', paint: { 'background-color': water } },
31
+ { id: 'land', type: 'fill', source: 'land', paint: { 'fill-color': land } }
32
+ ]
33
+ };
34
+ }
35
+
36
+ const TOKEN = /\{([^{}]+)\}/g;
37
+
38
+ /* A `{name}` text field as the expression it stands for, since a field combined with another expression is no longer read for tokens. */
39
+ function tokenExpression(text: string): Expression | string {
40
+ const parts: (Expression | string)[] = [];
41
+ let at = 0;
42
+ for (const match of text.matchAll(TOKEN)) {
43
+ if (match.index > at) {
44
+ parts.push(text.slice(at, match.index));
45
+ }
46
+ parts.push(['to-string', ['coalesce', ['get', match[1]], '']]);
47
+ at = match.index + match[0].length;
48
+ }
49
+ if (parts.length === 0) {
50
+ return text;
51
+ }
52
+ if (at < text.length) {
53
+ parts.push(text.slice(at));
54
+ }
55
+ return parts.length === 1 && typeof parts[0] !== 'string' ? parts[0] : ['concat', ...parts];
56
+ }
57
+
58
+ /*
59
+ * A label that prefers the property `field` of a feature and falls back to what the style wrote. A text
60
+ * field that is neither a string nor an expression (a legacy zoom function) is left as it is.
61
+ */
62
+ export function localizedTextField(original: unknown, field: string): unknown {
63
+ const fallback = typeof original === 'string' ? tokenExpression(original) : original;
64
+ if (typeof fallback !== 'string' && !Array.isArray(fallback)) {
65
+ return original;
66
+ }
67
+ return ['coalesce', ['get', field], fallback];
68
+ }
69
+
70
+ /*
71
+ * The bounds that hold every point, or null for none. Longitudes go round the globe, so the bounds leave out
72
+ * the widest gap between two neighbors: points on both sides of the date line span a few degrees, not 360.
73
+ * The east edge may then pass 180, which the map takes.
74
+ */
75
+ export function boundsOf(points: readonly MapPoint[]): LngLatBoundsLike | null {
76
+ if (points.length === 0) {
77
+ return null;
78
+ }
79
+ const lons = points.map((point) => point.lon).sort((a, b) => a - b);
80
+ let widest = lons[0]! + 360 - lons[lons.length - 1]!;
81
+ let west = lons[0]!;
82
+ let east = lons[lons.length - 1]!;
83
+ for (let i = 1; i < lons.length; i++) {
84
+ const gap = lons[i]! - lons[i - 1]!;
85
+ if (gap > widest) {
86
+ widest = gap;
87
+ west = lons[i]!;
88
+ east = lons[i - 1]! + 360;
89
+ }
90
+ }
91
+ const lats = points.map((point) => point.lat);
92
+ return [
93
+ [west, Math.min(...lats)],
94
+ [east, Math.max(...lats)]
95
+ ];
96
+ }
@@ -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
+ }