@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
@@ -5,20 +5,12 @@ import { ArrowDown, ArrowRight, ArrowUp } from 'lucide-react';
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.js';
8
- import { uiChildrenOf } from '../node-text.js';
8
+ import { uiChildrenOf, uiWithUnit } from '../node-text.js';
9
9
  import { isNumericColumn, UI_TABLE_ROWS, uiTableCell, uiTableColumns } from '../table-data.js';
10
10
  import { UI_TONE_TEXT } from '../tones.js';
11
11
  import { UiFallbackPart } from '../UiFallbackPart.js';
12
- import { UiLinkChip } from './links.js';
13
12
  /* EntityList draws a hairline between its rows from this many on. */
14
13
  const DIVIDED_FROM = 7;
15
- /* A number with its unit, where a percent sign hugs the number and every other unit stands apart from it. */
16
- function withUnit(text, unit) {
17
- if (unit === undefined || unit === '') {
18
- return text;
19
- }
20
- return unit === '%' ? `${text}${unit}` : `${text} ${unit}`;
21
- }
22
14
  /*
23
15
  * A number that live readings keep up to date. Keyed by its text, so a value that changed mounts
24
16
  * again and lights up once, even when it changes again before the last light settled.
@@ -37,10 +29,10 @@ export function StatRenderer({ node, context }) {
37
29
  const live = context.liveValue?.(node.id, 'value');
38
30
  const change = previous === undefined ? null : value - previous;
39
31
  const arrow = change === null || change === 0 ? ArrowRight : change > 0 ? ArrowUp : ArrowDown;
40
- const from = previous === undefined ? '' : withUnit(formatRounded(previous, 2), unit);
32
+ const from = previous === undefined ? '' : uiWithUnit(formatRounded(previous, 2), unit);
41
33
  // A change from zero has no percentage.
42
34
  const percent = change === null || previous === undefined || previous === 0 ? null : formatPercent(Math.abs((change / previous) * 100));
43
- return (_jsxs("div", { className: "flex min-w-0 flex-col gap-0.5 rounded-md bg-surface-hover px-2 py-1.5", children: [_jsx("span", { className: "truncate text-xs text-text-muted", children: label }), _jsx(LiveFigure, { text: withUnit(formatRounded(value, 2), unit), previous: typeof live?.previous === 'number' ? withUnit(formatRounded(live.previous, 2), unit) : undefined, live: live, className: "text-lg font-semibold tabular-nums" }), change !== null && (_jsxs("span", { className: clsx('flex items-center gap-1 text-xs tabular-nums', tone === undefined ? 'text-text-faint' : UI_TONE_TEXT[tone]), children: [_jsx(Icon, { icon: arrow, size: 12, className: "shrink-0" }), _jsx("span", { className: "min-w-0 truncate", children: percent === null ? t('blocks.stat.from', { previous: from }) : t('blocks.stat.change', { change: percent, previous: from }) })] }))] }));
35
+ return (_jsxs("div", { className: "flex min-w-0 flex-col gap-0.5 rounded-md bg-surface-hover px-2 py-1.5", children: [_jsx("span", { className: "truncate text-xs text-text-muted", children: label }), _jsx(LiveFigure, { text: uiWithUnit(formatRounded(value, 2), unit), previous: typeof live?.previous === 'number' ? uiWithUnit(formatRounded(live.previous, 2), unit) : undefined, live: live, className: "text-lg font-semibold tabular-nums" }), change !== null && (_jsxs("span", { className: clsx('flex items-center gap-1 text-xs tabular-nums', tone === undefined ? 'text-text-faint' : UI_TONE_TEXT[tone]), children: [_jsx(Icon, { icon: arrow, size: 12, className: "shrink-0" }), _jsx("span", { className: "min-w-0 truncate", children: percent === null ? t('blocks.stat.from', { previous: from }) : t('blocks.stat.change', { change: percent, previous: from }) })] }))] }));
44
36
  }
45
37
  export function EntityListRenderer({ node, children }) {
46
38
  return (_jsx(KeyValueList.Root, { divided: uiChildrenOf(node, 'Entry').length >= DIVIDED_FROM, className: "chat-ui-nodes px-2", children: children }));
@@ -49,9 +41,9 @@ export function EntryRenderer({ node, children }) {
49
41
  return (_jsxs(KeyValueList.Item, { children: [_jsx(KeyValueList.Name, { children: node.props.label }), _jsx(KeyValueList.Value, { children: children })] }));
50
42
  }
51
43
  function numberText(cell, unit) {
52
- return cell.as === 'bytes' ? formatBytes(cell.value) : cell.as === 'duration' ? formatDuration(cell.value) : withUnit(formatRounded(cell.value, 2), unit);
44
+ return cell.as === 'bytes' ? formatBytes(cell.value) : cell.as === 'duration' ? formatDuration(cell.value) : uiWithUnit(formatRounded(cell.value, 2), unit);
53
45
  }
54
- function Cell({ value, column, live, context }) {
46
+ function Cell({ value, column, live }) {
55
47
  const cell = uiTableCell(value, column);
56
48
  switch (cell.kind) {
57
49
  case 'empty':
@@ -65,7 +57,8 @@ function Cell({ value, column, live, context }) {
65
57
  case 'date':
66
58
  return _jsx("time", { dateTime: new Date(cell.at).toISOString(), children: formatMoment(cell.at) });
67
59
  case 'file':
68
- return _jsx(UiLinkChip, { target: { type: 'File', path: cell.path }, context: context });
60
+ // A cell is no link target the host checked, so its path stays text.
61
+ return _jsx("span", { className: "font-mono text-code break-all", children: cell.path });
69
62
  case 'tag':
70
63
  return (_jsx(Pill, { shape: "tag", size: "sm", children: cell.text }));
71
64
  }
@@ -83,5 +76,5 @@ export function TableRenderer({ node, context }) {
83
76
  }
84
77
  const { rows } = node.props;
85
78
  const shown = all ? rows : rows.slice(0, UI_TABLE_ROWS);
86
- return (_jsxs("div", { className: "flex flex-col items-start gap-1.5", children: [_jsx("div", { className: "max-h-80 w-full overflow-auto rounded-md border border-border-soft", children: _jsxs("table", { className: "w-full border-collapse text-xs tabular-nums", children: [_jsx("thead", { className: "sticky top-0 z-1 bg-surface-hover", children: _jsx("tr", { children: columns.map((column) => (_jsx("th", { scope: "col", className: clsx('h-8 px-2 font-medium whitespace-nowrap text-text-muted', isNumericColumn(column) ? 'text-right' : 'text-left'), children: column.title }, column.key))) }) }), _jsx("tbody", { children: shown.map((row, index) => (_jsx("tr", { className: "border-t border-border-soft", children: columns.map((column) => (_jsx("td", { className: clsx('h-8 px-2 text-text', isNumericColumn(column) ? 'text-right whitespace-nowrap' : 'text-left'), children: _jsx(Cell, { value: row[column.key], column: column, live: isNumericColumn(column) ? context.liveValue?.(node.id, 'rows', [index, column.key]) : undefined, context: context }) }, column.key))) }, index))) })] }) }), !all && rows.length > UI_TABLE_ROWS && (_jsx(Button, { size: "xs", variant: "secondary", onClick: () => setAll(true), children: t('blocks.table.showAll', { total: formatNumber(rows.length) }) }))] }));
79
+ return (_jsxs("div", { className: "flex flex-col items-start gap-1.5", children: [_jsx("div", { className: "max-h-80 w-full overflow-auto rounded-md border border-border-soft", children: _jsxs("table", { "aria-label": t('blocks.table.label', { columns: columns.map((column) => column.title).join(', ') }), className: "w-full border-collapse text-xs tabular-nums", children: [_jsx("thead", { className: "sticky top-0 z-1 bg-surface-hover", children: _jsx("tr", { children: columns.map((column) => (_jsx("th", { scope: "col", className: clsx('h-8 px-2 font-medium whitespace-nowrap text-text-muted', isNumericColumn(column) ? 'text-right' : 'text-left'), children: column.title }, column.key))) }) }), _jsx("tbody", { children: shown.map((row, index) => (_jsx("tr", { className: "border-t border-border-soft", children: columns.map((column) => (_jsx("td", { className: clsx('h-8 px-2 text-text', isNumericColumn(column) ? 'text-right whitespace-nowrap' : 'text-left'), children: _jsx(Cell, { value: row[column.key], column: column, live: isNumericColumn(column) ? context.liveValue?.(node.id, 'rows', [index, column.key]) : undefined }) }, column.key))) }, index))) })] }) }), !all && rows.length > UI_TABLE_ROWS && (_jsx(Button, { size: "xs", variant: "secondary", onClick: () => setAll(true), children: t('blocks.table.showAll', { total: formatNumber(rows.length) }) }))] }));
87
80
  }
@@ -5,3 +5,4 @@ export declare function ItemRenderer({ node, children }: UiRendererProps<UiProps
5
5
  export declare function SwitchRenderer({ node, children, context }: UiRendererProps<UiProps<'Switch'>>): import("react").JSX.Element;
6
6
  export declare function SliderRenderer({ node, context }: UiRendererProps<UiProps<'Slider'>>): import("react").JSX.Element;
7
7
  export declare function SegmentedRenderer({ node, context }: UiRendererProps<UiProps<'Segmented'>>): import("react").JSX.Element;
8
+ export declare function ButtonRenderer({ node, children, context }: UiRendererProps<UiProps<'Button'>>): import("react").JSX.Element;
@@ -2,8 +2,8 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { createContext, useContext } from 'react';
3
3
  import clsx from 'clsx';
4
4
  import { useTranslation } from 'react-i18next';
5
- import { Checkbox, Segmented, Slider, Switch } from '@adecore/ui';
6
- import { uiChildrenOf, uiNodeLabel } from '../node-text.js';
5
+ import { Button, Checkbox, Segmented, Slider, Switch } from '@adecore/ui';
6
+ import { uiChildrenOf, uiNodeLabel, uiWithUnit } from '../node-text.js';
7
7
  const ChecklistContext = createContext({ values: [], enabled: false, toggle: () => undefined });
8
8
  /*
9
9
  * The binding of an input, or null for a read-only node. An input works once its own node closed and
@@ -15,13 +15,18 @@ function bindingOf(node, context) {
15
15
  }
16
16
  /* An input whose node has not closed yet stands at 60% and takes no pointer. */
17
17
  const PENDING = 'pointer-events-none opacity-60';
18
+ /* The rows of a checklist as one named group. */
19
+ function ChecklistGroup({ pending, children }) {
20
+ const { t } = useTranslation('agent-chat');
21
+ return (_jsx("div", { role: "group", "aria-label": t('blocks.checklist'), className: clsx('chat-ui-nodes flex flex-col', pending && PENDING), children: children }));
22
+ }
18
23
  export function ChecklistRenderer({ node, children, context }) {
19
24
  const binding = bindingOf(node, context);
20
25
  const values = binding?.value ?? node.props.value;
21
26
  const toggle = (value) => {
22
27
  binding?.onValueChange(values.includes(value) ? values.filter((entry) => entry !== value) : [...values, value]);
23
28
  };
24
- return (_jsx(ChecklistContext, { value: { values, enabled: binding !== null, toggle }, children: _jsx("div", { className: clsx('chat-ui-nodes flex flex-col', !node.complete && PENDING), children: children }) }));
29
+ return (_jsx(ChecklistContext, { value: { values, enabled: binding !== null, toggle }, children: _jsx(ChecklistGroup, { pending: !node.complete, children: children }) }));
25
30
  }
26
31
  /* The whole row is the label; a link inside it keeps its own click and checks nothing. */
27
32
  export function ItemRenderer({ node, children }) {
@@ -42,7 +47,9 @@ export function SwitchRenderer({ node, children, context }) {
42
47
  export function SliderRenderer({ node, context }) {
43
48
  const binding = bindingOf(node, context);
44
49
  const { value, min, max, step, unit } = node.props;
45
- return (_jsx(Slider, { value: binding?.value ?? value, onValueChange: (next) => binding?.onValueChange(next), min: min, max: max, step: step, unit: unit, label: uiNodeLabel(node), disabled: binding === null, className: clsx('px-2', !node.complete && PENDING) }));
50
+ return (_jsx(Slider, { value: binding?.value ?? value, onValueChange: (next) => binding?.onValueChange(next), min: min, max: max, step: step,
51
+ // The slider prints its unit right after the number, so the space a unit stands apart with comes from here.
52
+ unit: uiWithUnit('', unit), label: uiNodeLabel(node), disabled: binding === null, className: clsx('px-2', !node.complete && PENDING) }));
46
53
  }
47
54
  /* Left aligned, never the full width. Each Option is a segment by its place, since a value may be any scalar. */
48
55
  export function SegmentedRenderer({ node, context }) {
@@ -58,3 +65,8 @@ export function SegmentedRenderer({ node, context }) {
58
65
  }
59
66
  } }) }));
60
67
  }
68
+ /* Runs its local action once its own node closed; like an input, it closes once the block is answered. */
69
+ export function ButtonRenderer({ node, children, context }) {
70
+ const enabled = node.onAction !== undefined && node.complete && context.answer === null && node.props.disabled !== true;
71
+ return (_jsx("div", { className: clsx('px-2', !node.complete && PENDING), children: _jsx(Button, { variant: "secondary", size: "sm", disabled: !enabled, onClick: () => node.onAction?.(), children: children }) }));
72
+ }
@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
4
4
  import { FileDiff, GitCommitHorizontal, SquareDashed } from 'lucide-react';
5
5
  import { FileIcon, Icon, Tooltip } from '@adecore/ui';
6
6
  import { CHIP_IN_MESSAGE, MENTION_TONE } from '../../chips.js';
7
+ import { uiReasonText } from '../node-text.js';
7
8
  const PLAIN = { state: 'plain' };
8
9
  function baseName(path) {
9
10
  const trimmed = path.replace(/\/+$/, '');
@@ -54,7 +55,7 @@ export function UiLinkChip({ target, context }) {
54
55
  const { t } = useTranslation('agent-chat');
55
56
  const link = context.link?.(target) ?? PLAIN;
56
57
  if (link.state === 'plain') {
57
- const reason = link.reason ?? (context.link === undefined ? null : t('blocks.outsideProject'));
58
+ const reason = uiReasonText(t, link.code, link.reason) ?? (context.link === undefined ? null : t('blocks.outsideProject'));
58
59
  // Focusable only with a reason, so a keyboard reaches the tooltip.
59
60
  const text = (_jsx("span", { tabIndex: reason === null ? undefined : 0, className: "font-mono text-code break-all text-text-muted", children: plainText(target, link) }));
60
61
  return reason === null ? text : _jsx(Tooltip, { label: reason, children: text });
@@ -1,9 +1,9 @@
1
- import type { ChatUiQueryState } from '@adecore/agent-contracts';
1
+ import type { ChatUiLinkReading, ChatUiQueryState } from '@adecore/agent-contracts';
2
2
  import { type UiLinkTarget } from '@adecore/intelligent-ui/links';
3
3
  import { type UiBlock, UiState } from '@adecore/intelligent-ui';
4
4
  import type { UiRenderContext } from './render-context.js';
5
- export declare function useUiLinks(block: UiBlock, state: UiState, context: UiRenderContext, reads: Readonly<Record<string, string>>, frozen?: ChatUiQueryState['blocks'][string]): {
6
- link: ((target: UiLinkTarget) => import("./render-context.js").UiLink) | undefined;
5
+ export declare function useUiLinks(block: UiBlock, state: UiState, context: Pick<UiRenderContext, 'scopeId' | 'chatId' | 'itemId' | 'phase'>, reads: Readonly<Record<string, string>>, frozen?: ChatUiQueryState['blocks'][string]): {
6
+ link: ((target: UiLinkTarget) => ChatUiLinkReading) | undefined;
7
7
  openLink: ((target: UiLinkTarget) => void) | undefined;
8
- openUrl: ((url: string) => void) | undefined;
8
+ openUrl: ((url: string) => void | undefined) | undefined;
9
9
  };
@@ -1,9 +1,13 @@
1
- import { useEffect, useState } from 'react';
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
2
  import { uiLinkTargets } from '@adecore/intelligent-ui/links';
3
- import { uiInputValues, UiState } from '@adecore/intelligent-ui';
3
+ import { uiInputValues, UI_HOST_LIMITS, UiState } from '@adecore/intelligent-ui';
4
4
  import { chatHost } from '../../../host.js';
5
+ // Inputs that change in a run, such as a dragged slider, check their links once the run rested.
6
+ const LINK_SETTLE_MS = 300;
5
7
  export function useUiLinks(block, state, context, reads, frozen) {
6
8
  const [checked, setChecked] = useState({});
9
+ // What each node was last checked as, so a change of input checks only the targets it moved.
10
+ const asked = useRef(new Map());
7
11
  const host = chatHost().intelligentUi;
8
12
  const values = uiInputValues(block, state);
9
13
  const scope = state.scope();
@@ -20,14 +24,25 @@ export function useUiLinks(block, state, context, reads, frozen) {
20
24
  const signature = JSON.stringify(targets);
21
25
  const input = JSON.stringify(values);
22
26
  const tickets = JSON.stringify(reads);
27
+ const defaultInput = useMemo(() => JSON.stringify(uiInputValues(block, new UiState(block))), [block]);
23
28
  const link = host?.link;
29
+ useEffect(() => {
30
+ asked.current.clear();
31
+ }, [block.id, block.revision, context.scopeId, context.chatId, context.itemId, link]);
24
32
  useEffect(() => {
25
33
  if (!link || !block.revision || context.phase !== 'final') {
26
34
  return;
27
35
  }
28
36
  let stopped = false;
29
37
  const validate = async () => {
30
- for (const [nodeId, target] of Object.entries(JSON.parse(signature)).slice(0, 64)) {
38
+ const moved = Object.entries(JSON.parse(signature))
39
+ .slice(0, UI_HOST_LIMITS.links)
40
+ .filter(([nodeId, target]) => asked.current.get(nodeId) !== JSON.stringify(target));
41
+ for (const [nodeId, target] of moved) {
42
+ const record = (reading) => {
43
+ asked.current.set(nodeId, JSON.stringify(target));
44
+ setChecked((previous) => ({ ...previous, [nodeId]: { target: JSON.stringify(target), reading } }));
45
+ };
31
46
  try {
32
47
  const reading = await link(context.scopeId, {
33
48
  chatId: context.chatId,
@@ -41,19 +56,21 @@ export function useUiLinks(block, state, context, reads, frozen) {
41
56
  if (stopped) {
42
57
  return;
43
58
  }
44
- setChecked((previous) => ({ ...previous, [nodeId]: { target: JSON.stringify(target), reading } }));
59
+ record(reading);
45
60
  }
46
61
  catch {
47
62
  if (stopped) {
48
63
  return;
49
64
  }
50
- setChecked((previous) => ({ ...previous, [nodeId]: { target: JSON.stringify(target), reading: { state: 'plain' } } }));
65
+ record({ state: 'plain' });
51
66
  }
52
67
  }
53
68
  };
54
- void validate();
69
+ // The first check runs at once; a later one waits until the inputs rested.
70
+ const timer = setTimeout(() => void validate(), asked.current.size === 0 ? 0 : LINK_SETTLE_MS);
55
71
  return () => {
56
72
  stopped = true;
73
+ clearTimeout(timer);
57
74
  };
58
75
  }, [block.id, block.revision, context.scopeId, context.chatId, context.itemId, context.phase, signature, input, tickets, link]);
59
76
  const idOf = (target) => Object.entries(targets).find(([, entry]) => JSON.stringify(entry) === JSON.stringify(target))?.[0];
@@ -65,7 +82,7 @@ export function useUiLinks(block, state, context, reads, frozen) {
65
82
  if (checked[id]?.target === JSON.stringify(target)) {
66
83
  return checked[id].reading;
67
84
  }
68
- if (input === JSON.stringify(uiInputValues(block, new UiState(block))) && frozen && frozen.revision === block.revision) {
85
+ if (input === defaultInput && frozen && frozen.revision === block.revision) {
69
86
  const firstQueries = Object.fromEntries(Object.entries(frozen.readings)
70
87
  .filter(([, reading]) => reading.state === 'fresh')
71
88
  .map(([name, reading]) => [name, reading.value]));
@@ -99,9 +116,11 @@ export function useUiLinks(block, state, context, reads, frozen) {
99
116
  setChecked((previous) => ({ ...previous, [nodeId]: { target: JSON.stringify(target), reading: { state: 'plain' } } }));
100
117
  });
101
118
  };
119
+ const hostOpenUrl = host?.openUrl;
120
+ const openUrl = useCallback((url) => hostOpenUrl?.(context.scopeId, url), [hostOpenUrl, context.scopeId]);
102
121
  return {
103
- link: link ? resolution : context.link,
104
- openLink: link && host?.openLink ? open : context.openLink,
105
- openUrl: host?.openUrl ? (url) => host.openUrl(context.scopeId, url) : context.openUrl
122
+ link: link ? resolution : undefined,
123
+ openLink: link && host?.openLink ? open : undefined,
124
+ openUrl: hostOpenUrl ? openUrl : undefined
106
125
  };
107
126
  }
@@ -1,7 +1,7 @@
1
1
  import type { ChatUiQueryReading, ChatUiQueryState } from '@adecore/agent-contracts';
2
2
  import { UiState, type UiBlock, type UiViewNode } from '@adecore/intelligent-ui';
3
3
  import type { UiLiveValue, UiLiveStatus, UiRenderContext } from './render-context.js';
4
- export declare function useUiQueries(block: UiBlock, state: UiState, context: UiRenderContext, element: HTMLDivElement | null, frozen?: ChatUiQueryState['blocks'][string]): {
4
+ export declare function useUiQueries(block: UiBlock, state: UiState, context: Pick<UiRenderContext, 'scopeId' | 'chatId' | 'itemId' | 'phase'>, element: HTMLDivElement | null, frozen?: ChatUiQueryState['blocks'][string]): {
5
5
  live: UiLiveStatus | undefined;
6
6
  reads: Record<string, string>;
7
7
  reading: boolean;
@@ -13,6 +13,7 @@ export declare function useUiQueries(block: UiBlock, state: UiState, context: Ui
13
13
  value?: unknown;
14
14
  readId?: string | undefined;
15
15
  readAt: number;
16
+ code?: string | undefined;
16
17
  reason?: string | undefined;
17
18
  };
18
19
  };
@@ -1,27 +1,34 @@
1
- import { useEffect, useRef, useState } from 'react';
2
- import { uiInputValues, UI_CATALOG_VERSION, UiState } from '@adecore/intelligent-ui';
1
+ import { useEffect, useMemo, useRef, useState } from 'react';
2
+ import { uiInputValues, UI_CATALOG_VERSION, UI_HOST_LIMITS, UiState } from '@adecore/intelligent-ui';
3
3
  import { chatHost } from '../../../host.js';
4
+ const NO_READINGS = {};
5
+ // A changed input reads once it has rested this long, so dragging a slider reads its last value only.
6
+ const INPUT_SETTLE_MS = 300;
7
+ /* 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. */
8
+ function successfulReading(local, initial, identity, signature, defaultInputs) {
9
+ if (local?.identity === identity && local.successful?.inputs === signature) {
10
+ return local.successful.reading;
11
+ }
12
+ return signature === defaultInputs && initial?.state === 'fresh' ? initial : undefined;
13
+ }
14
+ function settleDeadline() {
15
+ return Date.now() + INPUT_SETTLE_MS;
16
+ }
4
17
  function documentVisible() {
5
18
  return document.visibilityState !== 'hidden';
6
19
  }
7
20
  export function useUiQueries(block, state, context, element, frozen) {
8
21
  const [readings, setReadings] = useState({});
9
22
  const [busyInput, setBusyInput] = useState(null);
10
- const lastReadAt = useRef({ identity: '', at: 0 });
23
+ const lastRead = useRef({ inputKey: '', at: 0 });
11
24
  const names = Object.keys(block.queries);
12
25
  const inputs = JSON.stringify(uiInputValues(block, state));
13
26
  const identity = JSON.stringify([context.scopeId, context.chatId, context.itemId, block.id, block.revision]);
14
27
  const inputKey = JSON.stringify([identity, inputs]);
15
28
  const reading = busyInput === inputKey;
16
- const defaultInputs = JSON.stringify(uiInputValues(block, new UiState(block)));
17
- const initial = frozen && frozen.revision === block.revision ? frozen.readings : {};
18
- const successfulFor = (name, signature) => {
19
- const local = readings[name];
20
- if (local?.identity === identity && local.successful?.inputs === signature) {
21
- return local.successful.reading;
22
- }
23
- return signature === defaultInputs && initial[name]?.state === 'fresh' ? initial[name] : undefined;
24
- };
29
+ const defaultInputs = useMemo(() => JSON.stringify(uiInputValues(block, new UiState(block))), [block]);
30
+ const initial = frozen && frozen.revision === block.revision ? frozen.readings : NO_READINGS;
31
+ const successfulFor = (name, signature) => successfulReading(readings[name], initial[name], identity, signature, defaultInputs);
25
32
  const readsFor = (values) => {
26
33
  const signature = JSON.stringify(values);
27
34
  const reads = {};
@@ -41,13 +48,13 @@ export function useUiQueries(block, state, context, element, frozen) {
41
48
  const subscribe = chatHost().intelligentUi?.subscribe;
42
49
  useEffect(() => {
43
50
  const scope = state.scope();
44
- for (const name of names) {
45
- const successful = successfulFor(name, inputs);
51
+ for (const name of Object.keys(block.queries)) {
52
+ const successful = successfulReading(readings[name], initial[name], identity, inputs, defaultInputs);
46
53
  if (successful && JSON.stringify(scope[name]) !== JSON.stringify(successful.value)) {
47
54
  state.setQuery(name, successful.value, block);
48
55
  }
49
56
  }
50
- });
57
+ }, [state, block, readings, identity, inputs, defaultInputs, initial]);
51
58
  useEffect(() => {
52
59
  if (!query ||
53
60
  !element ||
@@ -62,11 +69,14 @@ export function useUiQueries(block, state, context, element, frozen) {
62
69
  let visible = typeof IntersectionObserver === 'undefined';
63
70
  let busy = false;
64
71
  let timer = null;
72
+ // The first read of this block reads at once; a changed input waits until it rests.
73
+ const changed = lastRead.current.inputKey !== '' && lastRead.current.inputKey !== inputKey;
74
+ const settledAt = changed ? settleDeadline() : 0;
65
75
  const refresh = async () => {
66
76
  if (stopped || !visible || !documentVisible() || busy) {
67
77
  return;
68
78
  }
69
- const wait = lastReadAt.current.identity === identity ? 10_000 - (Date.now() - lastReadAt.current.at) : 0;
79
+ const wait = Math.max(settledAt - Date.now(), lastRead.current.inputKey === inputKey ? UI_HOST_LIMITS.refreshMilliseconds - (Date.now() - lastRead.current.at) : 0);
70
80
  if (wait > 0) {
71
81
  if (timer === null) {
72
82
  if (!visible || !documentVisible()) {
@@ -80,9 +90,9 @@ export function useUiQueries(block, state, context, element, frozen) {
80
90
  return;
81
91
  }
82
92
  busy = true;
83
- lastReadAt.current = { identity, at: Date.now() };
93
+ lastRead.current = { inputKey, at: Date.now() };
84
94
  setBusyInput(inputKey);
85
- for (const name of Object.keys(block.queries).slice(0, 8)) {
95
+ for (const name of Object.keys(block.queries).slice(0, UI_HOST_LIMITS.queries)) {
86
96
  if (!visible || !documentVisible()) {
87
97
  break;
88
98
  }
@@ -118,7 +128,7 @@ export function useUiQueries(block, state, context, element, frozen) {
118
128
  ...previous,
119
129
  [name]: {
120
130
  identity,
121
- reading: { state: 'failed', readAt: Date.now(), reason: error instanceof Error ? error.message : 'This query could not be read.' },
131
+ reading: { state: 'failed', readAt: Date.now(), code: 'unreadable', reason: error instanceof Error ? error.message : undefined },
122
132
  successful: previous[name]?.identity === identity ? previous[name].successful : undefined
123
133
  }
124
134
  }));
@@ -132,7 +142,7 @@ export function useUiQueries(block, state, context, element, frozen) {
132
142
  timer = setTimeout(() => {
133
143
  timer = null;
134
144
  void refresh();
135
- }, 10_000);
145
+ }, UI_HOST_LIMITS.refreshMilliseconds);
136
146
  };
137
147
  const observer = typeof IntersectionObserver === 'undefined'
138
148
  ? null
@@ -157,6 +167,8 @@ export function useUiQueries(block, state, context, element, frozen) {
157
167
  void refresh();
158
168
  return () => {
159
169
  stopped = true;
170
+ // A read cut short by this cleanup never reaches its own reset, which would leave the choices closed.
171
+ setBusyInput((current) => (current === inputKey ? null : current));
160
172
  observer?.disconnect();
161
173
  unsubscribe?.();
162
174
  document.removeEventListener('visibilitychange', focus);
@@ -179,7 +191,8 @@ export function useUiQueries(block, state, context, element, frozen) {
179
191
  .filter((reading) => reading.state === 'fresh')
180
192
  .reduce((at, value) => (at === null ? value.readAt : Math.min(at, value.readAt)), null),
181
193
  source: failed ? block.queries[failed[0]]?.source : undefined,
182
- reason: failed?.[1].reason
194
+ reason: failed?.[1].reason,
195
+ code: failed?.[1].code
183
196
  }
184
197
  : undefined;
185
198
  return { live, reads: reads ?? {}, reading, ready, readsFor, currentReadings };
@@ -129,7 +129,7 @@ export function AssistantRow({ chatId, item: derived }) {
129
129
  const live = mode === 'words' && item.streaming;
130
130
  const reveal = useRevealedText(item.text, live);
131
131
  if (item.ui?.length && !item.parentToolUseId && !(mode === 'whole' && item.streaming)) {
132
- return (_jsxs("div", { "data-find-item": item.id, className: "-mx-1 px-1 pb-2", children: [_jsx(ReplyHeading, { chatId: chatId }), _jsx("div", { "data-find-field": "text", "data-quote-answer": true, className: "contents", children: _jsx(UiReply, { text: item.text, blocks: item.ui, answers: item.uiAnswers, queries: item.uiQueries, reply: reply, context: { scopeId: scope.id, chatId, itemId: item.id, phase: item.streaming ? 'streaming' : 'final', answer: null } }) })] }));
132
+ return (_jsxs("div", { "data-find-item": item.id, className: "-mx-1 px-1 pb-2", children: [_jsx(ReplyHeading, { chatId: chatId }), _jsx("div", { "data-find-field": "text", "data-quote-answer": true, className: "contents", children: _jsx(UiReply, { text: item.text, blocks: item.ui, answers: item.uiAnswers, queries: item.uiQueries, reply: reply, context: { scopeId: scope.id, chatId, itemId: item.id, phase: item.streaming ? 'streaming' : 'final' } }) })] }));
133
133
  }
134
134
  if (mode === 'blocks') {
135
135
  const settled = item.streaming ? settledBlocksText(item.text) : item.text;
@@ -186,9 +186,8 @@ function OpenChatButton({ chatId }) {
186
186
  return (_jsx("button", { className: "text-text-muted hover:text-text", onClick: place.go, children: t('rows.note.openFork') }));
187
187
  }
188
188
  /*
189
- * The header of a turn the agent started itself, in place of the message of the person that is
190
- * missing. It is one line: what the sub-agent came back with lives on that agent's own row, behind
191
- * "Show result". When the CLI named the sub-agent it woke up about, the header opens that row.
189
+ * The header of a turn the agent started itself, in place of a message of the person. What the
190
+ * sub-agent came back with lives on its own row, which the header opens when the CLI named it.
192
191
  */
193
192
  export function AgentTurnRow({ label, onOpen }) {
194
193
  const line = 'mb-0.5 flex h-7 w-full items-center gap-2 text-left text-xs text-text-muted';
@@ -1,20 +1,21 @@
1
+ import { type ReactNode } from 'react';
1
2
  import type { ChatCheckpointDiff, ChatToolItem, ChatTurnItem } from '@adecore/agent-contracts';
2
3
  export declare function ToggleLine({ icon, label, detail, open, onToggle, failed, live, trailing, className }: {
3
- icon: React.ReactNode;
4
+ icon: ReactNode;
4
5
  label: string;
5
6
  detail?: string;
6
7
  open: boolean;
7
8
  onToggle(): void;
8
9
  failed?: boolean;
9
10
  live?: boolean;
10
- trailing?: React.ReactNode;
11
+ trailing?: ReactNode;
11
12
  className?: string;
12
13
  }): import("react").JSX.Element;
13
14
  export declare function WorkRow({ tool, nested, detail, icon, label, failed }: {
14
15
  tool: ChatToolItem;
15
16
  nested?: boolean;
16
17
  detail?: string;
17
- icon?: React.ReactNode;
18
+ icon?: ReactNode;
18
19
  label?: string;
19
20
  failed?: boolean;
20
21
  }): import("react").JSX.Element;
@@ -1,4 +1,4 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Suspense, useEffect, useState } from 'react';
3
3
  import clsx from 'clsx';
4
4
  import i18next from 'i18next';
@@ -30,15 +30,24 @@ export function ToggleLine({ icon, label, detail, open, onToggle, failed, live,
30
30
  // Fix the width so long commands reach the truncation inside the negative margins.
31
31
  '-mx-1 mb-0.5 flex h-7 w-[calc(100%+8px)] items-center gap-2 rounded-md px-1 text-left text-xs text-text-muted hover:bg-surface-hover', failed && 'text-status-error', className), onClick: onToggle, children: [_jsx("span", { className: clsx(ROW_GUTTER, live && 'text-accent'), children: icon }), _jsx("span", { className: clsx('shrink-0', live && 'chat-live-text'), children: label }), detail && (_jsx("span", { "data-find-field": "summary", className: "min-w-0 truncate font-mono text-text-faint", children: detail })), _jsx("span", { className: "grow" }), trailing, failed && _jsx(Icon, { icon: X, size: 12, className: "shrink-0" }), _jsx(Icon, { icon: ChevronRight, size: 12, className: clsx('shrink-0 text-text-faint transition-transform', open && 'rotate-90') })] }));
32
32
  }
33
- function ToolBody({ tool }) {
33
+ /* The diff renderers load lazily, so a diff waits on them under a line that says so. */
34
+ function DiffSuspense({ children }) {
34
35
  const { t } = useTranslation('agent-chat');
36
+ return _jsx(Suspense, { fallback: _jsx("div", { className: "px-3 py-2 text-xs text-text-faint", children: t('work.loadingDiff') }), children: children });
37
+ }
38
+ /* A file in a changed files card, which opens its diff under it. */
39
+ function ChangedFileLine({ path, open, onToggle, trailing, children }) {
40
+ return (_jsxs("div", { className: "border-t border-border", children: [_jsxs("button", { className: "flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs text-text-muted hover:bg-surface-hover", "data-file-path": path, onClick: onToggle, children: [_jsx(Icon, { icon: ChevronRight, size: 12, className: clsx('shrink-0 text-text-faint transition-transform', open && 'rotate-90') }), _jsx("span", { className: "min-w-0 truncate font-mono text-text", children: path }), _jsx("span", { className: "grow" }), trailing] }), open && _jsx("div", { className: "border-t border-border", children: children })] }));
41
+ }
42
+ function ToolBody({ tool }) {
35
43
  const patches = unifiedChanges(tool);
36
44
  const changes = patches.length > 0 ? [] : fileChanges(tool.name, tool.input);
37
- return (_jsxs("div", { className: "mt-1.5 mb-2 ml-6 overflow-hidden rounded-md border border-border bg-surface-raised", children: [patches.length > 0 ? (_jsx(Suspense, { fallback: _jsx("div", { className: "px-3 py-2 text-xs text-text-faint", children: t('work.loadingDiff') }), children: patches.map((change, index) => (_jsx(UnifiedDiff, { change: change }, index))) })) : changes.length > 0 ? (_jsx(Suspense, { fallback: _jsx("div", { className: "px-3 py-2 text-xs text-text-faint", children: t('work.loadingDiff') }), children: changes.map((change, index) => (_jsx(EditDiff, { change: change }, index))) })) : (_jsx("pre", { className: "max-h-64 overflow-auto px-3 py-2 font-mono text-code text-text-muted select-text", children: clip(JSON.stringify(tool.input, null, 2) ?? '') })), tool.output !== null && tool.output !== '' && patches.length === 0 && (_jsx("pre", { "data-find-field": "output", className: clsx('max-h-64 overflow-auto whitespace-pre-wrap border-t border-border px-3 py-2 font-mono text-code select-text', tool.state === 'error' ? 'text-chat-removed' : 'text-chat-output'), children: _jsx(AnsiOutput, { text: tool.output, limit: OUTPUT_LIMIT }) }))] }));
45
+ return (_jsxs("div", { className: "mt-1.5 mb-2 ml-6 overflow-hidden rounded-md border border-border bg-surface-raised", children: [patches.length > 0 ? (_jsx(DiffSuspense, { children: patches.map((change, index) => (_jsx(UnifiedDiff, { change: change }, index))) })) : changes.length > 0 ? (_jsx(DiffSuspense, { children: changes.map((change, index) => (_jsx(EditDiff, { change: change }, index))) })) : (_jsx("pre", { className: "max-h-64 overflow-auto px-3 py-2 font-mono text-code text-text-muted select-text", children: clip(JSON.stringify(tool.input, null, 2) ?? '') })), tool.output !== null && tool.output !== '' && patches.length === 0 && (_jsx("pre", { "data-find-field": "output", className: clsx('max-h-64 overflow-auto whitespace-pre-wrap border-t border-border px-3 py-2 font-mono text-code select-text', tool.state === 'error' ? 'text-chat-removed' : 'text-chat-output'), children: _jsx(AnsiOutput, { text: tool.output, limit: OUTPUT_LIMIT }) }))] }));
38
46
  }
39
- /* One settled tool call: a line, and its input and output behind it. An image the call looked at
40
- is drawn under the line, because a picture says more about that read than its path does.
41
- `icon`, `label` and `failed` let a row that knows the call better name it in its own words. */
47
+ /*
48
+ * One settled tool call: a line, and its input and output behind it. An image the call looked at is
49
+ * drawn under the line. `icon`, `label` and `failed` let a row that knows the call better name it.
50
+ */
42
51
  export function WorkRow({ tool, nested, detail, icon, label, failed }) {
43
52
  const [open, setOpen] = useState(false);
44
53
  const image = tool.state === 'done' ? readImagePath(tool.name, tool.input) : null;
@@ -76,17 +85,16 @@ function omittedLabel(reason) {
76
85
  }
77
86
  /* One file of a turn's checkpoint diff: its patch, or the reason there is none. */
78
87
  function CheckpointFileBody({ file }) {
79
- const { t } = useTranslation('agent-chat');
80
88
  if (file.omitted) {
81
89
  return _jsx("div", { className: "px-3 py-2 text-xs text-text-faint", children: omittedLabel(file.omitted) });
82
90
  }
83
- return (_jsx(Suspense, { fallback: _jsx("div", { className: "px-3 py-2 text-xs text-text-faint", children: t('work.loadingDiff') }), children: _jsx(UnifiedDiff, { change: file }) }));
91
+ return (_jsx(DiffSuspense, { children: _jsx(UnifiedDiff, { change: file }) }));
84
92
  }
85
93
  /* The frame of a changed files card: a header that folds the list of files away. */
86
94
  function ChangedFilesCard({ count, truncated, added, deleted, revealNonce = 0, children }) {
87
95
  const { t } = useTranslation('agent-chat');
88
96
  const [expanded, setExpanded] = useState(false);
89
- // A jump from outside opens the card, also when the jump put the row on screen and so mounted it; the flash is gone by the time the row is scrolled to again.
97
+ // A jump opens the card, also one that mounted it by scrolling it on screen; the flash is gone by the next visit.
90
98
  const [seenReveal, setSeenReveal] = useState(0);
91
99
  if (revealNonce > 0 && revealNonce !== seenReveal) {
92
100
  setSeenReveal(revealNonce);
@@ -127,7 +135,7 @@ export function ChangedFilesRow({ tools, diff, checkpoint, chatId, turnId }) {
127
135
  const revealNonce = flash !== null && flash.opens && flash.key === keyOf(chatId) && flash.rowId === `files-${turnId}` ? flash.nonce : 0;
128
136
  if (checkpointDiff !== null && checkpointDiff.files.length > 0) {
129
137
  const files = checkpointDiff.files;
130
- return (_jsx(ChangedFilesCard, { revealNonce: revealNonce, count: files.length, truncated: checkpointDiff.truncated, added: files.reduce((sum, file) => sum + file.added, 0), deleted: files.reduce((sum, file) => sum + file.deleted, 0), children: files.map((file) => (_jsxs("div", { className: "border-t border-border", children: [_jsxs("button", { className: "flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs text-text-muted hover:bg-surface-hover", "data-file-path": file.path, onClick: () => setOpen((o) => ({ ...o, [file.path]: !o[file.path] })), children: [_jsx(Icon, { icon: ChevronRight, size: 12, className: clsx('shrink-0 text-text-faint transition-transform', open[file.path] && 'rotate-90') }), _jsx("span", { className: "min-w-0 truncate font-mono text-text", children: file.path }), _jsx("span", { className: "grow" }), _jsxs("span", { className: "text-chat-added tabular-nums", children: ["+", file.added] }), _jsxs("span", { className: "text-chat-removed tabular-nums", children: ["-", file.deleted] })] }), open[file.path] && (_jsx("div", { className: "border-t border-border", children: _jsx(CheckpointFileBody, { file: file }) }))] }, file.path))) }));
138
+ return (_jsx(ChangedFilesCard, { revealNonce: revealNonce, count: files.length, truncated: checkpointDiff.truncated, added: files.reduce((sum, file) => sum + file.added, 0), deleted: files.reduce((sum, file) => sum + file.deleted, 0), children: files.map((file) => (_jsx(ChangedFileLine, { path: file.path, open: open[file.path] === true, onToggle: () => setOpen((o) => ({ ...o, [file.path]: !o[file.path] })), trailing: _jsxs(_Fragment, { children: [_jsxs("span", { className: "text-chat-added tabular-nums", children: ["+", file.added] }), _jsxs("span", { className: "text-chat-removed tabular-nums", children: ["-", file.deleted] })] }), children: _jsx(CheckpointFileBody, { file: file }) }, file.path))) }));
131
139
  }
132
140
  return _jsx(ProviderChangedFiles, { tools: tools, open: open, setOpen: setOpen, revealNonce: revealNonce });
133
141
  }
@@ -156,10 +164,7 @@ function ProviderChangedFiles({ tools, open, setOpen, revealNonce }) {
156
164
  entryFor(change.path || tool.id).edits.push(change);
157
165
  }
158
166
  }
159
- return (_jsx(ChangedFilesCard, { count: byPath.size, revealNonce: revealNonce, children: [...byPath].map(([path, entry]) => {
160
- const count = entry.edits.length + entry.patches.length;
161
- return (_jsxs("div", { className: "border-t border-border", children: [_jsxs("button", { className: "flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs text-text-muted hover:bg-surface-hover", "data-file-path": path, onClick: () => setOpen((o) => ({ ...o, [path]: !o[path] })), children: [_jsx(Icon, { icon: ChevronRight, size: 12, className: clsx('shrink-0 text-text-faint transition-transform', open[path] && 'rotate-90') }), _jsx("span", { className: "min-w-0 truncate font-mono text-text", children: path }), _jsx("span", { className: "grow" }), _jsx("span", { className: "text-text-faint", children: t('work.edits', { count }) })] }), open[path] && (_jsx("div", { className: "border-t border-border", children: _jsxs(Suspense, { fallback: _jsx("div", { className: "px-3 py-2 text-xs text-text-faint", children: t('work.loadingDiff') }), children: [entry.patches.map((change, index) => (_jsx(UnifiedDiff, { change: change }, `patch-${index}`))), entry.edits.map((change, index) => (_jsx(EditDiff, { change: change }, `edit-${index}`)))] }) }))] }, path));
162
- }) }));
167
+ return (_jsx(ChangedFilesCard, { count: byPath.size, revealNonce: revealNonce, children: [...byPath].map(([path, entry]) => (_jsx(ChangedFileLine, { path: path, open: open[path] === true, onToggle: () => setOpen((o) => ({ ...o, [path]: !o[path] })), trailing: _jsx("span", { className: "text-text-faint", children: t('work.edits', { count: entry.edits.length + entry.patches.length }) }), children: _jsxs(DiffSuspense, { children: [entry.patches.map((change, index) => (_jsx(UnifiedDiff, { change: change }, `patch-${index}`))), entry.edits.map((change, index) => (_jsx(EditDiff, { change: change }, `edit-${index}`)))] }) }, path))) }));
163
168
  }
164
169
  /* "Working for 00:14". */
165
170
  export function WorkingRow({ startedAt }) {
@@ -1,6 +1,5 @@
1
1
  import type { VisualScrollRequest } from '@adecore/agent-contracts/visual';
2
2
  import type { VisualBridge } from './visual-bridge.js';
3
- /** Keeps one small frame in a full-height timeline row, without rendering React on scroll. */
4
3
  export declare class VisualViewportController {
5
4
  private readonly box;
6
5
  private readonly viewport;
@@ -18,5 +17,6 @@ export declare class VisualViewportController {
18
17
  refresh(): void;
19
18
  scroll(request: VisualScrollRequest): void;
20
19
  dispose(): void;
20
+ private offsetInScroller;
21
21
  private schedule;
22
22
  }
@@ -1,5 +1,5 @@
1
1
  import { visualViewportGeometry } from './logic/visual-viewport.js';
2
- /** Keeps one small frame in a full-height timeline row, without rendering React on scroll. */
2
+ /* Keeps one small frame in a full-height timeline row, without rendering React on scroll. */
3
3
  export class VisualViewportController {
4
4
  box;
5
5
  viewport;
@@ -35,11 +35,7 @@ export class VisualViewportController {
35
35
  if (this.disposed) {
36
36
  return;
37
37
  }
38
- const outer = this.scroller.getBoundingClientRect();
39
- const box = this.box.getBoundingClientRect();
40
- // A chat on a canvas can be scaled; messages inside its frame use unscaled CSS pixels.
41
- const scale = outer.height / this.scroller.offsetHeight || 1;
42
- const top = (box.top - outer.top) / scale - this.scroller.clientTop;
38
+ const top = this.offsetInScroller(this.scroller.getBoundingClientRect(), this.box.getBoundingClientRect());
43
39
  const available = this.scroller.clientHeight;
44
40
  const geometry = visualViewportGeometry(this.box.offsetHeight, available, top);
45
41
  this.viewport.style.height = `${geometry.height}px`;
@@ -61,8 +57,7 @@ export class VisualViewportController {
61
57
  return;
62
58
  }
63
59
  this.pendingDelta = 0;
64
- const scale = outer.height / this.scroller.offsetHeight || 1;
65
- const start = this.scroller.scrollTop + (box.top - outer.top) / scale - this.scroller.clientTop;
60
+ const start = this.scroller.scrollTop + this.offsetInScroller(outer, box);
66
61
  const top = 'to' in request ? start + request.to : request.edge === 'start' ? 0 : this.scroller.scrollHeight;
67
62
  this.scroller.scrollTo({ top, behavior: 'instant' });
68
63
  this.refresh();
@@ -75,6 +70,12 @@ export class VisualViewportController {
75
70
  this.scroller.removeEventListener('scroll', this.onScroll);
76
71
  this.bridge.setViewport(null);
77
72
  }
73
+ /* The box's top below the scroller's visible top, in the scroller's own pixels. */
74
+ offsetInScroller(outer, box) {
75
+ // A chat on a canvas can be scaled; messages inside its frame use unscaled CSS pixels.
76
+ const scale = outer.height / this.scroller.offsetHeight || 1;
77
+ return (box.top - outer.top) / scale - this.scroller.clientTop;
78
+ }
78
79
  schedule() {
79
80
  if (this.frame || this.disposed) {
80
81
  return;