@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
@@ -41,7 +41,11 @@ export function ChoiceRenderer({ node, children, context }: UiRendererProps<UiPr
41
41
  const failed = answer === null && context.failedChoiceId === node.id;
42
42
  const closed = waiting || unavailable || answer !== null;
43
43
  const slow = useLater(chosen && answer.state === 'sending', SPINNER_AFTER_MS);
44
- const reason = waiting ? t('blocks.choice.waiting') : answer === null && unavailable ? t('blocks.choice.unavailable') : null;
44
+ const reason = waiting
45
+ ? t('blocks.choice.waiting')
46
+ : answer === null && unavailable
47
+ ? ((disabled === true ? null : context.choiceReason) ?? t('blocks.choice.unavailable'))
48
+ : null;
45
49
  const described = detail !== undefined && detail !== '';
46
50
 
47
51
  const row = (
@@ -80,7 +84,7 @@ export function ChoiceRenderer({ node, children, context }: UiRendererProps<UiPr
80
84
  );
81
85
 
82
86
  return (
83
- <div className={clsx('flex flex-col gap-1', primary === true && 'order-first')}>
87
+ <div className="flex flex-col gap-1">
84
88
  {reason !== null ? (
85
89
  <DisabledReason reason={reason}>{row}</DisabledReason>
86
90
  ) : described && detail.length > LONG_CONTEXT ? (
@@ -1,5 +1,5 @@
1
1
  import clsx from 'clsx';
2
- import { createContext, useContext } from 'react';
2
+ import { createContext, useContext, useId } from 'react';
3
3
  import { useTranslation } from 'react-i18next';
4
4
  import type { UiProps } from '@adecore/intelligent-ui';
5
5
  import { PreviewCard } from '@adecore/ui';
@@ -8,7 +8,7 @@ import { chatHost } from '../../../../host';
8
8
  import { CodeBlock } from '../../CodeBlock';
9
9
  import { CodeStreamingContext } from '../../code-streaming';
10
10
  import { ImageThumb } from '../../ImageView';
11
- import { uiChildrenOf, uiNodeLabel, uiNodeText } from '../node-text';
11
+ import { uiChildrenOf, uiNodeLabel, uiNodeText, uiUrlDomain } from '../node-text';
12
12
  import type { UiRendererProps } from '../render-context';
13
13
 
14
14
  // The number of every source by its node id, which the list counts in its own order.
@@ -52,25 +52,20 @@ export function ImageRenderer({ node, children, context }: UiRendererProps<UiPro
52
52
 
53
53
  export function SourcesRenderer({ node, children }: UiRendererProps<UiProps<'Sources'>>) {
54
54
  const { t } = useTranslation('agent-chat');
55
+ const headingId = useId();
55
56
  const numbers = new Map(uiChildrenOf(node, 'Source').map((source, index) => [source.id, index + 1]));
56
57
  return (
57
58
  <SourceNumbers value={numbers}>
58
- <section aria-label={t('blocks.sources')} className="flex flex-col">
59
- <span className="px-2 pb-1 text-xs font-medium text-text-muted">{t('blocks.sources')}</span>
59
+ <section aria-labelledby={headingId} className="flex flex-col">
60
+ <span id={headingId} className="px-2 pb-1 text-xs font-medium text-text-muted">
61
+ {t('blocks.sources')}
62
+ </span>
60
63
  <ol className="chat-ui-nodes flex flex-col">{children}</ol>
61
64
  </section>
62
65
  </SourceNumbers>
63
66
  );
64
67
  }
65
68
 
66
- function domainOf(url: string): string {
67
- try {
68
- return new URL(url).hostname.replace(/^www\./, '');
69
- } catch {
70
- return url;
71
- }
72
- }
73
-
74
69
  export interface UiSourceCitationProps {
75
70
  /* The number of the Source, as its row in the list counts it. */
76
71
  number: number;
@@ -106,7 +101,7 @@ export function UiSourceCitation({ number, title, url, onReveal, className }: Ui
106
101
  </PreviewCard.Trigger>
107
102
  <PreviewCard.Popup side="top" align="center" sideOffset={4} className="flex max-w-72 flex-col px-2.5 py-1.5 text-xs">
108
103
  <span className="line-clamp-2 text-text">{title}</span>
109
- <span className="truncate text-text-faint">{domainOf(url)}</span>
104
+ <span className="truncate text-text-faint">{uiUrlDomain(url)}</span>
110
105
  </PreviewCard.Popup>
111
106
  </PreviewCard.Root>
112
107
  );
@@ -124,7 +119,7 @@ export function SourceRenderer({ node, context }: UiRendererProps<UiProps<'Sourc
124
119
  <>
125
120
  <span className="w-5 shrink-0 text-right text-text-faint tabular-nums">{number}</span>
126
121
  <span className="min-w-0 truncate text-text">{title}</span>
127
- <span className="shrink-0 text-text-faint">{domainOf(url)}</span>
122
+ <span className="shrink-0 text-text-faint">{uiUrlDomain(url)}</span>
128
123
  </>
129
124
  );
130
125
  return (
@@ -5,24 +5,15 @@ import type { UiProps } from '@adecore/intelligent-ui';
5
5
  import { Button, Icon, KeyValueList, Pill } from '@adecore/ui';
6
6
  import { formatBytes, formatDuration, formatMoment, formatNumber, formatPercent, formatRounded } from '@adecore/ui/format';
7
7
  import { useBlockLocal } from '../block-local';
8
- import { uiChildrenOf } from '../node-text';
9
- import type { UiLiveValue, UiRenderContext, UiRendererProps } from '../render-context';
8
+ import { uiChildrenOf, uiWithUnit } from '../node-text';
9
+ import type { UiLiveValue, UiRendererProps } from '../render-context';
10
10
  import { isNumericColumn, UI_TABLE_ROWS, uiTableCell, uiTableColumns, type UiTableCell, type UiTableColumn } from '../table-data';
11
11
  import { UI_TONE_TEXT } from '../tones';
12
12
  import { UiFallbackPart } from '../UiFallbackPart';
13
- import { UiLinkChip } from './links';
14
13
 
15
14
  /* EntityList draws a hairline between its rows from this many on. */
16
15
  const DIVIDED_FROM = 7;
17
16
 
18
- /* A number with its unit, where a percent sign hugs the number and every other unit stands apart from it. */
19
- function withUnit(text: string, unit: string | undefined): string {
20
- if (unit === undefined || unit === '') {
21
- return text;
22
- }
23
- return unit === '%' ? `${text}${unit}` : `${text} ${unit}`;
24
- }
25
-
26
17
  /*
27
18
  * A number that live readings keep up to date. Keyed by its text, so a value that changed mounts
28
19
  * again and lights up once, even when it changes again before the last light settled.
@@ -47,15 +38,15 @@ export function StatRenderer({ node, context }: UiRendererProps<UiProps<'Stat'>>
47
38
  const live = context.liveValue?.(node.id, 'value');
48
39
  const change = previous === undefined ? null : value - previous;
49
40
  const arrow = change === null || change === 0 ? ArrowRight : change > 0 ? ArrowUp : ArrowDown;
50
- const from = previous === undefined ? '' : withUnit(formatRounded(previous, 2), unit);
41
+ const from = previous === undefined ? '' : uiWithUnit(formatRounded(previous, 2), unit);
51
42
  // A change from zero has no percentage.
52
43
  const percent = change === null || previous === undefined || previous === 0 ? null : formatPercent(Math.abs((change / previous) * 100));
53
44
  return (
54
45
  <div className="flex min-w-0 flex-col gap-0.5 rounded-md bg-surface-hover px-2 py-1.5">
55
46
  <span className="truncate text-xs text-text-muted">{label}</span>
56
47
  <LiveFigure
57
- text={withUnit(formatRounded(value, 2), unit)}
58
- previous={typeof live?.previous === 'number' ? withUnit(formatRounded(live.previous, 2), unit) : undefined}
48
+ text={uiWithUnit(formatRounded(value, 2), unit)}
49
+ previous={typeof live?.previous === 'number' ? uiWithUnit(formatRounded(live.previous, 2), unit) : undefined}
59
50
  live={live}
60
51
  className="text-lg font-semibold tabular-nums"
61
52
  />
@@ -89,10 +80,10 @@ export function EntryRenderer({ node, children }: UiRendererProps<UiProps<'Entry
89
80
  }
90
81
 
91
82
  function numberText(cell: Extract<UiTableCell, { kind: 'number' }>, unit: string | undefined): string {
92
- return cell.as === 'bytes' ? formatBytes(cell.value) : cell.as === 'duration' ? formatDuration(cell.value) : withUnit(formatRounded(cell.value, 2), unit);
83
+ return cell.as === 'bytes' ? formatBytes(cell.value) : cell.as === 'duration' ? formatDuration(cell.value) : uiWithUnit(formatRounded(cell.value, 2), unit);
93
84
  }
94
85
 
95
- function Cell({ value, column, live, context }: { value: unknown; column: UiTableColumn; live?: UiLiveValue; context: UiRenderContext }) {
86
+ function Cell({ value, column, live }: { value: unknown; column: UiTableColumn; live?: UiLiveValue }) {
96
87
  const cell = uiTableCell(value, column);
97
88
  switch (cell.kind) {
98
89
  case 'empty':
@@ -112,7 +103,8 @@ function Cell({ value, column, live, context }: { value: unknown; column: UiTabl
112
103
  case 'date':
113
104
  return <time dateTime={new Date(cell.at).toISOString()}>{formatMoment(cell.at)}</time>;
114
105
  case 'file':
115
- return <UiLinkChip target={{ type: 'File', path: cell.path }} context={context} />;
106
+ // A cell is no link target the host checked, so its path stays text.
107
+ return <span className="font-mono text-code break-all">{cell.path}</span>;
116
108
  case 'tag':
117
109
  return (
118
110
  <Pill shape="tag" size="sm">
@@ -138,7 +130,10 @@ export function TableRenderer({ node, context }: UiRendererProps<UiProps<'Table'
138
130
  return (
139
131
  <div className="flex flex-col items-start gap-1.5">
140
132
  <div className="max-h-80 w-full overflow-auto rounded-md border border-border-soft">
141
- <table className="w-full border-collapse text-xs tabular-nums">
133
+ <table
134
+ aria-label={t('blocks.table.label', { columns: columns.map((column) => column.title).join(', ') })}
135
+ className="w-full border-collapse text-xs tabular-nums"
136
+ >
142
137
  <thead className="sticky top-0 z-1 bg-surface-hover">
143
138
  <tr>
144
139
  {columns.map((column) => (
@@ -168,7 +163,6 @@ export function TableRenderer({ node, context }: UiRendererProps<UiProps<'Table'
168
163
  value={row[column.key]}
169
164
  column={column}
170
165
  live={isNumericColumn(column) ? context.liveValue?.(node.id, 'rows', [index, column.key]) : undefined}
171
- context={context}
172
166
  />
173
167
  </td>
174
168
  ))}
@@ -1,9 +1,9 @@
1
- import { createContext, useContext, type MouseEvent } from 'react';
1
+ import { createContext, useContext, type MouseEvent, type ReactNode } from 'react';
2
2
  import clsx from 'clsx';
3
3
  import { useTranslation } from 'react-i18next';
4
4
  import type { UiBinding, UiProps, UiViewNode } from '@adecore/intelligent-ui';
5
- import { Checkbox, Segmented, Slider, Switch } from '@adecore/ui';
6
- import { uiChildrenOf, uiNodeLabel } from '../node-text';
5
+ import { Button, Checkbox, Segmented, Slider, Switch } from '@adecore/ui';
6
+ import { uiChildrenOf, uiNodeLabel, uiWithUnit } from '../node-text';
7
7
  import type { UiRenderContext, UiRendererProps } from '../render-context';
8
8
 
9
9
  type Scalar = UiProps<'Item'>['value'];
@@ -28,6 +28,16 @@ function bindingOf<Value>(node: UiViewNode<{ value: Value }>, context: UiRenderC
28
28
  /* An input whose node has not closed yet stands at 60% and takes no pointer. */
29
29
  const PENDING = 'pointer-events-none opacity-60';
30
30
 
31
+ /* The rows of a checklist as one named group. */
32
+ function ChecklistGroup({ pending, children }: { pending: boolean; children: ReactNode }) {
33
+ const { t } = useTranslation('agent-chat');
34
+ return (
35
+ <div role="group" aria-label={t('blocks.checklist')} className={clsx('chat-ui-nodes flex flex-col', pending && PENDING)}>
36
+ {children}
37
+ </div>
38
+ );
39
+ }
40
+
31
41
  export function ChecklistRenderer({ node, children, context }: UiRendererProps<UiProps<'Checklist'>>) {
32
42
  const binding = bindingOf(node, context);
33
43
  const values = binding?.value ?? node.props.value;
@@ -36,7 +46,7 @@ export function ChecklistRenderer({ node, children, context }: UiRendererProps<U
36
46
  };
37
47
  return (
38
48
  <ChecklistContext value={{ values, enabled: binding !== null, toggle }}>
39
- <div className={clsx('chat-ui-nodes flex flex-col', !node.complete && PENDING)}>{children}</div>
49
+ <ChecklistGroup pending={!node.complete}>{children}</ChecklistGroup>
40
50
  </ChecklistContext>
41
51
  );
42
52
  }
@@ -87,7 +97,8 @@ export function SliderRenderer({ node, context }: UiRendererProps<UiProps<'Slide
87
97
  min={min}
88
98
  max={max}
89
99
  step={step}
90
- unit={unit}
100
+ // The slider prints its unit right after the number, so the space a unit stands apart with comes from here.
101
+ unit={uiWithUnit('', unit)}
91
102
  label={uiNodeLabel(node)}
92
103
  disabled={binding === null}
93
104
  className={clsx('px-2', !node.complete && PENDING)}
@@ -119,3 +130,15 @@ export function SegmentedRenderer({ node, context }: UiRendererProps<UiProps<'Se
119
130
  </div>
120
131
  );
121
132
  }
133
+
134
+ /* Runs its local action once its own node closed; like an input, it closes once the block is answered. */
135
+ export function ButtonRenderer({ node, children, context }: UiRendererProps<UiProps<'Button'>>) {
136
+ const enabled = node.onAction !== undefined && node.complete && context.answer === null && node.props.disabled !== true;
137
+ return (
138
+ <div className={clsx('px-2', !node.complete && PENDING)}>
139
+ <Button variant="secondary" size="sm" disabled={!enabled} onClick={() => node.onAction?.()}>
140
+ {children}
141
+ </Button>
142
+ </div>
143
+ );
144
+ }
@@ -5,6 +5,7 @@ import { FileDiff, GitCommitHorizontal, SquareDashed } from 'lucide-react';
5
5
  import type { UiProps } from '@adecore/intelligent-ui';
6
6
  import { FileIcon, Icon, Tooltip } from '@adecore/ui';
7
7
  import { CHIP_IN_MESSAGE, MENTION_TONE } from '../../chips';
8
+ import { uiReasonText } from '../node-text';
8
9
  import type { UiLink, UiLinkTarget, UiRenderContext, UiRendererProps } from '../render-context';
9
10
 
10
11
  const PLAIN: UiLink = { state: 'plain' };
@@ -82,7 +83,7 @@ export function UiLinkChip({ target, context }: { target: UiLinkTarget; context:
82
83
  const { t } = useTranslation('agent-chat');
83
84
  const link = context.link?.(target) ?? PLAIN;
84
85
  if (link.state === 'plain') {
85
- const reason = link.reason ?? (context.link === undefined ? null : t('blocks.outsideProject'));
86
+ const reason = uiReasonText(t, link.code, link.reason) ?? (context.link === undefined ? null : t('blocks.outsideProject'));
86
87
  // Focusable only with a reason, so a keyboard reaches the tooltip.
87
88
  const text = (
88
89
  <span tabIndex={reason === null ? undefined : 0} className="font-mono text-code break-all text-text-muted">
@@ -1,7 +1,7 @@
1
- import { useEffect, useState } from 'react';
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
2
  import type { ChatUiLinkReading, ChatUiQueryState } from '@adecore/agent-contracts';
3
3
  import { uiLinkTargets, type UiLinkTarget } from '@adecore/intelligent-ui/links';
4
- import { uiInputValues, type UiBlock, UiState } from '@adecore/intelligent-ui';
4
+ import { uiInputValues, UI_HOST_LIMITS, type UiBlock, UiState } from '@adecore/intelligent-ui';
5
5
  import { chatHost } from '../../../host';
6
6
  import type { UiRenderContext } from './render-context';
7
7
 
@@ -10,14 +10,19 @@ interface LinkState {
10
10
  reading: ChatUiLinkReading;
11
11
  }
12
12
 
13
+ // Inputs that change in a run, such as a dragged slider, check their links once the run rested.
14
+ const LINK_SETTLE_MS = 300;
15
+
13
16
  export function useUiLinks(
14
17
  block: UiBlock,
15
18
  state: UiState,
16
- context: UiRenderContext,
19
+ context: Pick<UiRenderContext, 'scopeId' | 'chatId' | 'itemId' | 'phase'>,
17
20
  reads: Readonly<Record<string, string>>,
18
21
  frozen?: ChatUiQueryState['blocks'][string]
19
22
  ) {
20
23
  const [checked, setChecked] = useState<Record<string, LinkState>>({});
24
+ // What each node was last checked as, so a change of input checks only the targets it moved.
25
+ const asked = useRef(new Map<string, string>());
21
26
  const host = chatHost().intelligentUi;
22
27
  const values = uiInputValues(block, state);
23
28
  const scope = state.scope();
@@ -35,14 +40,25 @@ export function useUiLinks(
35
40
  const signature = JSON.stringify(targets);
36
41
  const input = JSON.stringify(values);
37
42
  const tickets = JSON.stringify(reads);
43
+ const defaultInput = useMemo(() => JSON.stringify(uiInputValues(block, new UiState(block))), [block]);
38
44
  const link = host?.link;
45
+ useEffect(() => {
46
+ asked.current.clear();
47
+ }, [block.id, block.revision, context.scopeId, context.chatId, context.itemId, link]);
39
48
  useEffect(() => {
40
49
  if (!link || !block.revision || context.phase !== 'final') {
41
50
  return;
42
51
  }
43
52
  let stopped = false;
44
53
  const validate = async () => {
45
- for (const [nodeId, target] of Object.entries(JSON.parse(signature) as Record<string, UiLinkTarget>).slice(0, 64)) {
54
+ const moved = Object.entries(JSON.parse(signature) as Record<string, UiLinkTarget>)
55
+ .slice(0, UI_HOST_LIMITS.links)
56
+ .filter(([nodeId, target]) => asked.current.get(nodeId) !== JSON.stringify(target));
57
+ for (const [nodeId, target] of moved) {
58
+ const record = (reading: ChatUiLinkReading) => {
59
+ asked.current.set(nodeId, JSON.stringify(target));
60
+ setChecked((previous) => ({ ...previous, [nodeId]: { target: JSON.stringify(target), reading } }));
61
+ };
46
62
  try {
47
63
  const reading = await link(context.scopeId, {
48
64
  chatId: context.chatId,
@@ -56,18 +72,20 @@ export function useUiLinks(
56
72
  if (stopped) {
57
73
  return;
58
74
  }
59
- setChecked((previous) => ({ ...previous, [nodeId]: { target: JSON.stringify(target), reading } }));
75
+ record(reading);
60
76
  } catch {
61
77
  if (stopped) {
62
78
  return;
63
79
  }
64
- setChecked((previous) => ({ ...previous, [nodeId]: { target: JSON.stringify(target), reading: { state: 'plain' } } }));
80
+ record({ state: 'plain' });
65
81
  }
66
82
  }
67
83
  };
68
- void validate();
84
+ // The first check runs at once; a later one waits until the inputs rested.
85
+ const timer = setTimeout(() => void validate(), asked.current.size === 0 ? 0 : LINK_SETTLE_MS);
69
86
  return () => {
70
87
  stopped = true;
88
+ clearTimeout(timer);
71
89
  };
72
90
  }, [block.id, block.revision, context.scopeId, context.chatId, context.itemId, context.phase, signature, input, tickets, link]);
73
91
  const idOf = (target: UiLinkTarget) => Object.entries(targets).find(([, entry]) => JSON.stringify(entry) === JSON.stringify(target))?.[0];
@@ -79,7 +97,7 @@ export function useUiLinks(
79
97
  if (checked[id]?.target === JSON.stringify(target)) {
80
98
  return checked[id].reading;
81
99
  }
82
- if (input === JSON.stringify(uiInputValues(block, new UiState(block))) && frozen && frozen.revision === block.revision) {
100
+ if (input === defaultInput && frozen && frozen.revision === block.revision) {
83
101
  const firstQueries = Object.fromEntries(
84
102
  Object.entries(frozen.readings)
85
103
  .filter(([, reading]) => reading.state === 'fresh')
@@ -115,9 +133,11 @@ export function useUiLinks(
115
133
  setChecked((previous) => ({ ...previous, [nodeId]: { target: JSON.stringify(target), reading: { state: 'plain' } } }));
116
134
  });
117
135
  };
136
+ const hostOpenUrl = host?.openUrl;
137
+ const openUrl = useCallback((url: string) => hostOpenUrl?.(context.scopeId, url), [hostOpenUrl, context.scopeId]);
118
138
  return {
119
- link: link ? resolution : context.link,
120
- openLink: link && host?.openLink ? open : context.openLink,
121
- openUrl: host?.openUrl ? (url: string) => host.openUrl!(context.scopeId, url) : context.openUrl
139
+ link: link ? resolution : undefined,
140
+ openLink: link && host?.openLink ? open : undefined,
141
+ openUrl: hostOpenUrl ? openUrl : undefined
122
142
  };
123
143
  }
@@ -1,6 +1,6 @@
1
- import { useEffect, useRef, useState } from 'react';
1
+ import { useEffect, useMemo, useRef, useState } from 'react';
2
2
  import type { ChatUiQueryReading, ChatUiQueryState } from '@adecore/agent-contracts';
3
- import { uiInputValues, UI_CATALOG_VERSION, UiState, type UiBlock, type UiViewNode, type UiNode } from '@adecore/intelligent-ui';
3
+ import { uiInputValues, UI_CATALOG_VERSION, UI_HOST_LIMITS, UiState, type UiBlock, type UiViewNode, type UiNode } from '@adecore/intelligent-ui';
4
4
  import { chatHost } from '../../../host';
5
5
  import type { UiLiveValue, UiLiveStatus, UiRenderContext } from './render-context';
6
6
 
@@ -10,6 +10,28 @@ interface LocalReading {
10
10
  successful?: { inputs: string; reading: ChatUiQueryReading };
11
11
  }
12
12
 
13
+ const NO_READINGS: ChatUiQueryState['blocks'][string]['readings'] = {};
14
+ // A changed input reads once it has rested this long, so dragging a slider reads its last value only.
15
+ const INPUT_SETTLE_MS = 300;
16
+
17
+ /* The reading that holds for these inputs: this view's own read of them, or the frozen first one for the inputs the block starts with. */
18
+ function successfulReading(
19
+ local: LocalReading | undefined,
20
+ initial: ChatUiQueryReading | undefined,
21
+ identity: string,
22
+ signature: string,
23
+ defaultInputs: string
24
+ ): ChatUiQueryReading | undefined {
25
+ if (local?.identity === identity && local.successful?.inputs === signature) {
26
+ return local.successful.reading;
27
+ }
28
+ return signature === defaultInputs && initial?.state === 'fresh' ? initial : undefined;
29
+ }
30
+
31
+ function settleDeadline(): number {
32
+ return Date.now() + INPUT_SETTLE_MS;
33
+ }
34
+
13
35
  function documentVisible(): boolean {
14
36
  return document.visibilityState !== 'hidden';
15
37
  }
@@ -17,27 +39,22 @@ function documentVisible(): boolean {
17
39
  export function useUiQueries(
18
40
  block: UiBlock,
19
41
  state: UiState,
20
- context: UiRenderContext,
42
+ context: Pick<UiRenderContext, 'scopeId' | 'chatId' | 'itemId' | 'phase'>,
21
43
  element: HTMLDivElement | null,
22
44
  frozen?: ChatUiQueryState['blocks'][string]
23
45
  ) {
24
46
  const [readings, setReadings] = useState<Record<string, LocalReading>>({});
25
47
  const [busyInput, setBusyInput] = useState<string | null>(null);
26
- const lastReadAt = useRef({ identity: '', at: 0 });
48
+ const lastRead = useRef({ inputKey: '', at: 0 });
27
49
  const names = Object.keys(block.queries);
28
50
  const inputs = JSON.stringify(uiInputValues(block, state));
29
51
  const identity = JSON.stringify([context.scopeId, context.chatId, context.itemId, block.id, block.revision]);
30
52
  const inputKey = JSON.stringify([identity, inputs]);
31
53
  const reading = busyInput === inputKey;
32
- const defaultInputs = JSON.stringify(uiInputValues(block, new UiState(block)));
33
- const initial = frozen && frozen.revision === block.revision ? frozen.readings : {};
34
- const successfulFor = (name: string, signature: string): ChatUiQueryReading | undefined => {
35
- const local = readings[name];
36
- if (local?.identity === identity && local.successful?.inputs === signature) {
37
- return local.successful.reading;
38
- }
39
- return signature === defaultInputs && initial[name]?.state === 'fresh' ? initial[name] : undefined;
40
- };
54
+ const defaultInputs = useMemo(() => JSON.stringify(uiInputValues(block, new UiState(block))), [block]);
55
+ const initial = frozen && frozen.revision === block.revision ? frozen.readings : NO_READINGS;
56
+ const successfulFor = (name: string, signature: string): ChatUiQueryReading | undefined =>
57
+ successfulReading(readings[name], initial[name], identity, signature, defaultInputs);
41
58
  const readsFor = (values: Readonly<Record<string, unknown>>): Record<string, string> | null => {
42
59
  const signature = JSON.stringify(values);
43
60
  const reads: Record<string, string> = {};
@@ -57,13 +74,13 @@ export function useUiQueries(
57
74
  const subscribe = chatHost().intelligentUi?.subscribe;
58
75
  useEffect(() => {
59
76
  const scope = state.scope();
60
- for (const name of names) {
61
- const successful = successfulFor(name, inputs);
77
+ for (const name of Object.keys(block.queries)) {
78
+ const successful = successfulReading(readings[name], initial[name], identity, inputs, defaultInputs);
62
79
  if (successful && JSON.stringify(scope[name]) !== JSON.stringify(successful.value)) {
63
80
  state.setQuery(name, successful.value, block);
64
81
  }
65
82
  }
66
- });
83
+ }, [state, block, readings, identity, inputs, defaultInputs, initial]);
67
84
  useEffect(() => {
68
85
  if (
69
86
  !query ||
@@ -81,11 +98,17 @@ export function useUiQueries(
81
98
  let busy = false;
82
99
 
83
100
  let timer: ReturnType<typeof setTimeout> | null = null;
101
+ // The first read of this block reads at once; a changed input waits until it rests.
102
+ const changed = lastRead.current.inputKey !== '' && lastRead.current.inputKey !== inputKey;
103
+ const settledAt = changed ? settleDeadline() : 0;
84
104
  const refresh = async () => {
85
105
  if (stopped || !visible || !documentVisible() || busy) {
86
106
  return;
87
107
  }
88
- const wait = lastReadAt.current.identity === identity ? 10_000 - (Date.now() - lastReadAt.current.at) : 0;
108
+ const wait = Math.max(
109
+ settledAt - Date.now(),
110
+ lastRead.current.inputKey === inputKey ? UI_HOST_LIMITS.refreshMilliseconds - (Date.now() - lastRead.current.at) : 0
111
+ );
89
112
  if (wait > 0) {
90
113
  if (timer === null) {
91
114
  if (!visible || !documentVisible()) {
@@ -99,9 +122,9 @@ export function useUiQueries(
99
122
  return;
100
123
  }
101
124
  busy = true;
102
- lastReadAt.current = { identity, at: Date.now() };
125
+ lastRead.current = { inputKey, at: Date.now() };
103
126
  setBusyInput(inputKey);
104
- for (const name of Object.keys(block.queries).slice(0, 8)) {
127
+ for (const name of Object.keys(block.queries).slice(0, UI_HOST_LIMITS.queries)) {
105
128
  if (!visible || !documentVisible()) {
106
129
  break;
107
130
  }
@@ -137,7 +160,7 @@ export function useUiQueries(
137
160
  ...previous,
138
161
  [name]: {
139
162
  identity,
140
- reading: { state: 'failed', readAt: Date.now(), reason: error instanceof Error ? error.message : 'This query could not be read.' },
163
+ reading: { state: 'failed', readAt: Date.now(), code: 'unreadable', reason: error instanceof Error ? error.message : undefined },
141
164
  successful: previous[name]?.identity === identity ? previous[name].successful : undefined
142
165
  }
143
166
  }));
@@ -151,7 +174,7 @@ export function useUiQueries(
151
174
  timer = setTimeout(() => {
152
175
  timer = null;
153
176
  void refresh();
154
- }, 10_000);
177
+ }, UI_HOST_LIMITS.refreshMilliseconds);
155
178
  };
156
179
  const observer =
157
180
  typeof IntersectionObserver === 'undefined'
@@ -176,6 +199,8 @@ export function useUiQueries(
176
199
  void refresh();
177
200
  return () => {
178
201
  stopped = true;
202
+ // A read cut short by this cleanup never reaches its own reset, which would leave the choices closed.
203
+ setBusyInput((current) => (current === inputKey ? null : current));
179
204
  observer?.disconnect();
180
205
  unsubscribe?.();
181
206
  document.removeEventListener('visibilitychange', focus);
@@ -200,7 +225,8 @@ export function useUiQueries(
200
225
  .filter((reading) => reading.state === 'fresh')
201
226
  .reduce<number | null>((at, value) => (at === null ? value.readAt : Math.min(at, value.readAt)), null),
202
227
  source: failed ? block.queries[failed[0]]?.source : undefined,
203
- reason: failed?.[1].reason
228
+ reason: failed?.[1].reason,
229
+ code: failed?.[1].code
204
230
  }
205
231
  : undefined;
206
232
  return { live, reads: reads ?? {}, reading, ready, readsFor, currentReadings };
@@ -1,6 +1,6 @@
1
1
  import { chipText, tokenizeChips, type ChipSegment } from '../mentions';
2
2
 
3
- /* Just the part of hast this plugin touches; the package's own types are not a dependency of the client. */
3
+ /* Just the part of hast this plugin touches; the package's own types are not a dependency here. */
4
4
  interface HastText {
5
5
  type: 'text';
6
6
  value: string;
@@ -1,4 +1,4 @@
1
- /* Just the part of hast this plugin touches; the package's own types are not a dependency of the client. */
1
+ /* Just the part of hast this plugin touches; the package's own types are not a dependency here. */
2
2
  interface HastText {
3
3
  type: 'text';
4
4
  value: string;
@@ -1,4 +1,4 @@
1
- /* Just the part of mdast this plugin touches; the package's own types are not a dependency of the client. */
1
+ /* Just the part of mdast this plugin touches; the package's own types are not a dependency here. */
2
2
  interface MdastNode {
3
3
  type: string;
4
4
  value?: string;
@@ -248,7 +248,7 @@ export function AssistantRow({ chatId, item: derived }: { chatId: string; item:
248
248
  answers={item.uiAnswers}
249
249
  queries={item.uiQueries}
250
250
  reply={reply}
251
- context={{ scopeId: scope.id, chatId, itemId: item.id, phase: item.streaming ? 'streaming' : 'final', answer: null }}
251
+ context={{ scopeId: scope.id, chatId, itemId: item.id, phase: item.streaming ? 'streaming' : 'final' }}
252
252
  />
253
253
  </div>
254
254
  </div>
@@ -391,9 +391,8 @@ function OpenChatButton({ chatId }: { chatId: string }) {
391
391
  }
392
392
 
393
393
  /*
394
- * The header of a turn the agent started itself, in place of the message of the person that is
395
- * missing. It is one line: what the sub-agent came back with lives on that agent's own row, behind
396
- * "Show result". When the CLI named the sub-agent it woke up about, the header opens that row.
394
+ * The header of a turn the agent started itself, in place of a message of the person. What the
395
+ * sub-agent came back with lives on its own row, which the header opens when the CLI named it.
397
396
  */
398
397
  export function AgentTurnRow({ label, onOpen }: { label: string; onOpen?: () => void }) {
399
398
  const line = 'mb-0.5 flex h-7 w-full items-center gap-2 text-left text-xs text-text-muted';
@@ -430,7 +429,7 @@ export function CompactionRow({ preTokens }: { preTokens: number | null }) {
430
429
  );
431
430
  }
432
431
 
433
- const DECISION_ICON: Record<Exclude<ChatApprovalItem['decision'], 'pending'>, React.ReactNode> = {
432
+ const DECISION_ICON: Record<Exclude<ChatApprovalItem['decision'], 'pending'>, ReactNode> = {
434
433
  allow: <Icon icon={Check} size={12} />,
435
434
  'allow-always': <Icon icon={Check} size={12} />,
436
435
  deny: <Icon icon={X} size={12} />,