@adecore/agents-react 0.21.0 → 0.21.1

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 (47) hide show
  1. package/dist/chat/ui/intelligent-ui/UiBlockFrame.d.ts +14 -1
  2. package/dist/chat/ui/intelligent-ui/UiBlockFrame.js +47 -5
  3. package/dist/chat/ui/intelligent-ui/UiReply.d.ts +3 -1
  4. package/dist/chat/ui/intelligent-ui/UiReply.js +34 -30
  5. package/dist/chat/ui/intelligent-ui/block-local.d.ts +9 -0
  6. package/dist/chat/ui/intelligent-ui/block-local.js +30 -1
  7. package/dist/chat/ui/intelligent-ui/node-text.d.ts +6 -0
  8. package/dist/chat/ui/intelligent-ui/node-text.js +54 -0
  9. package/dist/chat/ui/intelligent-ui/registry.js +2 -1
  10. package/dist/chat/ui/intelligent-ui/render-context.d.ts +3 -0
  11. package/dist/chat/ui/intelligent-ui/renderers/chart.js +5 -7
  12. package/dist/chat/ui/intelligent-ui/renderers/choices.js +6 -2
  13. package/dist/chat/ui/intelligent-ui/renderers/content.js +6 -13
  14. package/dist/chat/ui/intelligent-ui/renderers/data.js +8 -15
  15. package/dist/chat/ui/intelligent-ui/renderers/inputs.d.ts +1 -0
  16. package/dist/chat/ui/intelligent-ui/renderers/inputs.js +16 -4
  17. package/dist/chat/ui/intelligent-ui/renderers/links.js +2 -1
  18. package/dist/chat/ui/intelligent-ui/use-ui-links.d.ts +4 -4
  19. package/dist/chat/ui/intelligent-ui/use-ui-links.js +29 -10
  20. package/dist/chat/ui/intelligent-ui/use-ui-queries.d.ts +2 -1
  21. package/dist/chat/ui/intelligent-ui/use-ui-queries.js +34 -21
  22. package/dist/chat/ui/rows/MessageRows.js +1 -1
  23. package/dist/locales/en/agent-chat.json +23 -4
  24. package/dist/locales/nl/agent-chat.json +23 -4
  25. package/dist/state/chats.js +5 -1
  26. package/package.json +24 -29
  27. package/src/chat/ui/intelligent-ui/UiBlockFrame.tsx +135 -5
  28. package/src/chat/ui/intelligent-ui/UiReply.tsx +48 -38
  29. package/src/chat/ui/intelligent-ui/block-local.ts +36 -1
  30. package/src/chat/ui/intelligent-ui/node-text.ts +60 -0
  31. package/src/chat/ui/intelligent-ui/registry.ts +2 -1
  32. package/src/chat/ui/intelligent-ui/render-context.ts +6 -0
  33. package/src/chat/ui/intelligent-ui/renderers/chart.tsx +5 -7
  34. package/src/chat/ui/intelligent-ui/renderers/choices.tsx +6 -2
  35. package/src/chat/ui/intelligent-ui/renderers/content.tsx +9 -14
  36. package/src/chat/ui/intelligent-ui/renderers/data.tsx +13 -19
  37. package/src/chat/ui/intelligent-ui/renderers/inputs.tsx +28 -5
  38. package/src/chat/ui/intelligent-ui/renderers/links.tsx +2 -1
  39. package/src/chat/ui/intelligent-ui/use-ui-links.ts +31 -11
  40. package/src/chat/ui/intelligent-ui/use-ui-queries.ts +48 -22
  41. package/src/chat/ui/rows/MessageRows.tsx +1 -1
  42. package/src/locales/en/agent-chat.json +23 -4
  43. package/src/locales/nl/agent-chat.json +23 -4
  44. package/src/state/chats.ts +5 -1
  45. package/dist/chat/ui/intelligent-ui/UiBlockFooter.d.ts +0 -15
  46. package/dist/chat/ui/intelligent-ui/UiBlockFooter.js +0 -50
  47. package/src/chat/ui/intelligent-ui/UiBlockFooter.tsx +0 -139
@@ -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 };
@@ -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>
@@ -498,6 +498,7 @@
498
498
  "from": "from {{previous}}"
499
499
  },
500
500
  "table": {
501
+ "label": "Table of {{columns}}",
501
502
  "showAll": "Show all {{total}} rows"
502
503
  },
503
504
  "chart": {
@@ -514,8 +515,10 @@
514
515
  "outsideProject": "Outside the project, shown as text",
515
516
  "options": "Options",
516
517
  "choices": "Choices",
518
+ "checklist": "Checklist",
517
519
  "choice": {
518
520
  "waiting": "Available when the reply is done",
521
+ "reading": "Available once the live data is read",
519
522
  "unavailable": "Not available",
520
523
  "sent": "Sent",
521
524
  "queued": "Queued",
@@ -525,14 +528,11 @@
525
528
  "answered": "Answered with “{{label}}”",
526
529
  "chose": "Chose “{{label}}”",
527
530
  "choseOlder": "Chose “{{label}}” on the reply of {{time}}",
528
- "sendContext": "Send",
529
- "copyText": "Copy text",
530
531
  "fixes_one": "{{count}} fix",
531
532
  "fixes_other": "{{count}} fixes",
532
533
  "shownAsText": "Shown as text",
533
534
  "unknownComponent": "This version cannot draw {{component}}",
534
535
  "renderFailed": "Could not draw this part",
535
- "tooLarge": "The block is too large to draw",
536
536
  "unreadable": "The block could not be read",
537
537
  "live": {
538
538
  "label": "Live",
@@ -540,7 +540,26 @@
540
540
  "sources": "Reads {{sources}}",
541
541
  "failed": "Could not read {{source}}",
542
542
  "refused": "Not allowed to read {{source}}",
543
- "notRead": "Not read"
543
+ "notRead": "Not read",
544
+ "reasons": {
545
+ "access-unavailable": "The access of the chat that wrote this is no longer available.",
546
+ "block-stale": "This block changed since it was drawn.",
547
+ "busy": "Too many live blocks are reading right now.",
548
+ "link-unchecked": "This link has to be checked again.",
549
+ "link-unsupported": "This is not a link the machine can open.",
550
+ "links-unsupported": "The machine does not open links from blocks.",
551
+ "no-unique-match": "The source found no single match for that name.",
552
+ "outside-project": "This is outside the project the agent wrote this in.",
553
+ "query-undeclared": "This block does not read that source.",
554
+ "refresh-limit": "This source reads at most once every ten seconds.",
555
+ "result-too-large": "What the source returned is too large.",
556
+ "snapshot-too-large": "The first readings were too large to keep.",
557
+ "source-unregistered": "The machine does not know this source.",
558
+ "stale-read": "Read the source again before you choose.",
559
+ "target-unavailable": "What this points to is no longer available.",
560
+ "timed-out": "The source took too long to answer.",
561
+ "unreadable": "The source could not be read."
562
+ }
544
563
  }
545
564
  }
546
565
  }
@@ -498,6 +498,7 @@
498
498
  "from": "vanaf {{previous}}"
499
499
  },
500
500
  "table": {
501
+ "label": "Tabel van {{columns}}",
501
502
  "showAll": "Alle {{total}} rijen tonen"
502
503
  },
503
504
  "chart": {
@@ -514,8 +515,10 @@
514
515
  "outsideProject": "Buiten het project, als tekst getoond",
515
516
  "options": "Opties",
516
517
  "choices": "Keuzes",
518
+ "checklist": "Checklijst",
517
519
  "choice": {
518
520
  "waiting": "Beschikbaar zodra het antwoord klaar is",
521
+ "reading": "Beschikbaar zodra de live gegevens gelezen zijn",
519
522
  "unavailable": "Niet beschikbaar",
520
523
  "sent": "Verstuurd",
521
524
  "queued": "In de wachtrij",
@@ -525,14 +528,11 @@
525
528
  "answered": "Beantwoord met “{{label}}”",
526
529
  "chose": "Gekozen: “{{label}}”",
527
530
  "choseOlder": "Gekozen: “{{label}}”, bij het antwoord van {{time}}",
528
- "sendContext": "Versturen",
529
- "copyText": "Tekst kopiëren",
530
531
  "fixes_one": "{{count}} correctie",
531
532
  "fixes_other": "{{count}} correcties",
532
533
  "shownAsText": "Als tekst getoond",
533
534
  "unknownComponent": "Deze versie kan {{component}} niet tekenen",
534
535
  "renderFailed": "Dit deel kon niet getekend worden",
535
- "tooLarge": "Het blok is te groot om te tekenen",
536
536
  "unreadable": "Het blok kon niet gelezen worden",
537
537
  "live": {
538
538
  "label": "Live",
@@ -540,7 +540,26 @@
540
540
  "sources": "Leest {{sources}}",
541
541
  "failed": "{{source}} lezen lukte niet",
542
542
  "refused": "Geen toegang tot {{source}}",
543
- "notRead": "Niet gelezen"
543
+ "notRead": "Niet gelezen",
544
+ "reasons": {
545
+ "access-unavailable": "De toegang van de chat die dit schreef is niet meer beschikbaar.",
546
+ "block-stale": "Dit blok is veranderd sinds het getekend werd.",
547
+ "busy": "Er lezen nu te veel live blokken tegelijk.",
548
+ "link-unchecked": "Deze link moet opnieuw gecontroleerd worden.",
549
+ "link-unsupported": "Dit is geen link die de machine kan openen.",
550
+ "links-unsupported": "De machine opent geen links uit blokken.",
551
+ "no-unique-match": "De bron vond voor die naam niet precies één overeenkomst.",
552
+ "outside-project": "Dit valt buiten het project waarin de agent dit schreef.",
553
+ "query-undeclared": "Dit blok leest die bron niet.",
554
+ "refresh-limit": "Deze bron leest hooguit eens per tien seconden.",
555
+ "result-too-large": "Wat de bron teruggaf is te groot.",
556
+ "snapshot-too-large": "De eerste metingen waren te groot om te bewaren.",
557
+ "source-unregistered": "De machine kent deze bron niet.",
558
+ "stale-read": "Lees de bron opnieuw voordat je kiest.",
559
+ "target-unavailable": "Waar dit naar verwijst is niet meer beschikbaar.",
560
+ "timed-out": "De bron deed er te lang over om te antwoorden.",
561
+ "unreadable": "De bron kon niet gelezen worden."
562
+ }
544
563
  }
545
564
  }
546
565
  }
@@ -160,7 +160,11 @@ export function applyEvent(state: ChatState, event: ChatEvent): ChatState {
160
160
  case 'delta': {
161
161
  const item = state.items[event.itemId];
162
162
  if (event.uiQueries && item?.kind === 'assistant') {
163
- return { ...state, items: { ...state.items, [event.itemId]: { ...item, uiQueries: event.uiQueries } } };
163
+ // The text first, as the daemon's thread applies it, then the metadata on what it grew into.
164
+ const { uiQueries, ...text } = event;
165
+ const grown = event.text === '' ? state : applyEvent(state, text);
166
+ const reply = grown.items[event.itemId] ?? item;
167
+ return { ...grown, items: { ...grown.items, [event.itemId]: { ...reply, uiQueries } as ChatItem } };
164
168
  }
165
169
  if (event.ui !== undefined && event.text === '') {
166
170
  if (item?.kind === 'assistant' && item.streaming && event.textLength === item.text.length) {
@@ -1,15 +0,0 @@
1
- import type { UiLiveStatus } from './render-context.js';
2
- export interface UiBlockFix {
3
- code: string;
4
- message: string;
5
- }
6
- export interface UiBlockFooterProps {
7
- live?: UiLiveStatus | null;
8
- answered?: {
9
- label: string;
10
- at: number;
11
- } | null;
12
- fixes?: readonly UiBlockFix[];
13
- shownAsText?: string | null;
14
- }
15
- export declare function UiBlockFooter({ live, answered, fixes, shownAsText }: UiBlockFooterProps): import("react").JSX.Element | null;