@adecore/agents-react 0.21.0 → 0.21.2

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 (137) hide show
  1. package/dist/agents/status-look.js +2 -3
  2. package/dist/chat/chat-client.d.ts +1 -1
  3. package/dist/chat/chat-client.js +2 -2
  4. package/dist/chat/drafts.js +6 -12
  5. package/dist/chat/logic/ansi.js +5 -1
  6. package/dist/chat/logic/fork.js +8 -9
  7. package/dist/chat/logic/limit.js +17 -14
  8. package/dist/chat/logic/resume-compaction.js +2 -11
  9. package/dist/chat/logic/scrubber.js +9 -18
  10. package/dist/chat/logic/timeline.js +15 -10
  11. package/dist/chat/logic/tool-catalog.js +3 -4
  12. package/dist/chat/mentions.js +5 -6
  13. package/dist/chat/runtime-modes.js +2 -4
  14. package/dist/chat/stash.js +1 -3
  15. package/dist/chat/thumbnails.js +3 -4
  16. package/dist/chat/timeline-scroll.js +39 -45
  17. package/dist/chat/ui/BookmarkSubmenu.js +1 -5
  18. package/dist/chat/ui/CodeBlock.js +2 -3
  19. package/dist/chat/ui/Composer.js +57 -69
  20. package/dist/chat/ui/Markdown.js +11 -11
  21. package/dist/chat/ui/Pickers.js +5 -9
  22. package/dist/chat/ui/QuoteButton.js +2 -3
  23. package/dist/chat/ui/RunSettings.js +8 -2
  24. package/dist/chat/ui/Scrubber.js +2 -3
  25. package/dist/chat/ui/SubagentControls.js +1 -3
  26. package/dist/chat/ui/Timeline.js +15 -18
  27. package/dist/chat/ui/TimelineMenu.js +3 -9
  28. package/dist/chat/ui/UnifiedDiff.js +3 -5
  29. package/dist/chat/ui/VisualFrame.js +3 -4
  30. package/dist/chat/ui/composer/keys.js +19 -11
  31. package/dist/chat/ui/intelligent-ui/UiBlockFrame.d.ts +14 -1
  32. package/dist/chat/ui/intelligent-ui/UiBlockFrame.js +47 -5
  33. package/dist/chat/ui/intelligent-ui/UiReply.d.ts +3 -1
  34. package/dist/chat/ui/intelligent-ui/UiReply.js +34 -30
  35. package/dist/chat/ui/intelligent-ui/block-local.d.ts +9 -0
  36. package/dist/chat/ui/intelligent-ui/block-local.js +30 -1
  37. package/dist/chat/ui/intelligent-ui/node-text.d.ts +6 -0
  38. package/dist/chat/ui/intelligent-ui/node-text.js +54 -0
  39. package/dist/chat/ui/intelligent-ui/registry.js +2 -1
  40. package/dist/chat/ui/intelligent-ui/render-context.d.ts +4 -1
  41. package/dist/chat/ui/intelligent-ui/renderers/chart.js +5 -7
  42. package/dist/chat/ui/intelligent-ui/renderers/choices.js +6 -2
  43. package/dist/chat/ui/intelligent-ui/renderers/content.js +6 -13
  44. package/dist/chat/ui/intelligent-ui/renderers/data.js +8 -15
  45. package/dist/chat/ui/intelligent-ui/renderers/inputs.d.ts +1 -0
  46. package/dist/chat/ui/intelligent-ui/renderers/inputs.js +16 -4
  47. package/dist/chat/ui/intelligent-ui/renderers/links.js +2 -1
  48. package/dist/chat/ui/intelligent-ui/use-ui-links.d.ts +4 -4
  49. package/dist/chat/ui/intelligent-ui/use-ui-links.js +29 -10
  50. package/dist/chat/ui/intelligent-ui/use-ui-queries.d.ts +2 -1
  51. package/dist/chat/ui/intelligent-ui/use-ui-queries.js +34 -21
  52. package/dist/chat/ui/rows/MessageRows.js +3 -4
  53. package/dist/chat/ui/rows/WorkRows.d.ts +4 -3
  54. package/dist/chat/ui/rows/WorkRows.js +19 -14
  55. package/dist/chat/visual-viewport.d.ts +1 -1
  56. package/dist/chat/visual-viewport.js +9 -8
  57. package/dist/locales/en/agent-chat.json +23 -4
  58. package/dist/locales/nl/agent-chat.json +23 -4
  59. package/dist/port-transport.js +4 -8
  60. package/dist/state/chats.js +15 -15
  61. package/dist/state/usage.js +44 -49
  62. package/dist/storage.js +7 -7
  63. package/dist/theme.css +6 -14
  64. package/dist/usage/LimitsList.js +1 -2
  65. package/dist/usage/UsageBreakdown.js +1 -3
  66. package/dist/usage/UsageDialog.js +2 -4
  67. package/dist/usage/format.js +6 -6
  68. package/dist/usage/money.js +1 -5
  69. package/dist/usage/summary.js +2 -4
  70. package/package.json +24 -29
  71. package/src/agents/status-look.ts +2 -3
  72. package/src/chat/chat-client.ts +2 -3
  73. package/src/chat/drafts.ts +6 -14
  74. package/src/chat/logic/ansi.ts +5 -1
  75. package/src/chat/logic/fork.ts +8 -9
  76. package/src/chat/logic/limit.ts +17 -16
  77. package/src/chat/logic/resume-compaction.ts +2 -11
  78. package/src/chat/logic/scrubber.ts +13 -17
  79. package/src/chat/logic/timeline.ts +22 -11
  80. package/src/chat/logic/tool-catalog.ts +7 -6
  81. package/src/chat/mentions.ts +5 -6
  82. package/src/chat/runtime-modes.ts +2 -4
  83. package/src/chat/stash.ts +1 -3
  84. package/src/chat/thumbnails.ts +3 -4
  85. package/src/chat/timeline-scroll.ts +43 -52
  86. package/src/chat/ui/BookmarkSubmenu.tsx +1 -5
  87. package/src/chat/ui/CodeBlock.tsx +2 -3
  88. package/src/chat/ui/Composer.tsx +66 -86
  89. package/src/chat/ui/Markdown.tsx +13 -17
  90. package/src/chat/ui/Pickers.tsx +5 -9
  91. package/src/chat/ui/QuoteButton.tsx +2 -3
  92. package/src/chat/ui/RunSettings.tsx +9 -2
  93. package/src/chat/ui/Scrubber.tsx +2 -3
  94. package/src/chat/ui/SubagentControls.tsx +1 -3
  95. package/src/chat/ui/SubagentTimeline.tsx +1 -2
  96. package/src/chat/ui/Timeline.tsx +18 -20
  97. package/src/chat/ui/TimelineMenu.tsx +3 -9
  98. package/src/chat/ui/UnifiedDiff.tsx +3 -5
  99. package/src/chat/ui/VisualFrame.tsx +3 -4
  100. package/src/chat/ui/composer/keys.ts +21 -11
  101. package/src/chat/ui/intelligent-ui/UiBlockFrame.tsx +135 -5
  102. package/src/chat/ui/intelligent-ui/UiReply.tsx +48 -38
  103. package/src/chat/ui/intelligent-ui/block-local.ts +36 -1
  104. package/src/chat/ui/intelligent-ui/node-text.ts +60 -0
  105. package/src/chat/ui/intelligent-ui/registry.ts +2 -1
  106. package/src/chat/ui/intelligent-ui/render-context.ts +8 -2
  107. package/src/chat/ui/intelligent-ui/renderers/chart.tsx +5 -7
  108. package/src/chat/ui/intelligent-ui/renderers/choices.tsx +6 -2
  109. package/src/chat/ui/intelligent-ui/renderers/content.tsx +9 -14
  110. package/src/chat/ui/intelligent-ui/renderers/data.tsx +13 -19
  111. package/src/chat/ui/intelligent-ui/renderers/inputs.tsx +28 -5
  112. package/src/chat/ui/intelligent-ui/renderers/links.tsx +2 -1
  113. package/src/chat/ui/intelligent-ui/use-ui-links.ts +31 -11
  114. package/src/chat/ui/intelligent-ui/use-ui-queries.ts +48 -22
  115. package/src/chat/ui/rehype-chips.ts +1 -1
  116. package/src/chat/ui/rehype-fade.ts +1 -1
  117. package/src/chat/ui/remark-html-as-text.ts +1 -1
  118. package/src/chat/ui/rows/MessageRows.tsx +4 -5
  119. package/src/chat/ui/rows/WorkRows.tsx +86 -69
  120. package/src/chat/visual-viewport.ts +10 -8
  121. package/src/host.ts +1 -3
  122. package/src/locales/en/agent-chat.json +23 -4
  123. package/src/locales/nl/agent-chat.json +23 -4
  124. package/src/port-transport.ts +4 -8
  125. package/src/state/chats.ts +16 -15
  126. package/src/state/usage.ts +49 -60
  127. package/src/storage.ts +7 -7
  128. package/src/theme.css +6 -14
  129. package/src/usage/LimitsList.tsx +2 -4
  130. package/src/usage/UsageBreakdown.tsx +2 -5
  131. package/src/usage/UsageDialog.tsx +2 -4
  132. package/src/usage/format.ts +6 -6
  133. package/src/usage/money.ts +1 -5
  134. package/src/usage/summary.ts +2 -4
  135. package/dist/chat/ui/intelligent-ui/UiBlockFooter.d.ts +0 -15
  136. package/dist/chat/ui/intelligent-ui/UiBlockFooter.js +0 -50
  137. package/src/chat/ui/intelligent-ui/UiBlockFooter.tsx +0 -139
@@ -28,10 +28,9 @@ function personActsIn(frame: HTMLIFrameElement): boolean {
28
28
  }
29
29
 
30
30
  /*
31
- * One visual's page in a frame on the thread's own ground. The frame loads the app's sandbox host
32
- * page and the bridge hands it the page and the theme. The frame never gets `allow-same-origin`,
33
- * which keeps the page on an opaque origin even when the host page is served on the app's own. Its
34
- * box keeps one height while the page loads and when it fails, so nothing under it moves.
31
+ * One visual's page in a frame that loads the app's sandbox host page; the bridge hands it the page and
32
+ * the theme. The frame never gets `allow-same-origin`, which keeps the page on an opaque origin even
33
+ * when the host page is served on the app's own. Its box keeps one height while it loads or fails.
35
34
  */
36
35
  export function VisualFrame({ chatId, visual, fill = false, className }: VisualFrameProps) {
37
36
  const { t } = useTranslation('agent-chat');
@@ -60,25 +60,35 @@ export function enterAction(keys: EnterKeys, { inOpenFence, list, column }: Ente
60
60
  return list.empty ? 'leave-list' : 'continue-list';
61
61
  }
62
62
 
63
- /* Whether `pos` sits inside a fenced code block that has an opening fence and no closing one yet. */
64
- export function inOpenFence(tree: Tree, pos: number): boolean {
63
+ /* The innermost fenced code block around `pos`, if any. */
64
+ function enclosingFence(tree: Tree, pos: number): SyntaxNode | null {
65
65
  for (let node: SyntaxNode | null = tree.resolveInner(pos, -1); node !== null; node = node.parent) {
66
66
  if (node.name === 'FencedCode') {
67
- return pos > node.from && node.getChildren('CodeMark').length < 2;
67
+ return node;
68
68
  }
69
69
  }
70
- return false;
70
+ return null;
71
+ }
72
+
73
+ /* A fence nobody has closed yet has its opening mark only. */
74
+ function isOpenFence(fence: SyntaxNode): boolean {
75
+ return fence.getChildren('CodeMark').length < 2;
76
+ }
77
+
78
+ /* Whether `pos` sits inside a fenced code block that has an opening fence and no closing one yet. */
79
+ export function inOpenFence(tree: Tree, pos: number): boolean {
80
+ const fence = enclosingFence(tree, pos);
81
+ return fence !== null && pos > fence.from && isOpenFence(fence);
71
82
  }
72
83
 
73
84
  /* Whether `pos` sits in the body of a fenced code block, open or closed: below its opening line and before its end. */
74
85
  export function inFenceBody(tree: Tree, text: string, pos: number): boolean {
75
- for (let node: SyntaxNode | null = tree.resolveInner(pos, -1); node !== null; node = node.parent) {
76
- if (node.name === 'FencedCode') {
77
- const openingEnd = text.indexOf('\n', node.from);
78
- return openingEnd !== -1 && pos > openingEnd && (pos < node.to || node.getChildren('CodeMark').length < 2);
79
- }
86
+ const fence = enclosingFence(tree, pos);
87
+ if (fence === null) {
88
+ return false;
80
89
  }
81
- return false;
90
+ const openingEnd = text.indexOf('\n', fence.from);
91
+ return openingEnd !== -1 && pos > openingEnd && (pos < fence.to || isOpenFence(fence));
82
92
  }
83
93
 
84
94
  /* The spaces Tab inserts at `column`, up to the next stop of four, the way a code editor lines up. */
@@ -97,7 +107,7 @@ export function inCode(tree: Tree, text: string, pos: number): boolean {
97
107
  let blockFrom: number | null = null;
98
108
  for (let node: SyntaxNode | null = tree.resolveInner(pos, -1); node !== null; node = node.parent) {
99
109
  if (node.name === 'FencedCode') {
100
- return pos > node.from && (pos < node.to || node.getChildren('CodeMark').length < 2);
110
+ return pos > node.from && (pos < node.to || isOpenFence(node));
101
111
  }
102
112
  if (node.name === 'InlineCode' && pos > node.from && pos < node.to) {
103
113
  return true;
@@ -1,11 +1,141 @@
1
- import { useLayoutEffect, useRef, useState, type ReactNode, type RefObject } from 'react';
1
+ import { useId, useLayoutEffect, useRef, useState, type ReactNode, type RefObject } from 'react';
2
2
  import clsx from 'clsx';
3
3
  import { useTranslation } from 'react-i18next';
4
4
  import type { UiViewNode } from '@adecore/intelligent-ui';
5
- import { Skeleton } from '@adecore/ui';
6
- import { uiBlockHead } from './node-text';
7
- import { UiHeadContext } from './render-context';
8
- import { UiBlockFooter, type UiBlockFooterProps } from './UiBlockFooter';
5
+ import { Lock, Radio, TriangleAlert, Wrench } from 'lucide-react';
6
+ import { Icon, Skeleton, Spinner, Tooltip, useTickingText } from '@adecore/ui';
7
+ import { formatAgo, formatMoment } from '@adecore/ui/format';
8
+ import { uiBlockHead, uiReasonText } from './node-text';
9
+ import { UiHeadContext, type UiLiveStatus } from './render-context';
10
+ import { useLater } from './use-later';
11
+
12
+ /* Something the compiler repaired, such as a prop it dropped; never an error to a person. */
13
+ export interface UiBlockFix {
14
+ code: string;
15
+ message: string;
16
+ }
17
+
18
+ export interface UiBlockFooterProps {
19
+ live?: UiLiveStatus | null;
20
+ /* The label of the Choice that answered the block, and when, in milliseconds since the epoch. */
21
+ answered?: { label: string; at: number } | null;
22
+ fixes?: readonly UiBlockFix[];
23
+ /* Why the whole block is drawn as its text, such as that it is too large. */
24
+ shownAsText?: string | null;
25
+ }
26
+
27
+ const NO_FIXES: readonly UiBlockFix[] = [];
28
+ const READING_SPINNER_MS = 1000;
29
+
30
+ /* The one sign of a live block: what it reads, and when it last read it. A reading spins only after a second. */
31
+ function LivePart({ live }: { live: UiLiveStatus }) {
32
+ const { t } = useTranslation('agent-chat');
33
+ const slow = useLater(live.state === 'reading', READING_SPINNER_MS);
34
+ const { readAt } = live;
35
+ const fresh = live.state === 'fresh' || live.state === 'reading';
36
+ const ago = useTickingText(() => {
37
+ if (readAt === null) {
38
+ return '';
39
+ }
40
+ const time = formatAgo(Date.now() - readAt);
41
+ return fresh ? t('blocks.live.read', { time }) : time;
42
+ });
43
+ const source = live.source ?? live.sources[0] ?? '';
44
+ const icon = slow ? (
45
+ <Spinner size={12} />
46
+ ) : live.state === 'failed' ? (
47
+ <Icon icon={TriangleAlert} size={12} className="text-status-needs-you" />
48
+ ) : live.state === 'refused' ? (
49
+ <Icon icon={Lock} size={12} />
50
+ ) : (
51
+ <Icon icon={Radio} size={12} className="text-status-idle" />
52
+ );
53
+ const words =
54
+ live.state === 'failed'
55
+ ? t('blocks.live.failed', { source })
56
+ : live.state === 'refused'
57
+ ? t('blocks.live.refused', { source })
58
+ : t('blocks.live.label');
59
+ const reason = live.state === 'failed' || live.state === 'refused' ? uiReasonText(t, live.code, live.reason) : undefined;
60
+ const hint = reason !== undefined ? reason : live.sources.length > 0 ? t('blocks.live.sources', { sources: live.sources.join(', ') }) : null;
61
+ const body = (
62
+ <span tabIndex={hint === null ? undefined : 0} className="flex items-center gap-1.5">
63
+ {icon}
64
+ <span>{words}</span>
65
+ {readAt !== null && live.state !== 'refused' && (
66
+ <>
67
+ <span aria-hidden>·</span>
68
+ <time dateTime={new Date(readAt).toISOString()}>
69
+ <span ref={ago} className="tabular-nums" />
70
+ </time>
71
+ </>
72
+ )}
73
+ {readAt === null && fresh && (
74
+ <>
75
+ <span aria-hidden>·</span>
76
+ <span>{t('blocks.live.notRead')}</span>
77
+ </>
78
+ )}
79
+ </span>
80
+ );
81
+ return hint === null ? body : <Tooltip label={hint}>{body}</Tooltip>;
82
+ }
83
+
84
+ /*
85
+ * The line under a block, only when it has something to say: live, answered, repaired or drawn as
86
+ * text, in that order. A hairline of the soft border runs the full width of the card over it.
87
+ */
88
+ function UiBlockFooter({ live = null, answered = null, fixes = NO_FIXES, shownAsText = null }: UiBlockFooterProps) {
89
+ const { t } = useTranslation('agent-chat');
90
+ const [fixesOpen, setFixesOpen] = useState(false);
91
+ const listId = useId();
92
+ if (live === null && answered === null && fixes.length === 0 && shownAsText === null) {
93
+ return null;
94
+ }
95
+ return (
96
+ <div className="-mx-2.25 mt-2.25 -mb-2.25 flex flex-col gap-1.5 border-t border-border-soft px-4.25 py-2 text-xs text-text-faint">
97
+ <div className="flex flex-wrap items-center gap-x-3 gap-y-1">
98
+ {live !== null && <LivePart live={live} />}
99
+ {answered !== null && (
100
+ <span className="flex items-center gap-1.5">
101
+ <span>{t('blocks.answered', { label: answered.label })}</span>
102
+ <span aria-hidden>·</span>
103
+ <time dateTime={new Date(answered.at).toISOString()}>{formatMoment(answered.at)}</time>
104
+ </span>
105
+ )}
106
+ {fixes.length > 0 && (
107
+ <button
108
+ type="button"
109
+ aria-expanded={fixesOpen}
110
+ aria-controls={listId}
111
+ className="flex items-center gap-1.5 rounded-sm hover:text-text-muted"
112
+ onClick={() => setFixesOpen(!fixesOpen)}
113
+ >
114
+ <Icon icon={Wrench} size={12} />
115
+ {t('blocks.fixes', { count: fixes.length })}
116
+ </button>
117
+ )}
118
+ {shownAsText !== null && (
119
+ <span className="flex items-center gap-1.5">
120
+ <span>{t('blocks.shownAsText')}</span>
121
+ <span aria-hidden>·</span>
122
+ <span>{shownAsText}</span>
123
+ </span>
124
+ )}
125
+ </div>
126
+ {fixes.length > 0 && (
127
+ <ul id={listId} hidden={!fixesOpen} className="flex flex-col gap-0.5 text-text-muted">
128
+ {fixes.map((fix, index) => (
129
+ <li key={index} className="flex gap-2">
130
+ <span className="shrink-0 font-mono">{fix.code}</span>
131
+ <span className="min-w-0">{fix.message}</span>
132
+ </li>
133
+ ))}
134
+ </ul>
135
+ )}
136
+ </div>
137
+ );
138
+ }
9
139
 
10
140
  export interface UiBlockFrameProps extends UiBlockFooterProps {
11
141
  /* The evaluated top-level nodes, read for the Summary that heads the block. */
@@ -5,6 +5,7 @@ import {
5
5
  memo,
6
6
  useContext,
7
7
  useEffect,
8
+ useMemo,
8
9
  useRef,
9
10
  useState,
10
11
  useSyncExternalStore,
@@ -23,7 +24,7 @@ import {
23
24
  type UiNode,
24
25
  type UiViewNode
25
26
  } from '@adecore/intelligent-ui';
26
- import { ErrorBoundary } from '@adecore/ui';
27
+ import { ErrorBoundary, Tooltip } from '@adecore/ui';
27
28
  import type { ChatUiAnswer, ChatUiQueryState } from '@adecore/agent-contracts';
28
29
  import { chatHost } from '../../../host';
29
30
  import { UiReplyNavigationContext } from '../reply-context';
@@ -31,20 +32,19 @@ import { ReplyMarkdown } from '../Markdown';
31
32
  import { rehypeFadeWords } from '../rehype-fade';
32
33
  import { UI_RENDERERS } from './registry';
33
34
  import type { UiRenderContext, UiRenderer } from './render-context';
35
+ import { UiPageMemory } from './block-local';
34
36
  import { UiBlockFrame } from './UiBlockFrame';
35
37
  import { UiFallbackPart } from './UiFallbackPart';
36
38
  import { useUiLinks } from './use-ui-links';
37
39
  import { useUiQueries, useUiLiveValues } from './use-ui-queries';
38
40
  import { UiSourceCitation } from './renderers/content';
39
- import { uiChildrenOf, revealUiSource } from './node-text';
41
+ import { uiChildrenOf, revealUiSource, uiLiveChoiceReason, uiUrlDomain } from './node-text';
40
42
  import { uiReplyParts } from './reply-parts';
41
43
 
42
- const localStates = new Map<string, UiState>();
43
- const LOCAL_STATE_LIMIT = 64;
44
+ const localStates = new UiPageMemory<UiState>(64);
44
45
 
45
46
  function localState(key: string, block: UiBlock, answered?: ChatUiAnswer): UiState {
46
47
  let state = localStates.get(key);
47
- localStates.delete(key);
48
48
  if (!state) {
49
49
  state = new UiState(block);
50
50
  if (answered && answered.revision === block.revision) {
@@ -52,10 +52,7 @@ function localState(key: string, block: UiBlock, answered?: ChatUiAnswer): UiSta
52
52
  state.set(name, value);
53
53
  }
54
54
  }
55
- }
56
- localStates.set(key, state);
57
- if (localStates.size > LOCAL_STATE_LIMIT) {
58
- localStates.delete(localStates.keys().next().value!);
55
+ localStates.set(key, state);
59
56
  }
60
57
  return state;
61
58
  }
@@ -67,7 +64,7 @@ const BLOCK = '$block';
67
64
  * Parents whose text is read as written: code, a short tag, and the labels of controls, whose
68
65
  * accessible name and sent text are that same string.
69
66
  */
70
- const LITERAL_PARENTS = new Set(['CodeBlock', 'Tag', 'Choice', 'Item', 'Switch', 'Slider', 'Segmented', 'Option', 'Column']);
67
+ const LITERAL_PARENTS = new Set(['CodeBlock', 'Tag', 'Choice', 'Item', 'Switch', 'Slider', 'Segmented', 'Option', 'Button', 'Column']);
71
68
 
72
69
  /* Parents that stack their children, where a run of text and inline nodes is one paragraph. */
73
70
  const PARAGRAPHS: Readonly<Record<string, string>> = {
@@ -179,8 +176,6 @@ function sourceTargets(nodes: readonly UiViewNode[]): Map<number, CitationTarget
179
176
 
180
177
  const PROSE_PLUGINS = [remarkInlineProse, remarkCitations];
181
178
  const PROSE_ELEMENTS = ['p', 'br', 'strong', 'em', 'code', 'a', 'span'];
182
- // Raw HTML never parses here, so the only spans are the words the fade wraps.
183
- const FADING_PROSE_ELEMENTS = [...PROSE_ELEMENTS, 'span'];
184
179
  const FADE_PLUGINS = [rehypeFadeWords];
185
180
  const NO_PLUGINS: typeof FADE_PLUGINS = [];
186
181
 
@@ -193,9 +188,11 @@ function ProseLink({ href, children }: { href?: string; children?: ReactNode })
193
188
  return children;
194
189
  }
195
190
  return (
196
- <button type="button" className="cursor-pointer text-accent underline underline-offset-2 select-text" onClick={() => openUrl(href)}>
197
- {children}
198
- </button>
191
+ <Tooltip label={uiUrlDomain(href)}>
192
+ <button type="button" className="cursor-pointer text-accent underline underline-offset-2 select-text" onClick={() => openUrl(href)}>
193
+ {children}
194
+ </button>
195
+ </Tooltip>
199
196
  );
200
197
  }
201
198
 
@@ -227,7 +224,7 @@ const UiProse = memo(function UiProse({ text, fade, openUrl }: { text: string; f
227
224
  <ReactMarkdown
228
225
  remarkPlugins={PROSE_PLUGINS}
229
226
  rehypePlugins={fade ? FADE_PLUGINS : NO_PLUGINS}
230
- allowedElements={fade ? FADING_PROSE_ELEMENTS : PROSE_ELEMENTS}
227
+ allowedElements={PROSE_ELEMENTS}
231
228
  unwrapDisallowed
232
229
  components={PROSE_COMPONENTS}
233
230
  >
@@ -275,6 +272,10 @@ function UiNodes({ nodes, parent, texts, context }: UiNodesProps): ReactNode {
275
272
  </UiNodeBoundary>
276
273
  );
277
274
  const paragraph = PARAGRAPHS[parent];
275
+ if (parent === 'Choices') {
276
+ // A primary choice stands first in the order a keyboard and a screen reader meet it too, not only on screen.
277
+ return [...nodes].sort((a, b) => Number(b.props.primary === true) - Number(a.props.primary === true)).map(drawn);
278
+ }
278
279
  if (paragraph === undefined) {
279
280
  return nodes.map(drawn);
280
281
  }
@@ -364,6 +365,9 @@ function UiNodeBody({
364
365
  );
365
366
  }
366
367
 
368
+ /* What names a block and whether it still streams; the rest of its render context the block works out itself. */
369
+ type UiBlockIdentity = Pick<UiRenderContext, 'scopeId' | 'chatId' | 'itemId' | 'blockId' | 'phase'>;
370
+
367
371
  function UiBlockBody({
368
372
  block,
369
373
  context,
@@ -371,7 +375,7 @@ function UiBlockBody({
371
375
  frozen
372
376
  }: {
373
377
  block: UiBlock;
374
- context: UiRenderContext;
378
+ context: UiBlockIdentity;
375
379
  answered?: ChatUiAnswer;
376
380
  frozen?: ChatUiQueryState['blocks'][string];
377
381
  }): ReactNode {
@@ -379,7 +383,7 @@ function UiBlockBody({
379
383
  const key = JSON.stringify([context.scopeId, context.chatId, context.itemId, block.id]);
380
384
  const [state] = useState(() => localState(key, block, answered));
381
385
  state.sync(block);
382
- useSyncExternalStore(
386
+ const stateRevision = useSyncExternalStore(
383
387
  (listener) => state.subscribe(listener),
384
388
  () => state.snapshot(),
385
389
  () => state.snapshot()
@@ -399,7 +403,7 @@ function UiBlockBody({
399
403
  ? { choiceId: answered.choiceId, state: answered.queued ? ('queued' as const) : ('sent' as const) }
400
404
  : optimistic?.revision === block.revision
401
405
  ? optimistic
402
- : context.answer;
406
+ : null;
403
407
  const [element, setElement] = useState<HTMLDivElement | null>(null);
404
408
  const navigation = useContext(UiReplyNavigationContext);
405
409
  const flash = navigation?.flash;
@@ -407,7 +411,22 @@ function UiBlockBody({
407
411
  navigation?.chatId === context.chatId && flash?.itemId === context.itemId && flash.blockId === block.id && flash.revision === block.revision;
408
412
  const queries = useUiQueries(block, state, context, element, frozen);
409
413
  const links = useUiLinks(block, state, context, queries.reads, frozen);
410
- const evaluation = evaluateUiBlock(block, state);
414
+ // The state changes in place, so its revision is what tells this evaluation it went stale.
415
+ // oxlint-disable-next-line react-hooks/exhaustive-deps
416
+ const evaluation = useMemo(() => evaluateUiBlock(block, state), [block, state, stateRevision]);
417
+ const texts = useMemo(() => writtenTexts(block.nodes), [block]);
418
+ const sources = useMemo(() => sourceTargets(evaluation.nodes), [evaluation]);
419
+ const citations = useMemo(
420
+ () => ({
421
+ sources,
422
+ reveal: element
423
+ ? (source: CitationTarget) => {
424
+ void revealUiSource(element, source.id, source.ancestors);
425
+ }
426
+ : undefined
427
+ }),
428
+ [sources, element]
429
+ );
411
430
  const liveValue = useUiLiveValues(block, evaluation.nodes, queries.currentReadings);
412
431
  const sendChoice = chatHost().intelligentUi?.sendChoice;
413
432
  const rendering: UiRenderContext = {
@@ -415,8 +434,9 @@ function UiBlockBody({
415
434
  ...links,
416
435
  answer,
417
436
  failedChoiceId,
418
- live: queries.live ?? context.live,
419
- liveValue: liveValue ?? context.liveValue,
437
+ live: queries.live,
438
+ liveValue,
439
+ choiceReason: uiLiveChoiceReason(t, queries.live),
420
440
  onChoose:
421
441
  !queries.ready || queries.reading
422
442
  ? undefined
@@ -452,7 +472,7 @@ function UiBlockBody({
452
472
  pending.current = false;
453
473
  });
454
474
  }
455
- : context.onChoose
475
+ : undefined
456
476
  };
457
477
  const unknown = block.catalogVersion !== UI_CATALOG_VERSION;
458
478
  const failed = !unknown && !block.nodes.length && block.diagnostics.length > 0;
@@ -471,17 +491,8 @@ function UiBlockBody({
471
491
  {unknown || failed ? (
472
492
  <ReplyMarkdown text={block.fallback} streaming={false} />
473
493
  ) : (
474
- <CitationContext
475
- value={{
476
- sources: sourceTargets(evaluation.nodes),
477
- reveal: element
478
- ? (source) => {
479
- void revealUiSource(element, source.id, source.ancestors);
480
- }
481
- : undefined
482
- }}
483
- >
484
- <UiNodes nodes={evaluation.nodes} parent={BLOCK} texts={writtenTexts(block.nodes)} context={rendering} />
494
+ <CitationContext value={citations}>
495
+ <UiNodes nodes={evaluation.nodes} parent={BLOCK} texts={texts} context={rendering} />
485
496
  </CitationContext>
486
497
  )}
487
498
  </UiBlockFrame>
@@ -500,7 +511,7 @@ export function UiReply({
500
511
  }: {
501
512
  text: string;
502
513
  blocks: readonly UiBlock[];
503
- context: Omit<UiRenderContext, 'blockId'>;
514
+ context: Omit<UiBlockIdentity, 'blockId'>;
504
515
  answers?: Readonly<Record<string, ChatUiAnswer>>;
505
516
  queries?: ChatUiQueryState;
506
517
  reply?: ComponentProps<typeof ReplyMarkdown>['reply'];
@@ -515,13 +526,12 @@ export function UiReply({
515
526
  </Fragment>
516
527
  )
517
528
  ) : (
518
- <ErrorBoundary key={part.block.id} label="UI block" resetKeys={[context.scopeId, context.chatId, context.itemId, part.block.id]}>
529
+ <ErrorBoundary key={JSON.stringify([context.scopeId, context.chatId, context.itemId, part.block.id])} label="UI block">
519
530
  <UiBlockBody
520
- key={JSON.stringify([context.scopeId, context.chatId, context.itemId, part.block.id])}
521
531
  block={part.block}
522
532
  frozen={queries?.blocks[part.block.id]}
523
533
  answered={answers?.[part.block.id]}
524
- context={{ ...context, blockId: part.block.id }}
534
+ context={{ scopeId: context.scopeId, chatId: context.chatId, itemId: context.itemId, phase: context.phase, blockId: part.block.id }}
525
535
  />
526
536
  </ErrorBoundary>
527
537
  )
@@ -1,8 +1,43 @@
1
1
  import { useState } from 'react';
2
2
  import type { UiRenderContext } from './render-context';
3
3
 
4
+ /* Values that last as long as the page, past `limit` forgetting the one used longest ago. */
5
+ export class UiPageMemory<Value> {
6
+ private readonly entries = new Map<string, Value>();
7
+ private readonly limit: number;
8
+
9
+ constructor(limit: number) {
10
+ this.limit = limit;
11
+ }
12
+
13
+ has(key: string): boolean {
14
+ return this.entries.has(key);
15
+ }
16
+
17
+ /* Reading a value counts as using it. */
18
+ get(key: string): Value | undefined {
19
+ const value = this.entries.get(key);
20
+ if (value !== undefined) {
21
+ this.set(key, value);
22
+ }
23
+ return value;
24
+ }
25
+
26
+ set(key: string, value: Value): void {
27
+ this.entries.delete(key);
28
+ this.entries.set(key, value);
29
+ if (this.entries.size > this.limit) {
30
+ this.entries.delete(this.entries.keys().next().value!);
31
+ }
32
+ }
33
+
34
+ get size(): number {
35
+ return this.entries.size;
36
+ }
37
+ }
38
+
4
39
  // Which tab or section a person opened, so a block the thread unmounts while scrolling comes back as they left it.
5
- const remembered = new Map<string, unknown>();
40
+ const remembered = new UiPageMemory<unknown>(512);
6
41
 
7
42
  /* State of one node that lasts as long as the page, such as a fold; local input state belongs to the runtime instead. */
8
43
  export function useBlockLocal<Value>(context: UiRenderContext, nodeId: string, initial: () => Value): [Value, (next: Value) => void] {
@@ -1,4 +1,6 @@
1
+ import type { TFunction } from 'i18next';
1
2
  import type { UiComponentName, UiProps, UiViewNode } from '@adecore/intelligent-ui';
3
+ import type { UiLiveStatus } from './render-context';
2
4
 
3
5
  /* The text a node holds, every `$text` below it joined as written, such as the code of a CodeBlock. */
4
6
  export function uiNodeText(node: UiViewNode): string {
@@ -57,3 +59,61 @@ export async function revealUiSource(root: ParentNode, nodeId: string, ancestors
57
59
  row.scrollIntoView({ block: 'nearest', behavior: reduced ? 'auto' : 'smooth' });
58
60
  return true;
59
61
  }
62
+
63
+ /* The reason codes this version says in its own words; any other code reads as the reason the machine gave. */
64
+ const UI_REASON_CODES = new Set([
65
+ 'access-unavailable',
66
+ 'block-stale',
67
+ 'busy',
68
+ 'link-unchecked',
69
+ 'link-unsupported',
70
+ 'links-unsupported',
71
+ 'no-unique-match',
72
+ 'outside-project',
73
+ 'query-undeclared',
74
+ 'refresh-limit',
75
+ 'result-too-large',
76
+ 'snapshot-too-large',
77
+ 'source-unregistered',
78
+ 'stale-read',
79
+ 'target-unavailable',
80
+ 'timed-out',
81
+ 'unreadable'
82
+ ]);
83
+
84
+ /* Why a reading failed or a link stayed plain, in the person's language where the code is known. */
85
+ export function uiReasonText(t: TFunction<'agent-chat'>, code: string | undefined, reason: string | undefined): string | undefined {
86
+ return code !== undefined && UI_REASON_CODES.has(code) ? t(`blocks.live.reasons.${code}`) : reason;
87
+ }
88
+
89
+ /*
90
+ * A number with its unit, where a percent sign hugs the number and every other unit stands apart
91
+ * from it, by a space that never lets the two wrap apart.
92
+ */
93
+ export function uiWithUnit(text: string, unit: string | undefined): string {
94
+ if (unit === undefined || unit === '') {
95
+ return text;
96
+ }
97
+ return unit === '%' ? `${text}${unit}` : `${text}\u00a0${unit}`;
98
+ }
99
+
100
+ /* Why the choices of a live block are closed: its data is still being read, or the reason a reading failed. Null while nothing live holds them. */
101
+ export function uiLiveChoiceReason(t: TFunction<'agent-chat'>, live: UiLiveStatus | null | undefined): string | null {
102
+ if (live === null || live === undefined || live.state === 'fresh') {
103
+ return null;
104
+ }
105
+ if (live.state === 'reading') {
106
+ return t('blocks.choice.reading');
107
+ }
108
+ const source = live.source ?? live.sources[0] ?? '';
109
+ return uiReasonText(t, live.code, live.reason) ?? t(live.state === 'failed' ? 'blocks.live.failed' : 'blocks.live.refused', { source });
110
+ }
111
+
112
+ /* The host of an address without its `www.`, which is how a source or a link in prose says where it goes. */
113
+ export function uiUrlDomain(url: string): string {
114
+ try {
115
+ return new URL(url).hostname.replace(/^www\./, '');
116
+ } catch {
117
+ return url;
118
+ }
119
+ }
@@ -3,7 +3,7 @@ import { ChartRenderer } from './renderers/chart';
3
3
  import { ChoiceRenderer, ChoicesRenderer } from './renderers/choices';
4
4
  import { CodeBlockRenderer, ImageRenderer, SourceRenderer, SourcesRenderer } from './renderers/content';
5
5
  import { EntityListRenderer, EntryRenderer, StatRenderer, StatsRenderer, TableRenderer } from './renderers/data';
6
- import { ChecklistRenderer, ItemRenderer, SegmentedRenderer, SliderRenderer, SwitchRenderer } from './renderers/inputs';
6
+ import { ButtonRenderer, ChecklistRenderer, ItemRenderer, SegmentedRenderer, SliderRenderer, SwitchRenderer } from './renderers/inputs';
7
7
  import { CommitRenderer, DiffRenderer, FileRenderer, NodeRenderer } from './renderers/links';
8
8
  import { SectionRenderer, SectionsRenderer, TabRenderer, TabsRenderer } from './renderers/structure';
9
9
  import { CalloutRenderer, ProgressRenderer, StepRenderer, StepsRenderer, SummaryRenderer, TagRenderer } from './renderers/text';
@@ -45,6 +45,7 @@ export const UI_RENDERERS: UiRenderers = {
45
45
  Slider: SliderRenderer,
46
46
  Segmented: SegmentedRenderer,
47
47
  Option: null,
48
+ Button: ButtonRenderer,
48
49
  Show: null,
49
50
  Each: null,
50
51
  Choices: ChoicesRenderer,
@@ -1,9 +1,9 @@
1
1
  import { createContext, type ReactNode } from 'react';
2
2
  import type { UiComponentName, UiProps, UiViewNode } from '@adecore/intelligent-ui';
3
-
4
- export type { UiLinkTarget } from '@adecore/intelligent-ui/links';
5
3
  import type { UiLinkTarget } from '@adecore/intelligent-ui/links';
6
4
 
5
+ export type { UiLinkTarget };
6
+
7
7
  /* How the host lets a link be drawn: a chip it can open, or plain text it cannot. */
8
8
  export interface UiLink {
9
9
  state: 'chip' | 'plain';
@@ -11,6 +11,8 @@ export interface UiLink {
11
11
  label?: string;
12
12
  /* Why a plain link is not a chip, in its tooltip. */
13
13
  reason?: string;
14
+ /* The stable code of that reason, which a client words itself where it knows it. */
15
+ code?: string;
14
16
  }
15
17
 
16
18
  /* The Choice that answered a block. `sending` already reads as sent, so a second click does nothing. */
@@ -30,6 +32,8 @@ export interface UiLiveStatus {
30
32
  source?: string;
31
33
  /* Why a reading failed, in its tooltip. */
32
34
  reason?: string;
35
+ /* The stable code of that reason, which a client words itself where it knows it. */
36
+ code?: string;
33
37
  }
34
38
 
35
39
  /* Live history is separate from the comparison the agent supplies in Stat.previous. */
@@ -57,6 +61,8 @@ export interface UiRenderContext {
57
61
  openLink?(target: UiLinkTarget): void;
58
62
  openUrl?(url: string): void;
59
63
  onChoose?(nodeId: string): void;
64
+ /* Why the choices are closed while `onChoose` is absent, such as live data still being read; without it they say they are not available. */
65
+ choiceReason?: string | null;
60
66
  /* Table cells use the evaluated rows index and column key. */
61
67
  liveValue?(nodeId: string, prop: string, path?: readonly [row: number, key: string]): UiLiveValue | undefined;
62
68
  }
@@ -6,6 +6,7 @@ import type { UiProps } from '@adecore/intelligent-ui';
6
6
  import { SegmentBar, useMeasuredWidth } from '@adecore/ui';
7
7
  import { formatRounded } from '@adecore/ui/format';
8
8
  import { niceCeiling, UI_CHART_ROWS, uiChartData, type UiChartData } from '../chart-data';
9
+ import { uiWithUnit } from '../node-text';
9
10
  import type { UiRendererProps } from '../render-context';
10
11
  import { UiFallbackPart } from '../UiFallbackPart';
11
12
 
@@ -28,11 +29,7 @@ function seriesColor(index: number): string {
28
29
  }
29
30
 
30
31
  function valueText(value: number | null, unit: string | undefined): string {
31
- if (value === null) {
32
- return '';
33
- }
34
- const text = formatRounded(value, 2);
35
- return unit === undefined || unit === '' ? text : unit === '%' ? `${text}%` : `${text} ${unit}`;
32
+ return value === null ? '' : uiWithUnit(formatRounded(value, 2), unit);
36
33
  }
37
34
 
38
35
  /* The height a chart will take, which its place holds while the node streams. */
@@ -210,7 +207,7 @@ function VerticalChart({ chart, kind, unit, width }: { chart: UiChartData; kind:
210
207
  />
211
208
  {chart.labels.length <= DOTS_UP_TO &&
212
209
  series.values.map((value, index) =>
213
- value === null ? null : <circle key={index} cx={center(index)} cy={y(value)} r={2.5} fill="currentColor" />
210
+ value === null ? null : <circle key={index} cx={center(index)} cy={y(value)} r={3} fill="currentColor" />
214
211
  )}
215
212
  </g>
216
213
  ))}
@@ -235,7 +232,8 @@ function VerticalChart({ chart, kind, unit, width }: { chart: UiChartData; kind:
235
232
 
236
233
  /* A row of 24px per category, its label beside the bars. */
237
234
  function HorizontalChart({ chart, unit }: { chart: UiChartData; unit: string | undefined }) {
238
- const high = niceCeiling(chart.max);
235
+ // Bars grow right from zero only, so a chart of only negative values has no length to share and keeps a unit scale.
236
+ const high = niceCeiling(chart.max) || 1;
239
237
  const single = chart.series.length === 1;
240
238
  return (
241
239
  <div aria-hidden className="flex flex-col">