@adecore/agents-react 0.21.2 → 0.22.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 (104) hide show
  1. package/dist/agents/model-requirement.d.ts +6 -0
  2. package/dist/agents/model-requirement.js +26 -0
  3. package/dist/chat/chat-client.d.ts +2 -1
  4. package/dist/chat/chat-client.js +2 -0
  5. package/dist/chat/drafts.d.ts +2 -1
  6. package/dist/chat/drafts.js +15 -5
  7. package/dist/chat/logic/picker-entries.d.ts +10 -0
  8. package/dist/chat/logic/picker-entries.js +19 -0
  9. package/dist/chat/logic/timeline.d.ts +5 -0
  10. package/dist/chat/logic/timeline.js +10 -1
  11. package/dist/chat/prompt-hold.d.ts +8 -0
  12. package/dist/chat/prompt-hold.js +16 -0
  13. package/dist/chat/quote.d.ts +4 -0
  14. package/dist/chat/quote.js +19 -0
  15. package/dist/chat/stash.d.ts +8 -2
  16. package/dist/chat/stash.js +21 -5
  17. package/dist/chat/subagent-list.d.ts +7 -1
  18. package/dist/chat/subagent-list.js +13 -1
  19. package/dist/chat/timeline-scroll.d.ts +6 -2
  20. package/dist/chat/timeline-scroll.js +12 -7
  21. package/dist/chat/ui/ChatActivity.js +22 -8
  22. package/dist/chat/ui/Composer.js +103 -19
  23. package/dist/chat/ui/ComposerInput.d.ts +3 -1
  24. package/dist/chat/ui/ComposerInput.js +13 -4
  25. package/dist/chat/ui/Pickers.js +26 -18
  26. package/dist/chat/ui/PromptComposer.d.ts +8 -1
  27. package/dist/chat/ui/PromptComposer.js +18 -6
  28. package/dist/chat/ui/QuoteButton.js +2 -2
  29. package/dist/chat/ui/SubagentControls.d.ts +4 -2
  30. package/dist/chat/ui/SubagentControls.js +12 -8
  31. package/dist/chat/ui/SubagentInfo.d.ts +1 -1
  32. package/dist/chat/ui/SubagentInfo.js +9 -15
  33. package/dist/chat/ui/SubagentStopButton.js +2 -1
  34. package/dist/chat/ui/Timeline.js +24 -3
  35. package/dist/chat/ui/composer/menu-aria.d.ts +10 -0
  36. package/dist/chat/ui/composer/menu-aria.js +18 -0
  37. package/dist/chat/ui/intelligent-ui/UiBlockFrame.js +3 -3
  38. package/dist/chat/ui/intelligent-ui/UiFallbackPart.js +1 -1
  39. package/dist/chat/ui/intelligent-ui/chart-data.d.ts +22 -2
  40. package/dist/chat/ui/intelligent-ui/chart-data.js +83 -19
  41. package/dist/chat/ui/intelligent-ui/renderers/chart.d.ts +1 -1
  42. package/dist/chat/ui/intelligent-ui/renderers/chart.js +99 -35
  43. package/dist/chat/ui/intelligent-ui/renderers/choices.d.ts +1 -1
  44. package/dist/chat/ui/intelligent-ui/renderers/choices.js +8 -2
  45. package/dist/chat/ui/intelligent-ui/renderers/content.js +5 -3
  46. package/dist/chat/ui/intelligent-ui/renderers/inputs.js +9 -4
  47. package/dist/chat/ui/intelligent-ui/renderers/links.js +1 -1
  48. package/dist/chat/ui/intelligent-ui/renderers/text.js +5 -2
  49. package/dist/chat/ui/intelligent-ui/table-data.js +4 -4
  50. package/dist/chat/ui/intelligent-ui/use-truncated.d.ts +1 -0
  51. package/dist/chat/ui/intelligent-ui/use-truncated.js +28 -0
  52. package/dist/chat/ui/quote-selection.d.ts +5 -2
  53. package/dist/chat/ui/quote-selection.js +3 -2
  54. package/dist/chat/ui/rows/MessageRows.js +24 -5
  55. package/dist/chat/ui/rows/Rows.js +1 -1
  56. package/dist/chat/ui/rows/WorkRows.d.ts +3 -1
  57. package/dist/chat/ui/rows/WorkRows.js +4 -4
  58. package/dist/host.d.ts +1 -0
  59. package/dist/host.js +1 -0
  60. package/dist/locales/en/agent-chat.json +39 -10
  61. package/dist/locales/nl/agent-chat.json +39 -10
  62. package/dist/state/chats.d.ts +2 -1
  63. package/dist/state/chats.js +19 -1
  64. package/package.json +29 -4
  65. package/src/agents/model-requirement.ts +36 -0
  66. package/src/chat/chat-client.ts +5 -0
  67. package/src/chat/drafts.ts +27 -7
  68. package/src/chat/logic/picker-entries.ts +25 -0
  69. package/src/chat/logic/timeline.ts +17 -2
  70. package/src/chat/prompt-hold.ts +27 -0
  71. package/src/chat/quote.ts +20 -0
  72. package/src/chat/stash.ts +31 -5
  73. package/src/chat/subagent-list.ts +19 -5
  74. package/src/chat/timeline-scroll.ts +24 -10
  75. package/src/chat/ui/ChatActivity.tsx +35 -9
  76. package/src/chat/ui/Composer.tsx +197 -52
  77. package/src/chat/ui/ComposerInput.tsx +18 -3
  78. package/src/chat/ui/Pickers.tsx +51 -30
  79. package/src/chat/ui/PromptComposer.tsx +37 -8
  80. package/src/chat/ui/QuoteButton.tsx +3 -2
  81. package/src/chat/ui/SubagentControls.tsx +22 -19
  82. package/src/chat/ui/SubagentInfo.tsx +24 -42
  83. package/src/chat/ui/SubagentStopButton.tsx +2 -1
  84. package/src/chat/ui/Timeline.tsx +26 -4
  85. package/src/chat/ui/composer/menu-aria.ts +29 -0
  86. package/src/chat/ui/intelligent-ui/UiBlockFrame.tsx +5 -5
  87. package/src/chat/ui/intelligent-ui/UiFallbackPart.tsx +6 -1
  88. package/src/chat/ui/intelligent-ui/chart-data.ts +106 -20
  89. package/src/chat/ui/intelligent-ui/renderers/chart.tsx +237 -72
  90. package/src/chat/ui/intelligent-ui/renderers/choices.tsx +9 -4
  91. package/src/chat/ui/intelligent-ui/renderers/content.tsx +31 -15
  92. package/src/chat/ui/intelligent-ui/renderers/inputs.tsx +16 -5
  93. package/src/chat/ui/intelligent-ui/renderers/links.tsx +3 -3
  94. package/src/chat/ui/intelligent-ui/renderers/text.tsx +18 -8
  95. package/src/chat/ui/intelligent-ui/table-data.ts +4 -4
  96. package/src/chat/ui/intelligent-ui/use-truncated.ts +33 -0
  97. package/src/chat/ui/quote-selection.ts +6 -4
  98. package/src/chat/ui/rows/MessageRows.tsx +44 -5
  99. package/src/chat/ui/rows/Rows.tsx +3 -1
  100. package/src/chat/ui/rows/WorkRows.tsx +15 -1
  101. package/src/host.ts +3 -0
  102. package/src/locales/en/agent-chat.json +39 -10
  103. package/src/locales/nl/agent-chat.json +39 -10
  104. package/src/state/chats.ts +31 -2
@@ -2,9 +2,10 @@ import { useId, useState } from 'react';
2
2
  import clsx from 'clsx';
3
3
  import { useTranslation } from 'react-i18next';
4
4
  import { ArrowRight, Check } from 'lucide-react';
5
- import type { UiProps } from '@adecore/intelligent-ui';
5
+ import { uiChoiceLabel, type UiProps } from '@adecore/intelligent-ui';
6
6
  import { DisabledReason, Icon, Spinner, Tooltip } from '@adecore/ui';
7
7
  import type { UiRendererProps } from '../render-context';
8
+ import { UiFallbackPart } from '../UiFallbackPart';
8
9
  import { useLater } from '../use-later';
9
10
 
10
11
  /* A context this long may run past its two lines, so the row offers all of it in a tooltip. */
@@ -28,7 +29,8 @@ export function ChoicesRenderer({ children, context }: UiRendererProps<UiProps<'
28
29
  /*
29
30
  * A row that shows what it sends: the label, and under it the context the agent receives. It stays
30
31
  * closed while the reply streams, once the block is answered and without a host to send it, and
31
- * remains focusable then so a screen reader hears why.
32
+ * remains focusable then so a screen reader hears why. A finished Choice the host would refuse for
33
+ * its label is no row at all, but its text.
32
34
  */
33
35
  export function ChoiceRenderer({ node, children, context }: UiRendererProps<UiProps<'Choice'>>) {
34
36
  const { t } = useTranslation('agent-chat');
@@ -41,6 +43,9 @@ export function ChoiceRenderer({ node, children, context }: UiRendererProps<UiPr
41
43
  const failed = answer === null && context.failedChoiceId === node.id;
42
44
  const closed = waiting || unavailable || answer !== null;
43
45
  const slow = useLater(chosen && answer.state === 'sending', SPINNER_AFTER_MS);
46
+ if (!waiting && uiChoiceLabel(node) === null) {
47
+ return <UiFallbackPart fallback={node.fallback} problem={{ kind: 'failed' }} />;
48
+ }
44
49
  const reason = waiting
45
50
  ? t('blocks.choice.waiting')
46
51
  : answer === null && unavailable
@@ -67,9 +72,9 @@ export function ChoiceRenderer({ node, children, context }: UiRendererProps<UiPr
67
72
  >
68
73
  <Icon icon={ArrowRight} size={14} className={clsx('mt-1 shrink-0', primary === true ? 'text-accent' : 'text-text-faint')} />
69
74
  <span className="flex min-w-0 grow flex-col">
70
- <span className="text-sm font-semibold text-text">{children}</span>
75
+ <span className="text-sm font-semibold wrap-anywhere text-text">{children}</span>
71
76
  {described && (
72
- <span id={descriptionId} className="line-clamp-2 text-xs text-text-muted">
77
+ <span id={descriptionId} className="line-clamp-2 text-xs wrap-anywhere text-text-muted">
73
78
  {detail}
74
79
  </span>
75
80
  )}
@@ -2,7 +2,7 @@ import clsx from 'clsx';
2
2
  import { createContext, useContext, useId } from 'react';
3
3
  import { useTranslation } from 'react-i18next';
4
4
  import type { UiProps } from '@adecore/intelligent-ui';
5
- import { PreviewCard } from '@adecore/ui';
5
+ import { PreviewCard, Tooltip } from '@adecore/ui';
6
6
  import { formatNumber } from '@adecore/ui/format';
7
7
  import { chatHost } from '../../../../host';
8
8
  import { CodeBlock } from '../../CodeBlock';
@@ -10,6 +10,7 @@ import { CodeStreamingContext } from '../../code-streaming';
10
10
  import { ImageThumb } from '../../ImageView';
11
11
  import { uiChildrenOf, uiNodeLabel, uiNodeText, uiUrlDomain } from '../node-text';
12
12
  import type { UiRendererProps } from '../render-context';
13
+ import { useTruncated } from '../use-truncated';
13
14
 
14
15
  // The number of every source by its node id, which the list counts in its own order.
15
16
  const SourceNumbers = createContext<ReadonlyMap<string, number>>(new Map());
@@ -115,26 +116,41 @@ export function SourceRenderer({ node, context }: UiRendererProps<UiProps<'Sourc
115
116
  const number = useContext(SourceNumbers).get(node.id);
116
117
  const { title, url } = node.props;
117
118
  const { openUrl } = context;
119
+ const [measureDomain, domainCut] = useTruncated<HTMLSpanElement>();
118
120
  const face = (
119
121
  <>
120
- <span className="w-5 shrink-0 text-right text-text-faint tabular-nums">{number}</span>
121
- <span className="min-w-0 truncate text-text">{title}</span>
122
- <span className="shrink-0 text-text-faint">{uiUrlDomain(url)}</span>
122
+ <span className="w-5 shrink-0 self-start text-right text-text-faint tabular-nums">{number}</span>
123
+ <span className="min-w-0 grow wrap-anywhere text-text">{title}</span>
124
+ <span ref={measureDomain} className="max-w-1/2 min-w-0 shrink-0 self-start truncate text-text-faint">
125
+ {uiUrlDomain(url)}
126
+ </span>
123
127
  </>
124
128
  );
125
129
  return (
126
130
  <li data-ui-source={node.id} tabIndex={-1} className="chat-ui-source scroll-my-2 rounded-md">
127
- {openUrl === undefined ? (
128
- <span className="flex h-8 items-center gap-2 px-2 text-xs">{face}</span>
129
- ) : (
130
- <button
131
- type="button"
132
- className="flex h-8 w-full items-center gap-2 rounded-md px-2 text-left text-xs hover:bg-surface-hover"
133
- onClick={() => openUrl(url)}
134
- >
135
- {face}
136
- </button>
137
- )}
131
+ {/* The title wraps and the domain may be cut short, so the tooltip says both whole; a row that opens nothing takes focus only while it cuts. */}
132
+ <Tooltip
133
+ label={
134
+ <>
135
+ <span className="block">{title}</span>
136
+ <span className="block text-text-muted">{url}</span>
137
+ </>
138
+ }
139
+ >
140
+ {openUrl === undefined ? (
141
+ <span tabIndex={domainCut ? 0 : undefined} className="flex min-h-8 items-start gap-2 rounded-md px-2 py-2 text-xs">
142
+ {face}
143
+ </span>
144
+ ) : (
145
+ <button
146
+ type="button"
147
+ className="flex min-h-8 w-full items-start gap-2 rounded-md px-2 py-2 text-left text-xs hover:bg-surface-hover"
148
+ onClick={() => openUrl(url)}
149
+ >
150
+ {face}
151
+ </button>
152
+ )}
153
+ </Tooltip>
138
154
  </li>
139
155
  );
140
156
  }
@@ -2,9 +2,10 @@ import { createContext, useContext, type MouseEvent, type ReactNode } from 'reac
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 { Button, Checkbox, Segmented, Slider, Switch } from '@adecore/ui';
5
+ import { Button, Checkbox, Segmented, Slider, Switch, Tooltip } from '@adecore/ui';
6
6
  import { uiChildrenOf, uiNodeLabel, uiWithUnit } from '../node-text';
7
7
  import type { UiRenderContext, UiRendererProps } from '../render-context';
8
+ import { useTruncated } from '../use-truncated';
8
9
 
9
10
  type Scalar = UiProps<'Item'>['value'];
10
11
 
@@ -106,6 +107,9 @@ export function SliderRenderer({ node, context }: UiRendererProps<UiProps<'Slide
106
107
  );
107
108
  }
108
109
 
110
+ /* A label longer than this reads as a sentence, which wraps badly beside another; the options then stand in a column. */
111
+ const SIDE_BY_SIDE_LABEL = 24;
112
+
109
113
  /* Left aligned, never the full width. Each Option is a segment by its place, since a value may be any scalar. */
110
114
  export function SegmentedRenderer({ node, context }: UiRendererProps<UiProps<'Segmented'>>) {
111
115
  const { t } = useTranslation('agent-chat');
@@ -119,6 +123,7 @@ export function SegmentedRenderer({ node, context }: UiRendererProps<UiProps<'Se
119
123
  value={picked}
120
124
  options={options}
121
125
  label={t('blocks.options')}
126
+ orientation={options.some((option) => option.label.length > SIDE_BY_SIDE_LABEL) ? 'vertical' : 'horizontal'}
122
127
  disabled={binding === null}
123
128
  onValueChange={(id) => {
124
129
  const option = options[Number(id)];
@@ -131,14 +136,20 @@ export function SegmentedRenderer({ node, context }: UiRendererProps<UiProps<'Se
131
136
  );
132
137
  }
133
138
 
134
- /* Runs its local action once its own node closed; like an input, it closes once the block is answered. */
139
+ /* Runs its local action once its own node closed; like an input, it closes once the block is answered. A label cut to the button's one line reads whole in its tooltip. */
135
140
  export function ButtonRenderer({ node, children, context }: UiRendererProps<UiProps<'Button'>>) {
136
141
  const enabled = node.onAction !== undefined && node.complete && context.answer === null && node.props.disabled !== true;
142
+ const label = uiNodeLabel(node);
143
+ const [measure, truncated] = useTruncated<HTMLSpanElement>();
137
144
  return (
138
145
  <div className={clsx('px-2', !node.complete && PENDING)}>
139
- <Button variant="secondary" size="sm" disabled={!enabled} onClick={() => node.onAction?.()}>
140
- {children}
141
- </Button>
146
+ <Tooltip label={label} disabled={!truncated}>
147
+ <Button variant="secondary" size="sm" className="max-w-full" disabled={!enabled} onClick={() => node.onAction?.()}>
148
+ <span ref={measure} className="min-w-0 truncate">
149
+ {children}
150
+ </span>
151
+ </Button>
152
+ </Tooltip>
142
153
  </div>
143
154
  );
144
155
  }
@@ -115,11 +115,11 @@ function LinkRow({ target, context, described, children }: { target: UiLinkTarge
115
115
  return <UiLinkChip target={target} context={context} />;
116
116
  }
117
117
  return (
118
- <div className="flex min-w-0 items-baseline gap-2 px-2 text-sm">
119
- <span className="shrink-0">
118
+ <div className="flex min-w-0 flex-wrap items-baseline gap-x-2 px-2 text-sm">
119
+ <span className="max-w-full min-w-0">
120
120
  <UiLinkChip target={target} context={context} />
121
121
  </span>
122
- <span className="min-w-0 text-text-muted">{children}</span>
122
+ <span className="min-w-0 wrap-break-word text-text-muted">{children}</span>
123
123
  </div>
124
124
  );
125
125
  }
@@ -31,20 +31,27 @@ function isLive(context: UiRenderContext): boolean {
31
31
  return context.phase === 'streaming' || (context.live !== undefined && context.live !== null && context.live.state !== 'refused');
32
32
  }
33
33
 
34
+ /* A badge longer than this is a sentence, which a tag cannot wrap; it stands under the title as text instead. */
35
+ const BADGE_AS_TAG = 32;
36
+
34
37
  /* The head of the block when it is the first node, a subheading without an icon further down. */
35
38
  export function SummaryRenderer({ node, children }: UiRendererProps<UiProps<'Summary'>>) {
36
39
  const headId = useContext(UiHeadContext);
37
40
  const head = headId === undefined || headId === node.id;
38
41
  const { tone, badge } = node.props;
42
+ const tag = badge !== undefined && badge !== '' && badge.length <= BADGE_AS_TAG;
39
43
  return (
40
44
  <div className="flex items-start gap-2 px-2">
41
45
  {head && tone !== undefined && <Icon icon={UI_TONE_ICONS[tone]} size={16} className={clsx('mt-0.75 shrink-0', UI_TONE_TEXT[tone])} />}
42
- <div className="min-w-0 grow text-sm font-semibold text-balance text-text">{children}</div>
43
- {badge !== undefined && badge !== '' && (
44
- <Pill shape="tag" tone={tone === undefined ? 'muted' : UI_TONE_PILL[tone]} className="shrink-0">
45
- {badge}
46
- </Pill>
47
- )}
46
+ <div className="flex min-w-0 grow flex-wrap items-start gap-x-2 gap-y-1">
47
+ <div className="max-w-full min-w-0 grow basis-48 text-sm font-semibold text-balance wrap-break-word text-text">{children}</div>
48
+ {tag && (
49
+ <Pill shape="tag" tone={tone === undefined ? 'muted' : UI_TONE_PILL[tone]}>
50
+ {badge}
51
+ </Pill>
52
+ )}
53
+ {!tag && badge !== undefined && badge !== '' && <p className="w-full text-xs wrap-anywhere text-text-muted">{badge}</p>}
54
+ </div>
48
55
  </div>
49
56
  );
50
57
  }
@@ -117,8 +124,11 @@ export function StepRenderer({ node, children, context }: UiRendererProps<UiProp
117
124
  )}
118
125
  </span>
119
126
  <span className="sr-only">{t(`blocks.step.${state}`)}</span>
120
- <span className={clsx('min-w-0 grow', quiet ? 'text-text-faint' : 'text-text')}>{children}</span>
121
- {detail !== undefined && detail !== '' && <span className="shrink-0 text-xs text-text-faint tabular-nums">{detail}</span>}
127
+ {/* A short detail stays beside the title; one that does not fit beside it takes the line under it. */}
128
+ <span className="flex min-w-0 grow flex-wrap items-baseline justify-between gap-x-2">
129
+ <span className={clsx('max-w-full min-w-0 grow basis-48 wrap-break-word', quiet ? 'text-text-faint' : 'text-text')}>{children}</span>
130
+ {detail !== undefined && detail !== '' && <span className="max-w-full text-xs wrap-anywhere text-text-faint tabular-nums">{detail}</span>}
131
+ </span>
122
132
  </li>
123
133
  );
124
134
  }
@@ -21,8 +21,8 @@ export type UiTableCell =
21
21
 
22
22
  /* Rows shown before a person asks for all of them. */
23
23
  export const UI_TABLE_ROWS = 50;
24
- /* Columns a table without Column children takes from its rows. */
25
- const DERIVED_COLUMNS = 8;
24
+ /* The furthest a JavaScript Date reaches from the epoch either way, in milliseconds. */
25
+ const DATE_RANGE = 8.64e15;
26
26
  const NUMERIC_KINDS: ReadonlySet<UiColumnKind> = new Set(['number', 'bytes', 'duration']);
27
27
 
28
28
  export function isNumericColumn(column: UiTableColumn): boolean {
@@ -51,7 +51,7 @@ export function uiTableColumns(node: UiViewNode<UiProps<'Table'>>): UiTableColum
51
51
  const keys: string[] = [];
52
52
  for (const row of rows) {
53
53
  for (const key of Object.keys(row)) {
54
- if (!keys.includes(key) && keys.length < DERIVED_COLUMNS) {
54
+ if (!keys.includes(key)) {
55
55
  keys.push(key);
56
56
  }
57
57
  }
@@ -92,7 +92,7 @@ export function uiTableCell(value: unknown, column: UiTableColumn): UiTableCell
92
92
  }
93
93
  case 'date': {
94
94
  const at = typeof value === 'number' ? value : typeof value === 'string' ? Date.parse(value) : Number.NaN;
95
- return Number.isFinite(at) ? { kind: 'date', at } : { kind: 'text', text: textOf(value) };
95
+ return Number.isFinite(at) && Math.abs(at) <= DATE_RANGE ? { kind: 'date', at } : { kind: 'text', text: textOf(value) };
96
96
  }
97
97
  case 'file':
98
98
  return typeof value === 'string' ? { kind: 'file', path: value } : { kind: 'text', text: textOf(value) };
@@ -0,0 +1,33 @@
1
+ import { useCallback, useState } from 'react';
2
+
3
+ function cut(element: HTMLElement): boolean {
4
+ return element.scrollWidth > element.clientWidth + 1;
5
+ }
6
+
7
+ /*
8
+ * Whether an element's text is cut short where it stands, for a tooltip or a list of values that shows
9
+ * it whole exactly then. `within` asks the same of every `.truncate` element inside it instead. Measured
10
+ * again when the box resizes or the text changes, since neither moves the other.
11
+ */
12
+ export function useTruncated<T extends HTMLElement>(within = false): [(element: T | null) => (() => void) | undefined, boolean] {
13
+ const [truncated, setTruncated] = useState(false);
14
+ const measure = useCallback(
15
+ (element: T | null) => {
16
+ if (element === null || typeof ResizeObserver === 'undefined') {
17
+ return undefined;
18
+ }
19
+ const check = () => setTruncated(within ? [...element.querySelectorAll<HTMLElement>('.truncate')].some(cut) : cut(element));
20
+ check();
21
+ const resize = new ResizeObserver(check);
22
+ resize.observe(element);
23
+ const text = new MutationObserver(check);
24
+ text.observe(element, { characterData: true, childList: true, subtree: true });
25
+ return () => {
26
+ resize.disconnect();
27
+ text.disconnect();
28
+ };
29
+ },
30
+ [within]
31
+ );
32
+ return [measure, truncated];
33
+ }
@@ -4,7 +4,8 @@ import { quoteOf } from '../quote';
4
4
  /* The text of an answer (`rows/MessageRows.tsx`), the only part of a thread a selection quotes from. */
5
5
  const ANSWER = '[data-quote-answer]';
6
6
 
7
- export type QuoteTaker = (quote: string) => void;
7
+ /* `itemId` is the reply the quote came from, when the selection sits in a row that names one. */
8
+ export type QuoteTaker = (quote: string, itemId: string | null) => void;
8
9
 
9
10
  /* Registers the composer below a thread as the one its quote button hands a quote to, until the returned call. */
10
11
  export const QuoteTakerContext = createContext<((take: QuoteTaker) => () => void) | null>(null);
@@ -35,8 +36,8 @@ function selectsOutside(range: Range, answer: Element): boolean {
35
36
  return false;
36
37
  }
37
38
 
38
- /* The quote the selection makes, when it lies in one answer of this thread. */
39
- export function selectedAnswerQuote(selection: Selection | null, thread: HTMLElement | null): string | null {
39
+ /* The quote the selection makes, when it lies in one answer of this thread, and the reply it was taken from. */
40
+ export function selectedAnswerQuote(selection: Selection | null, thread: HTMLElement | null): { text: string; itemId: string | null } | null {
40
41
  if (thread === null || selection === null || selection.isCollapsed || selection.rangeCount === 0) {
41
42
  return null;
42
43
  }
@@ -49,5 +50,6 @@ export function selectedAnswerQuote(selection: Selection | null, thread: HTMLEle
49
50
  if (!inside && selectsOutside(range, answer)) {
50
51
  return null;
51
52
  }
52
- return quoteOf(selection.toString());
53
+ const text = quoteOf(selection.toString());
54
+ return text === null ? null : { text, itemId: answer.closest('[data-find-item]')?.getAttribute('data-find-item') ?? null };
53
55
  }
@@ -11,10 +11,13 @@ import type {
11
11
  ChatUiChoiceOrigin,
12
12
  ChatUserItem
13
13
  } from '@adecore/agent-contracts';
14
+ import { WAITING_NOTE_PREFIX } from '@adecore/agent-contracts';
14
15
  import { fileBadge, formatBytes, isImageAttachment } from '../../attachments';
15
16
  import { useChatRow, useCurrentItem } from '../../../state/chats';
16
17
  import { chatHost } from '../../../host';
17
18
  import { useChatScope } from '../../../scope';
19
+ import { splitQuote } from '../../quote';
20
+ import { jumpToTimelineItem } from '../../timeline-scroll';
18
21
  import { useProviders } from '../../../state/providers';
19
22
  import { ChatReferenceChip } from '../ChatReferenceChip';
20
23
  import { ImageThumb } from '../ImageView';
@@ -86,7 +89,19 @@ export function UserRow({ chatId, item }: { chatId: string; item: ChatUserItem }
86
89
  const long = item.text.split('\n').length > USER_FOLD_LINES || item.text.length > USER_FOLD_CHARS;
87
90
  const attachments = item.attachments ?? [];
88
91
  const chats = item.chats ?? [];
92
+ const [missing, setMissing] = useState(false);
93
+ const { keyOf } = useChatScope();
89
94
  useOpenForFind(item.id, 'text', setOpen);
95
+ // A quote only leads somewhere when the message still opens with the blockquote it was sent with.
96
+ const quoted = item.quoteSource === undefined ? null : splitQuote(item.text);
97
+ const source = item.quoteSource?.itemId;
98
+
99
+ function goToSource(): void {
100
+ if (source !== undefined) {
101
+ setMissing(false);
102
+ jumpToTimelineItem(keyOf(chatId), source, { flash: true, onMissing: () => setMissing(true) });
103
+ }
104
+ }
90
105
  return (
91
106
  <div data-find-item={item.id} className="flex flex-col items-end">
92
107
  <MessageHeading>{t('rows.user.heading')}</MessageHeading>
@@ -112,7 +127,31 @@ export function UserRow({ chatId, item }: { chatId: string; item: ChatUserItem }
112
127
  {item.text !== '' && (
113
128
  <div className="relative max-w-[80%] rounded-2xl bg-surface-active px-3.5 py-2.5 text-sm wrap-anywhere text-text select-text">
114
129
  <div data-find-field="text" className={clsx(long && !open && FOLD)}>
115
- <MessageMarkdown text={item.text} mentions={item.mentions} skills={item.skills} />
130
+ {quoted !== null && source !== undefined ? (
131
+ <>
132
+ {/* A div, since the quote is block markdown and a button holds none. */}
133
+ <Tooltip label={t('composer.quote.goToSource')}>
134
+ <div
135
+ role="button"
136
+ tabIndex={0}
137
+ className="mb-2 cursor-pointer rounded-sm border-l-2 border-border-strong pl-2.5 text-text-muted hover:bg-text/5 hover:text-text focus-visible:outline-2 focus-visible:outline-accent"
138
+ onClick={goToSource}
139
+ onKeyDown={(e) => {
140
+ if (e.key === 'Enter' || e.key === ' ') {
141
+ e.preventDefault();
142
+ goToSource();
143
+ }
144
+ }}
145
+ >
146
+ <MessageMarkdown text={quoted.quote} />
147
+ </div>
148
+ </Tooltip>
149
+ {missing && <div className="mb-2 text-xs text-text-faint">{t('rows.user.quoteGone')}</div>}
150
+ {quoted.rest !== '' && <MessageMarkdown text={quoted.rest} mentions={item.mentions} skills={item.skills} />}
151
+ </>
152
+ ) : (
153
+ <MessageMarkdown text={item.text} mentions={item.mentions} skills={item.skills} />
154
+ )}
116
155
  </div>
117
156
  {long && (
118
157
  <button className="mt-1 flex items-center gap-1 text-xs text-text-muted hover:text-text" onClick={() => setOpen((o) => !o)}>
@@ -364,7 +403,7 @@ export function NoteRow({ id, level, text, from }: { id: string; level: 'info' |
364
403
  {open ? t('rows.note.hide') : t('rows.note.show')}
365
404
  </button>
366
405
  )}
367
- {from !== undefined && <OpenChatButton chatId={from} />}
406
+ {from !== undefined && <OpenChatButton chatId={from} label={id.startsWith(WAITING_NOTE_PREFIX) ? 'openWaiting' : 'openFork'} />}
368
407
  </div>
369
408
  {open && rest !== '' && (
370
409
  <div data-find-field="output" className="mt-1 text-text-muted select-text">
@@ -376,8 +415,8 @@ export function NoteRow({ id, level, text, from }: { id: string; level: 'info' |
376
415
  );
377
416
  }
378
417
 
379
- /* "Open fork" on a note a fork sent, while that fork is still in the project. */
380
- function OpenChatButton({ chatId }: { chatId: string }) {
418
+ /* "Open fork" on a note a fork sent, or "Open to answer" on one about a waiting child, while that chat is still in the project. */
419
+ function OpenChatButton({ chatId, label }: { chatId: string; label: 'openFork' | 'openWaiting' }) {
381
420
  const { t } = useTranslation('agent-chat');
382
421
  const place = chatHost().useChatPlace(chatId);
383
422
  if (place.title === null) {
@@ -385,7 +424,7 @@ function OpenChatButton({ chatId }: { chatId: string }) {
385
424
  }
386
425
  return (
387
426
  <button className="text-text-muted hover:text-text" onClick={place.go}>
388
- {t('rows.note.openFork')}
427
+ {t(`rows.note.${label}`)}
389
428
  </button>
390
429
  );
391
430
  }
@@ -67,7 +67,9 @@ export function Row({ row, chatId, toggleGroup, toggleTurn, toggleSubagent, open
67
67
  case 'forks':
68
68
  return <ForksRow chatId={chatId} turnId={row.turnId} />;
69
69
  case 'changed-files':
70
- return <ChangedFilesRow chatId={chatId} turnId={row.turnId} tools={row.tools} diff={row.diff} checkpoint={row.checkpoint} />;
70
+ return (
71
+ <ChangedFilesRow chatId={chatId} turnId={row.turnId} tools={row.tools} diff={row.diff} checkpoint={row.checkpoint} baseMoved={row.baseMoved} />
72
+ );
71
73
  case 'approval':
72
74
  return <ApprovalHistoryRow item={row.item} />;
73
75
  case 'question':
@@ -4,6 +4,7 @@ import i18next from 'i18next';
4
4
  import { useTranslation } from 'react-i18next';
5
5
  import { ChevronRight, FileDiff, X } from 'lucide-react';
6
6
  import type { ChatCheckpointDiff, ChatCheckpointFile, ChatFileChange, ChatToolItem, ChatTurnItem } from '@adecore/agent-contracts';
7
+ import type { BaseMoved } from '../../logic/timeline';
7
8
  import { fileChanges, liveOutput, readImagePath, toolStartedAt, toolSummary, unifiedChanges, type FileChange } from '../../logic/tools';
8
9
  import { useOpenForFind } from '../find-reveal';
9
10
  import { chatHost } from '../../../host';
@@ -289,10 +290,13 @@ function ChangedFilesCard({
289
290
  added,
290
291
  deleted,
291
292
  revealNonce = 0,
293
+ baseMoved,
292
294
  children
293
295
  }: {
294
296
  count: number;
295
297
  truncated?: boolean;
298
+ /* Set when the branch moved during the turn: the count is then what changed in the folder, not only the agent's work. */
299
+ baseMoved?: BaseMoved | undefined;
296
300
  added?: number;
297
301
  deleted?: number;
298
302
  /* A jump from outside the chat that asked for this card; each new one opens it. */
@@ -315,13 +319,20 @@ function ChangedFilesCard({
315
319
  onClick={() => setExpanded((value) => !value)}
316
320
  >
317
321
  <Icon icon={FileDiff} size={12} className="shrink-0" />
318
- <span className="shrink-0 font-medium text-text">{t('work.changedFiles', { count })}</span>
322
+ <span className="shrink-0 font-medium text-text">
323
+ {baseMoved === undefined ? t('work.changedFiles', { count }) : t('work.folderChanges', { count })}
324
+ </span>
319
325
  {truncated && <span className="min-w-0 truncate text-text-faint">{t('work.andMore')}</span>}
320
326
  <span className="grow" />
321
327
  {added !== undefined && <span className="text-chat-added tabular-nums">+{added}</span>}
322
328
  {deleted !== undefined && <span className="text-chat-removed tabular-nums">-{deleted}</span>}
323
329
  <Icon icon={ChevronRight} size={12} className={clsx('shrink-0 text-text-faint transition-transform', expanded && 'rotate-90')} />
324
330
  </button>
331
+ {baseMoved !== undefined && (
332
+ <div className="pr-3 pb-2 pl-8 text-xs text-text-faint">
333
+ {t('work.baseMoved', { from: baseMoved.from.slice(0, 7), to: baseMoved.to.slice(0, 7) })}
334
+ </div>
335
+ )}
325
336
  {expanded && children}
326
337
  </div>
327
338
  );
@@ -337,12 +348,14 @@ export function ChangedFilesRow({
337
348
  tools,
338
349
  diff,
339
350
  checkpoint,
351
+ baseMoved,
340
352
  chatId,
341
353
  turnId
342
354
  }: {
343
355
  tools: ChatToolItem[];
344
356
  diff: ChatCheckpointDiff | null;
345
357
  checkpoint: boolean;
358
+ baseMoved?: BaseMoved | undefined;
346
359
  chatId: string;
347
360
  turnId: string;
348
361
  }) {
@@ -375,6 +388,7 @@ export function ChangedFilesRow({
375
388
  return (
376
389
  <ChangedFilesCard
377
390
  revealNonce={revealNonce}
391
+ baseMoved={baseMoved}
378
392
  count={files.length}
379
393
  truncated={checkpointDiff.truncated}
380
394
  added={files.reduce((sum, file) => sum + file.added, 0)}
package/src/host.ts CHANGED
@@ -279,6 +279,8 @@ export interface ChatHost {
279
279
  useLoginBlocked(scopeId: string): string | null;
280
280
  /* The name and the mark of a project of the host, for the usage per project; null draws the folder's own. */
281
281
  useProjectLook(scopeId: string, projectId: string | null): { name: string; mark: ReactNode } | null;
282
+ /* What a person calls the machine the scope's chats run on, or null when it has no name worth saying. */
283
+ useMachineLabel(scopeId: string): string | null;
282
284
  /* Where another chat of the app stands, for a fork's way back and a note from a fork. */
283
285
  useChatPlace(chatId: string): ChatPlace;
284
286
  /* What a chat may read besides its folder, named under an empty thread. */
@@ -345,6 +347,7 @@ const DEFAULT_HOST: ChatHost = {
345
347
  openLogin: null,
346
348
  useLoginBlocked: () => null,
347
349
  useProjectLook: () => null,
350
+ useMachineLabel: () => null,
348
351
  useChatPlace: () => NOWHERE,
349
352
  useContextSources: () => NO_SOURCES,
350
353
  useHidesFolder: () => false,