@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
@@ -1,8 +1,8 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import clsx from 'clsx';
3
- import { createContext, useContext, useId } from 'react';
3
+ import { createContext, useContext, useId, useState } from 'react';
4
4
  import { useTranslation } from 'react-i18next';
5
- import { PreviewCard, Tooltip } from '@adecore/ui';
5
+ import { Pill, PreviewCard, Tooltip, Wipe } from '@adecore/ui';
6
6
  import { formatNumber } from '@adecore/ui/format';
7
7
  import { useChatHost } from '../../../../host.js';
8
8
  import { CodeBlock } from '../../CodeBlock.js';
@@ -16,11 +16,19 @@ const SourceNumbers = createContext(new Map());
16
16
  export function CodeBlockRenderer({ node }) {
17
17
  return (_jsx(CodeStreamingContext.Provider, { value: !node.complete, children: _jsx(CodeBlock, { code: uiNodeText(node).replace(/^\n|\n$/g, ''), lang: node.props.language ?? 'text' }) }));
18
18
  }
19
- /* Its own component, so the host's hook is only called for an image that names an attachment. */
20
- function AttachedImage({ scopeId, chatId, attachmentId, alt }) {
19
+ // Set by a Gallery, so the images inside it draw as cells of its grid.
20
+ const GalleryContext = createContext(false);
21
+ /*
22
+ * Its own component, so the host's hook is only called for an image that names an attachment. In a
23
+ * gallery a cell that cannot show its picture says what it was, since the square alone tells nothing.
24
+ */
25
+ function AttachedImage({ scopeId, chatId, attachmentId, alt, cell, unnamed }) {
21
26
  const host = useChatHost();
22
27
  const source = host.attachments.useUrl(scopeId, chatId, attachmentId);
23
- return _jsx(ImageThumb, { source: source, alt: alt, className: "max-h-70 max-w-full object-contain" });
28
+ if (!cell) {
29
+ return _jsx(ImageThumb, { source: source, alt: alt, className: "max-h-70 max-w-full object-contain" });
30
+ }
31
+ return (_jsxs(_Fragment, { children: [_jsx(ImageThumb, { source: source, alt: alt, aspect: 1 }), unnamed && source.failure !== null && _jsx("figcaption", { className: "text-xs wrap-anywhere text-text-faint", children: alt })] }));
24
32
  }
25
33
  /*
26
34
  * An attachment of the chat, never a path or an address the model wrote. An image of the latest
@@ -28,10 +36,64 @@ function AttachedImage({ scopeId, chatId, attachmentId, alt }) {
28
36
  */
29
37
  export function ImageRenderer({ node, children, context }) {
30
38
  const { t } = useTranslation('agent-chat');
39
+ const cell = useContext(GalleryContext);
31
40
  const { attachment, alt } = node.props;
32
41
  const caption = uiNodeLabel(node);
33
42
  const name = alt ?? (caption === '' ? t('blocks.image.label') : caption);
34
- return (_jsxs("figure", { className: "flex flex-col items-start gap-1.5", children: [attachment === undefined ? (_jsx(ImageThumb, { source: { url: null, failure: t('blocks.image.unavailable') }, alt: name })) : (_jsx(AttachedImage, { scopeId: context.scopeId, chatId: context.chatId, attachmentId: attachment, alt: name })), caption !== '' && _jsx("figcaption", { className: "px-2 text-xs text-text-muted", children: children })] }));
43
+ return (_jsxs("figure", { className: cell ? 'flex min-w-0 flex-col gap-1' : 'flex flex-col items-start gap-1.5', children: [attachment === undefined ? (_jsx(ImageThumb, { source: { url: null, failure: t('blocks.image.unavailable') }, alt: name, aspect: cell ? 1 : undefined })) : (_jsx(AttachedImage, { scopeId: context.scopeId, chatId: context.chatId, attachmentId: attachment, alt: name, cell: cell, unnamed: caption === '' })), caption !== '' && _jsx("figcaption", { className: cell ? 'text-xs wrap-anywhere text-text-muted' : 'px-2 text-xs text-text-muted', children: children }), cell && caption === '' && attachment === undefined && (_jsx("figcaption", { className: "text-xs wrap-anywhere text-text-faint", children: alt ?? t('blocks.image.unavailable') }))] }));
44
+ }
45
+ /* Three columns, two in a narrow block; the container decides, not the window, since a chat can be any width. */
46
+ export function GalleryRenderer({ node, children }) {
47
+ const { t } = useTranslation('agent-chat');
48
+ return (_jsx(GalleryContext, { value: true, children: _jsx("div", { role: "group", "aria-label": t('blocks.gallery.label', { count: uiChildrenOf(node, 'Image').length }), className: "@container px-2", children: _jsx("div", { className: "chat-ui-nodes grid grid-cols-2 gap-2 @min-[420px]:grid-cols-3", children: children }) }) }));
49
+ }
50
+ // The shape of the frame until the first picture reports its own.
51
+ const COMPARE_ASPECT = 4 / 3;
52
+ /* The tallest a comparison stands, in pixels: `max-h-96`. */
53
+ const COMPARE_MAX_HEIGHT = 384;
54
+ /*
55
+ * Two attachments in one frame, shaped like the first. The split is only this renderer's state. With one
56
+ * attachment gone the other stands alone with the reason; with both gone the thumbnail says why.
57
+ */
58
+ export function CompareRenderer({ node, children, context }) {
59
+ const { t } = useTranslation('agent-chat');
60
+ const host = useChatHost();
61
+ const { before, after } = node.props;
62
+ const beforeLabel = node.props.beforeLabel || t('blocks.compare.before');
63
+ const afterLabel = node.props.afterLabel || t('blocks.compare.after');
64
+ const first = host.attachments.useUrl(context.scopeId, context.chatId, before);
65
+ const second = host.attachments.useUrl(context.scopeId, context.chatId, after);
66
+ const [split, setSplit] = useState(0.5);
67
+ const [aspect, setAspect] = useState(COMPARE_ASPECT);
68
+ const caption = uiNodeLabel(node);
69
+ // Each picture is named by its side and by what the whole comparison shows.
70
+ const describe = (side) => (caption === '' ? side : `${side}: ${caption}`);
71
+ const loading = (source) => source.url === null && source.failure === null;
72
+ const label = t('blocks.compare.label', { before: beforeLabel, after: afterLabel });
73
+ let body;
74
+ if (loading(first) || loading(second)) {
75
+ body = _jsx(ImageThumb, { source: { url: null, failure: null }, alt: label, aspect: COMPARE_ASPECT });
76
+ }
77
+ else if (first.url !== null && second.url !== null) {
78
+ body = (_jsxs("div", { className: "relative max-h-96 overflow-hidden rounded-lg border border-border bg-surface-sunken", style: { aspectRatio: aspect, width: `min(100%, ${COMPARE_MAX_HEIGHT * aspect}px)` }, children: [_jsx(Wipe, { value: split, onValueChange: setSplit, label: label, before: _jsx("img", { src: first.url, alt: describe(beforeLabel), draggable: false, className: "size-full object-contain select-none", onLoad: (event) => {
79
+ const { naturalWidth, naturalHeight } = event.currentTarget;
80
+ if (naturalWidth > 0 && naturalHeight > 0) {
81
+ setAspect(naturalWidth / naturalHeight);
82
+ }
83
+ } }), after: _jsx("img", { src: second.url, alt: describe(afterLabel), draggable: false, className: "size-full object-contain select-none" }) }), _jsx(CompareChip, { side: "left", label: beforeLabel }), _jsx(CompareChip, { side: "right", label: afterLabel })] }));
84
+ }
85
+ else if (first.url === null && second.url === null) {
86
+ body = _jsx(ImageThumb, { source: first, alt: label, aspect: COMPARE_ASPECT });
87
+ }
88
+ else {
89
+ const shown = first.url !== null ? { source: first, name: describe(beforeLabel) } : { source: second, name: describe(afterLabel) };
90
+ body = (_jsxs(_Fragment, { children: [_jsx(ImageThumb, { source: shown.source, alt: shown.name, className: "max-h-70 max-w-full object-contain" }), _jsx("span", { className: "px-2 text-xs text-text-faint", children: t('blocks.compare.missing') })] }));
91
+ }
92
+ return (_jsxs("figure", { className: "flex flex-col items-start gap-1.5 px-2", children: [body, caption !== '' && _jsx("figcaption", { className: "text-xs text-text-muted", children: children })] }));
93
+ }
94
+ /* A chip on the picture gets a ground of its own, since a pill's alpha ground would vanish over an image. */
95
+ function CompareChip({ side, label }) {
96
+ return (_jsx("span", { className: clsx('pointer-events-none absolute top-2 z-20 max-w-2/5 truncate rounded-md bg-surface-raised', side === 'left' ? 'left-2' : 'right-2'), children: _jsx(Pill, { shape: "tag", size: "sm", tone: "muted", children: label }) }));
35
97
  }
36
98
  export function SourcesRenderer({ node, children }) {
37
99
  const { t } = useTranslation('agent-chat');
@@ -5,4 +5,6 @@ export declare function ItemRenderer({ node, children }: UiRendererProps<UiProps
5
5
  export declare function SwitchRenderer({ node, children, context }: UiRendererProps<UiProps<'Switch'>>): import("react").JSX.Element;
6
6
  export declare function SliderRenderer({ node, context }: UiRendererProps<UiProps<'Slider'>>): import("react").JSX.Element;
7
7
  export declare function SegmentedRenderer({ node, context }: UiRendererProps<UiProps<'Segmented'>>): import("react").JSX.Element;
8
+ export declare function TextFieldRenderer({ node, children, context }: UiRendererProps<UiProps<'TextField'>>): import("react").JSX.Element;
9
+ export declare function SelectRenderer({ node, context }: UiRendererProps<UiProps<'Select'>>): import("react").JSX.Element;
8
10
  export declare function ButtonRenderer({ node, children, context }: UiRendererProps<UiProps<'Button'>>): import("react").JSX.Element;
@@ -1,21 +1,13 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { createContext, useContext } from 'react';
2
+ import { createContext, useContext, useId } from 'react';
3
3
  import clsx from 'clsx';
4
4
  import { useTranslation } from 'react-i18next';
5
- import { Button, Checkbox, Segmented, Slider, Switch, Tooltip } from '@adecore/ui';
6
- import { uiChildrenOf, uiNodeLabel, uiWithUnit } from '../node-text.js';
5
+ import { Button, Checkbox, Input, Segmented, Select, Slider, Switch, Tooltip } from '@adecore/ui';
6
+ import { formatNumber } from '@adecore/ui/format';
7
+ import { uiChildrenOf, uiNodeLabel, uiOwnLabel, uiWithUnit } from '../node-text.js';
7
8
  import { useTruncated } from '../use-truncated.js';
9
+ import { bindingOf, PENDING } from './binding.js';
8
10
  const ChecklistContext = createContext({ values: [], enabled: false, toggle: () => undefined });
9
- /*
10
- * The binding of an input, or null for a read-only node. An input works once its own node closed and
11
- * only through its binding, so the value never leaves the block until a Choice carries it.
12
- */
13
- function bindingOf(node, context) {
14
- const binding = node.bindings.value;
15
- return binding === undefined || !node.complete || context.answer !== null ? null : binding;
16
- }
17
- /* An input whose node has not closed yet stands at 60% and takes no pointer. */
18
- const PENDING = 'pointer-events-none opacity-60';
19
11
  /* The rows of a checklist as one named group. */
20
12
  function ChecklistGroup({ pending, children }) {
21
13
  const { t } = useTranslation('agent-chat');
@@ -68,6 +60,41 @@ export function SegmentedRenderer({ node, context }) {
68
60
  }
69
61
  } }) }));
70
62
  }
63
+ /* The longest a text field takes when its agent names none; the schema allows up to 1024. */
64
+ const DEFAULT_MAX_LENGTH = 256;
65
+ /* The count under a field appears once the field is this full, and never nags before. */
66
+ const COUNT_FROM = 0.8;
67
+ /*
68
+ * A line of text: the label above the field, which stops taking characters at its limit. Nothing here binds
69
+ * a key or submits on Enter, so the value reaches the agent only through a Choice.
70
+ */
71
+ export function TextFieldRenderer({ node, children, context }) {
72
+ const { t } = useTranslation('agent-chat');
73
+ const id = useId();
74
+ const binding = bindingOf(node, context);
75
+ const { maxLength = DEFAULT_MAX_LENGTH, placeholder } = node.props;
76
+ const value = binding?.value ?? node.props.value;
77
+ const countId = `${id}-count`;
78
+ const counting = value.length >= maxLength * COUNT_FROM;
79
+ return (_jsxs("div", { className: clsx('flex flex-col gap-1 px-2', !node.complete && PENDING), children: [_jsx("label", { htmlFor: id, className: "text-sm text-text-muted", children: children }), _jsx(Input, { id: id, value: value, size: "sm", maxLength: maxLength, placeholder: placeholder, disabled: binding === null, "aria-describedby": counting ? countId : undefined, className: "max-w-96", onChange: (event) => binding?.onValueChange(event.target.value) }), counting && (_jsx("span", { id: countId, className: "text-xs text-text-faint tabular-nums", children: t('blocks.textField.count', { used: formatNumber(value.length), max: formatNumber(maxLength) }) }))] }));
80
+ }
81
+ /*
82
+ * Left aligned, never the full width. Like Segmented, each Option is an item by its place, since a value may be any scalar.
83
+ * The text beside its options is its label, drawn above it and read as its name.
84
+ */
85
+ export function SelectRenderer({ node, context }) {
86
+ const { t } = useTranslation('agent-chat');
87
+ const binding = bindingOf(node, context);
88
+ const current = binding?.value ?? node.props.value;
89
+ const options = uiChildrenOf(node, 'Option').map((option, index) => ({ id: String(index), label: uiNodeLabel(option), value: option.props.value }));
90
+ const label = uiOwnLabel(node);
91
+ return (_jsxs("div", { className: clsx('flex flex-col items-start gap-1 px-2', !node.complete && PENDING), children: [label !== '' && _jsx("span", { className: "text-sm text-text-muted", children: label }), _jsx(Select, { value: options.find((option) => option.value === current)?.id ?? null, items: options.map((option) => ({ value: option.id, label: option.label })), label: label === '' ? t('blocks.options') : label, disabled: binding === null || options.length === 0, onValueChange: (id) => {
92
+ const option = options[Number(id)];
93
+ if (option !== undefined) {
94
+ binding?.onValueChange(option.value);
95
+ }
96
+ } })] }));
97
+ }
71
98
  /* Runs its local action once its own node closed; like an input, it closes once the block is answered. A label cut to the button's one line reads whole in its tooltip. */
72
99
  export function ButtonRenderer({ node, children, context }) {
73
100
  const enabled = node.onAction !== undefined && node.complete && context.answer === null && node.props.disabled !== true;
@@ -0,0 +1,20 @@
1
+ export declare const MAX_LIVE_MAPS = 4;
2
+ export type MapSlotState = 'idle' | 'granted' | 'queued';
3
+ export declare class MapSlots {
4
+ private readonly capacity;
5
+ private readonly held;
6
+ private readonly queue;
7
+ private readonly listeners;
8
+ constructor(capacity: number);
9
+ request(id: string): void;
10
+ release(id: string): void;
11
+ take(id: string): void;
12
+ stateOf(id: string): MapSlotState;
13
+ subscribe: (listener: () => void) => (() => void);
14
+ private changed;
15
+ }
16
+ export declare function pageMapSlots(): MapSlots;
17
+ export declare function useMapSlot(id: string, wanted: boolean, slots?: MapSlots): {
18
+ state: MapSlotState;
19
+ take(): void;
20
+ };
@@ -0,0 +1,87 @@
1
+ import { useEffect, useSyncExternalStore } from 'react';
2
+ /*
3
+ * A map holds a WebGL context for as long as it is drawn, and a browser keeps only so many of them before
4
+ * it drops the oldest without a word. This is the page's budget of them for maps, kept under what the
5
+ * terminals of `@adecore/terminal` take from the same pool.
6
+ */
7
+ export const MAX_LIVE_MAPS = 4;
8
+ /*
9
+ * Who may draw a map right now. A map in view asks for a slot and gets one while there are fewer than
10
+ * `capacity` held; the others queue, in the order they asked, and are granted a slot as one is given back.
11
+ * A person can `take` one for a queued map: the longest-held map gives its slot up and queues first.
12
+ */
13
+ export class MapSlots {
14
+ capacity;
15
+ held = [];
16
+ queue = [];
17
+ listeners = new Set();
18
+ constructor(capacity) {
19
+ this.capacity = capacity;
20
+ }
21
+ request(id) {
22
+ if (this.held.includes(id) || this.queue.includes(id)) {
23
+ return;
24
+ }
25
+ (this.held.length < this.capacity ? this.held : this.queue).push(id);
26
+ this.changed();
27
+ }
28
+ release(id) {
29
+ const wasHeld = this.held.includes(id);
30
+ const wasQueued = this.queue.includes(id);
31
+ if (!wasHeld && !wasQueued) {
32
+ return;
33
+ }
34
+ this.held.splice(0, this.held.length, ...this.held.filter((entry) => entry !== id));
35
+ this.queue.splice(0, this.queue.length, ...this.queue.filter((entry) => entry !== id));
36
+ while (this.held.length < this.capacity && this.queue.length > 0) {
37
+ this.held.push(this.queue.shift());
38
+ }
39
+ this.changed();
40
+ }
41
+ take(id) {
42
+ if (this.held.includes(id)) {
43
+ return;
44
+ }
45
+ const at = this.queue.indexOf(id);
46
+ if (at >= 0) {
47
+ this.queue.splice(at, 1);
48
+ }
49
+ if (this.held.length >= this.capacity) {
50
+ this.queue.unshift(this.held.shift());
51
+ }
52
+ this.held.push(id);
53
+ this.changed();
54
+ }
55
+ stateOf(id) {
56
+ return this.held.includes(id) ? 'granted' : this.queue.includes(id) ? 'queued' : 'idle';
57
+ }
58
+ subscribe = (listener) => {
59
+ this.listeners.add(listener);
60
+ return () => {
61
+ this.listeners.delete(listener);
62
+ };
63
+ };
64
+ changed() {
65
+ for (const listener of [...this.listeners]) {
66
+ listener();
67
+ }
68
+ }
69
+ }
70
+ let page = null;
71
+ /* The one budget of the page, made on first use. */
72
+ export function pageMapSlots() {
73
+ page ??= new MapSlots(MAX_LIVE_MAPS);
74
+ return page;
75
+ }
76
+ /* A map's place in the budget while it `wanted`, which is while it is in view; gone from it otherwise and on unmount. */
77
+ export function useMapSlot(id, wanted, slots = pageMapSlots()) {
78
+ const state = useSyncExternalStore(slots.subscribe, () => slots.stateOf(id), () => 'idle');
79
+ useEffect(() => {
80
+ if (!wanted) {
81
+ return;
82
+ }
83
+ slots.request(id);
84
+ return () => slots.release(id);
85
+ }, [slots, id, wanted]);
86
+ return { state, take: () => slots.take(id) };
87
+ }
@@ -0,0 +1,18 @@
1
+ import type { LngLatBoundsLike, MapOptions } from 'maplibre-gl';
2
+ export type MapStyle = Exclude<MapOptions['style'], string | undefined>;
3
+ export interface MapColors {
4
+ land: string;
5
+ water: string;
6
+ }
7
+ export interface MapPoint {
8
+ id: string;
9
+ lat: number;
10
+ lon: number;
11
+ }
12
+ export declare const MAP_MAX_ZOOM: {
13
+ readonly tiles: 16;
14
+ readonly outline: 6;
15
+ };
16
+ export declare function baseMapStyle({ land, water }: MapColors): MapStyle;
17
+ export declare function localizedTextField(original: unknown, field: string): unknown;
18
+ export declare function boundsOf(points: readonly MapPoint[]): LngLatBoundsLike | null;
@@ -0,0 +1,72 @@
1
+ import { BASE_LAND_JSON } from './base-land.js';
2
+ /* 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. */
3
+ export const MAP_MAX_ZOOM = { tiles: 16, outline: 6 };
4
+ /* The map a page draws without tiles: land over water, from the outline of the world that ships with the package. */
5
+ export function baseMapStyle({ land, water }) {
6
+ return {
7
+ version: 8,
8
+ sources: { land: { type: 'geojson', data: JSON.parse(BASE_LAND_JSON) } },
9
+ layers: [
10
+ { id: 'water', type: 'background', paint: { 'background-color': water } },
11
+ { id: 'land', type: 'fill', source: 'land', paint: { 'fill-color': land } }
12
+ ]
13
+ };
14
+ }
15
+ const TOKEN = /\{([^{}]+)\}/g;
16
+ /* A `{name}` text field as the expression it stands for, since a field combined with another expression is no longer read for tokens. */
17
+ function tokenExpression(text) {
18
+ const parts = [];
19
+ let at = 0;
20
+ for (const match of text.matchAll(TOKEN)) {
21
+ if (match.index > at) {
22
+ parts.push(text.slice(at, match.index));
23
+ }
24
+ parts.push(['to-string', ['coalesce', ['get', match[1]], '']]);
25
+ at = match.index + match[0].length;
26
+ }
27
+ if (parts.length === 0) {
28
+ return text;
29
+ }
30
+ if (at < text.length) {
31
+ parts.push(text.slice(at));
32
+ }
33
+ return parts.length === 1 && typeof parts[0] !== 'string' ? parts[0] : ['concat', ...parts];
34
+ }
35
+ /*
36
+ * A label that prefers the property `field` of a feature and falls back to what the style wrote. A text
37
+ * field that is neither a string nor an expression (a legacy zoom function) is left as it is.
38
+ */
39
+ export function localizedTextField(original, field) {
40
+ const fallback = typeof original === 'string' ? tokenExpression(original) : original;
41
+ if (typeof fallback !== 'string' && !Array.isArray(fallback)) {
42
+ return original;
43
+ }
44
+ return ['coalesce', ['get', field], fallback];
45
+ }
46
+ /*
47
+ * The bounds that hold every point, or null for none. Longitudes go round the globe, so the bounds leave out
48
+ * the widest gap between two neighbors: points on both sides of the date line span a few degrees, not 360.
49
+ * The east edge may then pass 180, which the map takes.
50
+ */
51
+ export function boundsOf(points) {
52
+ if (points.length === 0) {
53
+ return null;
54
+ }
55
+ const lons = points.map((point) => point.lon).sort((a, b) => a - b);
56
+ let widest = lons[0] + 360 - lons[lons.length - 1];
57
+ let west = lons[0];
58
+ let east = lons[lons.length - 1];
59
+ for (let i = 1; i < lons.length; i++) {
60
+ const gap = lons[i] - lons[i - 1];
61
+ if (gap > widest) {
62
+ widest = gap;
63
+ west = lons[i];
64
+ east = lons[i - 1] + 360;
65
+ }
66
+ }
67
+ const lats = points.map((point) => point.lat);
68
+ return [
69
+ [west, Math.min(...lats)],
70
+ [east, Math.max(...lats)]
71
+ ];
72
+ }
@@ -0,0 +1,15 @@
1
+ import 'maplibre-gl/dist/maplibre-gl.css';
2
+ import { type UiMapSource } from '../../../../host.js';
3
+ import { type MapPoint } from './map-style.js';
4
+ export type MapViewStatus = 'loading' | 'tiles' | 'base' | 'offline' | 'unavailable' | 'failed' | 'lost';
5
+ export interface MapMarker extends MapPoint {
6
+ number: number;
7
+ }
8
+ export interface MapViewProps {
9
+ markers: readonly MapMarker[];
10
+ source: UiMapSource;
11
+ selected: string | null;
12
+ onPick: ((id: string) => void) | null;
13
+ onStatus: (status: MapViewStatus) => void;
14
+ }
15
+ export declare function MapView({ markers, source, selected, onPick, onStatus }: MapViewProps): import("react").JSX.Element;
@@ -0,0 +1,213 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import 'maplibre-gl/dist/maplibre-gl.css';
3
+ import { useEffect, useRef, useState } from 'react';
4
+ import { Map as MapLibreMap, Marker, setWorkerUrl } from 'maplibre-gl';
5
+ import { Minus, Plus } from 'lucide-react';
6
+ import { useTranslation } from 'react-i18next';
7
+ import { ButtonGroup, IconButton } from '@adecore/ui';
8
+ import { useChatHost } from '../../../../host.js';
9
+ import { baseMapStyle, boundsOf, localizedTextField, MAP_MAX_ZOOM } from './map-style.js';
10
+ const MARKER = 'flex size-5 items-center justify-center rounded-full bg-map-marker text-xs text-map-marker-text tabular-nums shadow-raised';
11
+ const PICKED = 'z-1 bg-accent text-accent-text outline-2 outline-surface';
12
+ // A tile that fails this many times with none loading in between leaves the map empty, so the outline stands in.
13
+ const TILE_ERRORS_IN_A_ROW = 4;
14
+ let workerReady = false;
15
+ /* 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. */
16
+ function ensureBundledWorker() {
17
+ if (!workerReady) {
18
+ setWorkerUrl(new URL('maplibre-gl/dist/maplibre-gl-worker.mjs', import.meta.url).href);
19
+ workerReady = true;
20
+ }
21
+ }
22
+ /* The grounds of the built-in map are theme tokens, which the page resolves; the style needs plain colors. */
23
+ function readColors(element) {
24
+ const style = getComputedStyle(element);
25
+ return { land: style.getPropertyValue('--map-land').trim() || 'transparent', water: style.getPropertyValue('--map-water').trim() || 'transparent' };
26
+ }
27
+ /*
28
+ * The map itself, in the chunk that holds MapLibre. It draws the host's tiles when the source has them
29
+ * and the outline of the world otherwise, also when the tiles turn out to be out of reach. Its
30
+ * canvas is hidden from assistive technology, since the list of places beside it says the same.
31
+ */
32
+ export function MapView({ markers, source, selected, onPick, onStatus }) {
33
+ const { t, i18n } = useTranslation('agent-chat');
34
+ const mode = useChatHost().code.useMode();
35
+ const element = useRef(null);
36
+ const [map, setMap] = useState(null);
37
+ const [outline, setOutline] = useState(false);
38
+ const latest = useRef({ source, onStatus, onPick });
39
+ useEffect(() => {
40
+ latest.current = { source, onStatus, onPick };
41
+ });
42
+ const language = i18n.language.split('-')[0] ?? 'en';
43
+ const styleKey = `${mode}:${source.state === 'tiles' ? source.styleUrl(mode) : 'outline'}`;
44
+ const pointsKey = JSON.stringify(markers.map((marker) => [marker.id, marker.lat, marker.lon]));
45
+ useEffect(() => {
46
+ const container = element.current;
47
+ if (container === null) {
48
+ return;
49
+ }
50
+ const current = latest.current.source;
51
+ const colors = readColors(container);
52
+ const offline = current.state === 'tiles' && typeof navigator !== 'undefined' && navigator.onLine === false;
53
+ const tiles = current.state === 'tiles' && !offline;
54
+ let created;
55
+ try {
56
+ ensureBundledWorker();
57
+ created = new MapLibreMap({
58
+ container,
59
+ style: current.state === 'tiles' && tiles ? current.styleUrl(mode) : baseMapStyle(colors),
60
+ center: [0, 20],
61
+ zoom: 1,
62
+ keyboard: false,
63
+ cooperativeGestures: true,
64
+ attributionControl: false,
65
+ dragRotate: false,
66
+ pitchWithRotate: false,
67
+ locale: {
68
+ 'CooperativeGesturesHandler.WindowsHelpText': t('blocks.map.scrollHint'),
69
+ 'CooperativeGesturesHandler.MacHelpText': t('blocks.map.scrollHint'),
70
+ 'CooperativeGesturesHandler.MobileHelpText': t('blocks.map.touchHint')
71
+ },
72
+ transformRequest: (url) => {
73
+ const now = latest.current.source;
74
+ return now.state === 'tiles' && now.transformRequest ? now.transformRequest(url) : { url };
75
+ }
76
+ });
77
+ }
78
+ catch {
79
+ latest.current.onStatus('failed');
80
+ return;
81
+ }
82
+ created.touchZoomRotate.disableRotation();
83
+ const canvas = created.getCanvas();
84
+ canvas.tabIndex = -1;
85
+ const wantsTiles = current.state === 'tiles';
86
+ let drawing = tiles ? 'tiles' : 'outline';
87
+ // Why the outline is drawn in place of tiles that were asked for; null while the tiles are.
88
+ let reason = wantsTiles && !tiles ? 'offline' : null;
89
+ let styleLoaded = false;
90
+ let restoring = false;
91
+ let tileErrors = 0;
92
+ setOutline(!tiles);
93
+ latest.current.onStatus(tiles ? 'loading' : wantsTiles ? 'offline' : 'base');
94
+ const drawOutline = (why) => {
95
+ if (drawing === 'outline' || !wantsTiles) {
96
+ return;
97
+ }
98
+ drawing = 'outline';
99
+ reason = why;
100
+ setOutline(true);
101
+ created.setStyle(baseMapStyle(colors));
102
+ latest.current.onStatus(why);
103
+ };
104
+ const drawTiles = () => {
105
+ if (current.state !== 'tiles') {
106
+ return;
107
+ }
108
+ drawing = 'tiles';
109
+ reason = null;
110
+ styleLoaded = false;
111
+ restoring = true;
112
+ tileErrors = 0;
113
+ setOutline(false);
114
+ created.setStyle(current.styleUrl(mode));
115
+ latest.current.onStatus('loading');
116
+ };
117
+ const unreachable = () => (typeof navigator !== 'undefined' && navigator.onLine === false ? 'offline' : 'unavailable');
118
+ created.on('style.load', () => {
119
+ if (drawing !== 'tiles') {
120
+ return;
121
+ }
122
+ styleLoaded = true;
123
+ if (restoring) {
124
+ restoring = false;
125
+ latest.current.onStatus('tiles');
126
+ }
127
+ const field = current.state === 'tiles' ? current.labelField?.(language) : undefined;
128
+ if (field === undefined) {
129
+ return;
130
+ }
131
+ for (const layer of created.getStyle().layers) {
132
+ const textField = layer.type === 'symbol' ? layer.layout?.['text-field'] : undefined;
133
+ if (textField !== undefined) {
134
+ created.setLayoutProperty(layer.id, 'text-field', localizedTextField(textField, field));
135
+ }
136
+ }
137
+ });
138
+ created.on('load', () => {
139
+ if (drawing === 'tiles') {
140
+ latest.current.onStatus('tiles');
141
+ }
142
+ });
143
+ created.on('sourcedata', (event) => {
144
+ if ('tile' in event) {
145
+ tileErrors = 0;
146
+ }
147
+ });
148
+ created.on('error', (event) => {
149
+ if (drawing !== 'tiles') {
150
+ return;
151
+ }
152
+ // Before the style is here there is nothing to draw. After it, a tile the host refuses (a ticket
153
+ // that ran out) or several in a row leave an empty map; a single one the map retries itself.
154
+ const status = event.error?.status;
155
+ if (!styleLoaded || status === 401 || status === 403 || ++tileErrors >= TILE_ERRORS_IN_A_ROW) {
156
+ drawOutline(unreachable());
157
+ }
158
+ });
159
+ const goOffline = () => drawOutline('offline');
160
+ const goOnline = () => {
161
+ if (reason === 'offline') {
162
+ drawTiles();
163
+ }
164
+ };
165
+ const contextLost = () => latest.current.onStatus('lost');
166
+ if (wantsTiles) {
167
+ window.addEventListener('offline', goOffline);
168
+ window.addEventListener('online', goOnline);
169
+ }
170
+ canvas.addEventListener('webglcontextlost', contextLost);
171
+ setMap(created);
172
+ return () => {
173
+ window.removeEventListener('offline', goOffline);
174
+ window.removeEventListener('online', goOnline);
175
+ canvas.removeEventListener('webglcontextlost', contextLost);
176
+ setMap(null);
177
+ created.remove();
178
+ };
179
+ // The map is made again when the style it draws changes, and not for any other change of the source.
180
+ // oxlint-disable-next-line react-hooks/exhaustive-deps
181
+ }, [styleKey, language]);
182
+ useEffect(() => {
183
+ const bounds = boundsOf(markers);
184
+ if (map === null || bounds === null) {
185
+ return;
186
+ }
187
+ map.fitBounds(bounds, { padding: 32, maxZoom: outline ? MAP_MAX_ZOOM.outline : MAP_MAX_ZOOM.tiles, duration: 0 });
188
+ // The points are compared by their coordinates, so a render that rebuilt the same list does not move the map.
189
+ // oxlint-disable-next-line react-hooks/exhaustive-deps
190
+ }, [map, pointsKey, outline]);
191
+ const pickable = onPick !== null;
192
+ useEffect(() => {
193
+ if (map === null) {
194
+ return;
195
+ }
196
+ const drawn = markers.map((marker) => {
197
+ const node = document.createElement('div');
198
+ node.setAttribute('aria-hidden', 'true');
199
+ node.dataset.mapMarker = marker.id;
200
+ node.className = `${MARKER} ${marker.id === selected ? PICKED : ''} ${pickable ? 'cursor-pointer' : ''}`;
201
+ node.textContent = String(marker.number);
202
+ node.addEventListener('click', () => latest.current.onPick?.(marker.id));
203
+ return new Marker({ element: node }).setLngLat([marker.lon, marker.lat]).addTo(map);
204
+ });
205
+ return () => {
206
+ for (const marker of drawn) {
207
+ marker.remove();
208
+ }
209
+ };
210
+ // oxlint-disable-next-line react-hooks/exhaustive-deps
211
+ }, [map, pointsKey, selected, pickable]);
212
+ return (_jsxs("div", { className: "absolute inset-0", children: [_jsx("div", { ref: element, "aria-hidden": true, className: "size-full" }), _jsxs(ButtonGroup, { className: "absolute top-2 right-2 rounded-md border border-border bg-surface-raised shadow-raised", children: [_jsx(IconButton, { icon: Plus, size: "sm", label: t('blocks.map.zoomIn'), onClick: () => map?.zoomIn() }), _jsx(IconButton, { icon: Minus, size: "sm", label: t('blocks.map.zoomOut'), onClick: () => map?.zoomOut() })] })] }));
213
+ }
@@ -0,0 +1,3 @@
1
+ import type { UiProps } from '@adecore/intelligent-ui';
2
+ import type { UiRendererProps } from '../render-context.js';
3
+ export declare function MapRenderer({ node, children, context }: UiRendererProps<UiProps<'Map'>>): import("react").JSX.Element;