@adecore/agents-react 0.21.1 → 0.22.0

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 (145) hide show
  1. package/dist/agents/model-requirement.d.ts +6 -0
  2. package/dist/agents/model-requirement.js +26 -0
  3. package/dist/agents/status-look.js +2 -3
  4. package/dist/chat/chat-client.d.ts +3 -2
  5. package/dist/chat/chat-client.js +4 -2
  6. package/dist/chat/drafts.d.ts +2 -1
  7. package/dist/chat/drafts.js +21 -17
  8. package/dist/chat/logic/ansi.js +5 -1
  9. package/dist/chat/logic/fork.js +8 -9
  10. package/dist/chat/logic/limit.js +17 -14
  11. package/dist/chat/logic/picker-entries.d.ts +10 -0
  12. package/dist/chat/logic/picker-entries.js +19 -0
  13. package/dist/chat/logic/resume-compaction.js +2 -11
  14. package/dist/chat/logic/scrubber.js +9 -18
  15. package/dist/chat/logic/timeline.d.ts +5 -0
  16. package/dist/chat/logic/timeline.js +25 -11
  17. package/dist/chat/logic/tool-catalog.js +3 -4
  18. package/dist/chat/mentions.js +5 -6
  19. package/dist/chat/prompt-hold.d.ts +8 -0
  20. package/dist/chat/prompt-hold.js +16 -0
  21. package/dist/chat/quote.d.ts +4 -0
  22. package/dist/chat/quote.js +19 -0
  23. package/dist/chat/runtime-modes.js +2 -4
  24. package/dist/chat/stash.d.ts +8 -2
  25. package/dist/chat/stash.js +22 -8
  26. package/dist/chat/subagent-list.d.ts +7 -1
  27. package/dist/chat/subagent-list.js +13 -1
  28. package/dist/chat/thumbnails.js +3 -4
  29. package/dist/chat/timeline-scroll.d.ts +6 -2
  30. package/dist/chat/timeline-scroll.js +45 -46
  31. package/dist/chat/ui/BookmarkSubmenu.js +1 -5
  32. package/dist/chat/ui/ChatActivity.js +22 -8
  33. package/dist/chat/ui/CodeBlock.js +2 -3
  34. package/dist/chat/ui/Composer.js +160 -88
  35. package/dist/chat/ui/ComposerInput.d.ts +3 -1
  36. package/dist/chat/ui/ComposerInput.js +13 -4
  37. package/dist/chat/ui/Markdown.js +11 -11
  38. package/dist/chat/ui/Pickers.js +31 -27
  39. package/dist/chat/ui/PromptComposer.d.ts +8 -1
  40. package/dist/chat/ui/PromptComposer.js +18 -6
  41. package/dist/chat/ui/QuoteButton.js +4 -5
  42. package/dist/chat/ui/RunSettings.js +8 -2
  43. package/dist/chat/ui/Scrubber.js +2 -3
  44. package/dist/chat/ui/SubagentControls.d.ts +4 -2
  45. package/dist/chat/ui/SubagentControls.js +13 -11
  46. package/dist/chat/ui/SubagentInfo.d.ts +1 -1
  47. package/dist/chat/ui/SubagentInfo.js +9 -15
  48. package/dist/chat/ui/SubagentStopButton.js +2 -1
  49. package/dist/chat/ui/Timeline.js +39 -21
  50. package/dist/chat/ui/TimelineMenu.js +3 -9
  51. package/dist/chat/ui/UnifiedDiff.js +3 -5
  52. package/dist/chat/ui/VisualFrame.js +3 -4
  53. package/dist/chat/ui/composer/keys.js +19 -11
  54. package/dist/chat/ui/composer/menu-aria.d.ts +10 -0
  55. package/dist/chat/ui/composer/menu-aria.js +18 -0
  56. package/dist/chat/ui/intelligent-ui/render-context.d.ts +1 -1
  57. package/dist/chat/ui/quote-selection.d.ts +5 -2
  58. package/dist/chat/ui/quote-selection.js +3 -2
  59. package/dist/chat/ui/rows/MessageRows.js +26 -8
  60. package/dist/chat/ui/rows/Rows.js +1 -1
  61. package/dist/chat/ui/rows/WorkRows.d.ts +7 -4
  62. package/dist/chat/ui/rows/WorkRows.js +22 -17
  63. package/dist/chat/visual-viewport.d.ts +1 -1
  64. package/dist/chat/visual-viewport.js +9 -8
  65. package/dist/host.d.ts +1 -0
  66. package/dist/host.js +1 -0
  67. package/dist/locales/en/agent-chat.json +30 -7
  68. package/dist/locales/nl/agent-chat.json +30 -7
  69. package/dist/port-transport.js +4 -8
  70. package/dist/state/chats.d.ts +2 -1
  71. package/dist/state/chats.js +29 -15
  72. package/dist/state/usage.js +44 -49
  73. package/dist/storage.js +7 -7
  74. package/dist/theme.css +6 -14
  75. package/dist/usage/LimitsList.js +1 -2
  76. package/dist/usage/UsageBreakdown.js +1 -3
  77. package/dist/usage/UsageDialog.js +2 -4
  78. package/dist/usage/format.js +6 -6
  79. package/dist/usage/money.js +1 -5
  80. package/dist/usage/summary.js +2 -4
  81. package/package.json +24 -4
  82. package/src/agents/model-requirement.ts +36 -0
  83. package/src/agents/status-look.ts +2 -3
  84. package/src/chat/chat-client.ts +7 -3
  85. package/src/chat/drafts.ts +33 -21
  86. package/src/chat/logic/ansi.ts +5 -1
  87. package/src/chat/logic/fork.ts +8 -9
  88. package/src/chat/logic/limit.ts +17 -16
  89. package/src/chat/logic/picker-entries.ts +25 -0
  90. package/src/chat/logic/resume-compaction.ts +2 -11
  91. package/src/chat/logic/scrubber.ts +13 -17
  92. package/src/chat/logic/timeline.ts +39 -13
  93. package/src/chat/logic/tool-catalog.ts +7 -6
  94. package/src/chat/mentions.ts +5 -6
  95. package/src/chat/prompt-hold.ts +27 -0
  96. package/src/chat/quote.ts +20 -0
  97. package/src/chat/runtime-modes.ts +2 -4
  98. package/src/chat/stash.ts +32 -8
  99. package/src/chat/subagent-list.ts +19 -5
  100. package/src/chat/thumbnails.ts +3 -4
  101. package/src/chat/timeline-scroll.ts +58 -53
  102. package/src/chat/ui/BookmarkSubmenu.tsx +1 -5
  103. package/src/chat/ui/ChatActivity.tsx +35 -9
  104. package/src/chat/ui/CodeBlock.tsx +2 -3
  105. package/src/chat/ui/Composer.tsx +263 -138
  106. package/src/chat/ui/ComposerInput.tsx +18 -3
  107. package/src/chat/ui/Markdown.tsx +13 -17
  108. package/src/chat/ui/Pickers.tsx +56 -39
  109. package/src/chat/ui/PromptComposer.tsx +37 -8
  110. package/src/chat/ui/QuoteButton.tsx +5 -5
  111. package/src/chat/ui/RunSettings.tsx +9 -2
  112. package/src/chat/ui/Scrubber.tsx +2 -3
  113. package/src/chat/ui/SubagentControls.tsx +23 -22
  114. package/src/chat/ui/SubagentInfo.tsx +24 -42
  115. package/src/chat/ui/SubagentStopButton.tsx +2 -1
  116. package/src/chat/ui/SubagentTimeline.tsx +1 -2
  117. package/src/chat/ui/Timeline.tsx +44 -24
  118. package/src/chat/ui/TimelineMenu.tsx +3 -9
  119. package/src/chat/ui/UnifiedDiff.tsx +3 -5
  120. package/src/chat/ui/VisualFrame.tsx +3 -4
  121. package/src/chat/ui/composer/keys.ts +21 -11
  122. package/src/chat/ui/composer/menu-aria.ts +29 -0
  123. package/src/chat/ui/intelligent-ui/render-context.ts +2 -2
  124. package/src/chat/ui/quote-selection.ts +6 -4
  125. package/src/chat/ui/rehype-chips.ts +1 -1
  126. package/src/chat/ui/rehype-fade.ts +1 -1
  127. package/src/chat/ui/remark-html-as-text.ts +1 -1
  128. package/src/chat/ui/rows/MessageRows.tsx +47 -9
  129. package/src/chat/ui/rows/Rows.tsx +3 -1
  130. package/src/chat/ui/rows/WorkRows.tsx +101 -70
  131. package/src/chat/visual-viewport.ts +10 -8
  132. package/src/host.ts +4 -3
  133. package/src/locales/en/agent-chat.json +30 -7
  134. package/src/locales/nl/agent-chat.json +30 -7
  135. package/src/port-transport.ts +4 -8
  136. package/src/state/chats.ts +42 -16
  137. package/src/state/usage.ts +49 -60
  138. package/src/storage.ts +7 -7
  139. package/src/theme.css +6 -14
  140. package/src/usage/LimitsList.tsx +2 -4
  141. package/src/usage/UsageBreakdown.tsx +2 -5
  142. package/src/usage/UsageDialog.tsx +2 -4
  143. package/src/usage/format.ts +6 -6
  144. package/src/usage/money.ts +1 -5
  145. package/src/usage/summary.ts +2 -4
@@ -1,20 +1,18 @@
1
- import { Fragment, useState } from 'react';
2
- import clsx from 'clsx';
1
+ import { Fragment } from 'react';
3
2
  import { useTranslation } from 'react-i18next';
4
- import { ChevronDown } from 'lucide-react';
5
3
  import { entryTimeOf, statusWordOf, subagentFacts, taskIdOf } from '../subagent-list';
6
4
  import { useSubagentItem } from '../subagent-view';
7
5
  import { StatusIcon } from './ChatActivity';
8
- import { Markdown } from './Markdown';
9
- import { Icon, useNow } from '@adecore/ui';
6
+ import { SubagentActions, SubagentBreadcrumb } from './SubagentControls';
7
+ import { useNow } from '@adecore/ui';
10
8
  import { useModelName } from '../../agents/model-name';
11
9
  import { useChatRow } from '../../state/chats';
12
10
  import { chatHost } from '../../host';
13
11
  import { useChatScope } from '../../scope';
14
12
 
15
13
  /*
16
- * What a sub-agent is, over its conversation: its state and time, the model it runs on and what it
17
- * spent, and the task it was given behind a fold. Nothing for a conversation whose row the thread lost.
14
+ * What a sub-agent is, over its conversation: the way down to it, its state and time, the model it runs on
15
+ * and what it spent, and the way out. Only the way down and out for a conversation whose row the thread lost.
18
16
  */
19
17
  export function SubagentInfo({ chatId, toolUseId }: { chatId: string; toolUseId: string }) {
20
18
  const { t } = useTranslation('agent-chat');
@@ -24,43 +22,27 @@ export function SubagentInfo({ chatId, toolUseId }: { chatId: string; toolUseId:
24
22
  const task = chatHost().tasks.useTask(id, item === null ? null : taskIdOf(item));
25
23
  const modelName = useModelName(provider, item?.model ?? '');
26
24
  const now = useNow(1000, item?.status === 'running');
27
- const [open, setOpen] = useState(false);
28
- if (item === null) {
29
- return null;
30
- }
31
- const word = statusWordOf(item, task);
32
- const facts = subagentFacts(item, item.model === undefined ? null : modelName);
25
+ const word = item === null ? null : statusWordOf(item, task);
26
+ const facts = item === null ? [] : subagentFacts(item, item.model === undefined ? null : modelName);
33
27
  return (
34
- <section aria-label={t('subagents.info.label')} className="shrink-0 border-b border-border px-4 py-2">
35
- <div className="chat-column-content flex flex-col gap-2">
36
- <div className="flex min-w-0 items-center gap-2 text-xs text-text-faint">
37
- <StatusIcon word={word} />
38
- <span className="sr-only">{t(`common.status.${word}`)}</span>
39
- <span className="shrink-0 text-text-muted tabular-nums">{entryTimeOf(item, task, now)}</span>
40
- {facts.map((fact) => (
41
- <Fragment key={fact}>
42
- <span aria-hidden="true">·</span>
43
- <span className="min-w-0 truncate">{fact}</span>
44
- </Fragment>
45
- ))}
46
- <span className="grow" />
47
- {item.prompt && (
48
- <button
49
- type="button"
50
- className="flex shrink-0 items-center gap-1.5 text-text-muted hover:text-text"
51
- aria-expanded={open}
52
- onClick={() => setOpen((current) => !current)}
53
- >
54
- <Icon icon={ChevronDown} size={12} className={clsx('transition-transform', open && 'rotate-180')} />
55
- {open ? t('subagents.info.hideTask') : t('subagents.info.showTask')}
56
- </button>
57
- )}
58
- </div>
59
- {open && item.prompt && (
60
- <div className="max-h-60 overflow-auto rounded-md border border-border bg-surface-raised px-3 py-2 text-xs select-text">
61
- <Markdown text={item.prompt} />
62
- </div>
28
+ <section aria-label={t('subagents.info.label')} className="shrink-0 border-b border-border px-4 py-1.5">
29
+ <div className="flex min-w-0 items-center gap-2 text-xs text-text-faint">
30
+ <SubagentBreadcrumb chatId={chatId} className="max-w-1/3 shrink-0 font-medium" />
31
+ {item !== null && word !== null && (
32
+ <>
33
+ <StatusIcon word={word} />
34
+ <span className="sr-only">{t(`common.status.${word}`)}</span>
35
+ <span className="shrink-0 text-text-muted tabular-nums">{entryTimeOf(item, task, now)}</span>
36
+ </>
63
37
  )}
38
+ {facts.map((fact) => (
39
+ <Fragment key={fact}>
40
+ <span aria-hidden="true">·</span>
41
+ <span className="min-w-0 truncate">{fact}</span>
42
+ </Fragment>
43
+ ))}
44
+ <span className="grow" />
45
+ <SubagentActions chatId={chatId} />
64
46
  </div>
65
47
  </section>
66
48
  );
@@ -30,7 +30,8 @@ export function SubagentStopButton({
30
30
  }
31
31
  const send = (): void => {
32
32
  void actions.stopSubagent(chatId, item.toolUseId).catch((e: unknown) => {
33
- const description = e instanceof Error ? e.message : t('subagents.noAnswer');
33
+ const stale = e instanceof Error && 'code' in e && e.code === 'subagent-stale';
34
+ const description = stale ? t('subagents.stale') : e instanceof Error ? e.message : t('subagents.noAnswer');
34
35
  chatHost().notify({ kind: 'error', title: t('subagents.stopFailed'), description });
35
36
  });
36
37
  };
@@ -136,8 +136,7 @@ export function SubagentTimeline({ chatId, toolUseId }: { chatId: string; toolUs
136
136
  loadEarlier();
137
137
  }}
138
138
  >
139
- {/* The same menu the chat's own thread has; this transcript is read-only, so the chat
140
- it belongs to is not passed on and the fork item stays out. */}
139
+ {/* The chat's own menu; this transcript is read-only, so no chat is passed and the fork item stays out. */}
141
140
  <ContextMenu.Root>
142
141
  <ContextMenu.Trigger
143
142
  ref={threadRef}
@@ -1,6 +1,6 @@
1
- import { type CSSProperties, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from 'react';
1
+ import { type CSSProperties, type RefObject, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from 'react';
2
2
  import { useTranslation } from 'react-i18next';
3
- import { useVirtualizer } from '@tanstack/react-virtual';
3
+ import { useVirtualizer, type Virtualizer } from '@tanstack/react-virtual';
4
4
  import clsx from 'clsx';
5
5
  import { LoaderCircle } from 'lucide-react';
6
6
  import type { ChatBookmark } from '@adecore/agent-contracts';
@@ -16,6 +16,7 @@ import {
16
16
  registerTurnJumper,
17
17
  setTimelineAtEnd,
18
18
  wantsEarlier,
19
+ type ItemJumpOptions,
19
20
  type ReadingAnchor,
20
21
  type TurnTarget
21
22
  } from '../timeline-scroll';
@@ -66,6 +67,14 @@ import { useModelName } from '../../agents/model-name';
66
67
  import { ContextMenu, EmptyState, ErrorBoundary, Icon, SectionLabel } from '@adecore/ui';
67
68
 
68
69
  const ESTIMATED_ROW_PX = 56;
70
+ // How long a jump waits for a thread that is still loading before it calls the item missing.
71
+ const JUMP_THREAD_WAIT_MS = 8000;
72
+
73
+ /* A jump puts its row at the top and stops following the end, so the reader stays where they landed. */
74
+ function showRowAtTop(virtualizer: Virtualizer<HTMLDivElement, Element>, followRef: RefObject<boolean>, index: number): void {
75
+ followRef.current = false;
76
+ virtualizer.scrollToIndex(index, { align: 'start' });
77
+ }
69
78
 
70
79
  /*
71
80
  * A thread before its first message: which model answers, what the lines into this node let it read,
@@ -216,7 +225,7 @@ export function Timeline({ chatId, composer, overlay }: { chatId: string; compos
216
225
  const marks = useMemo(() => bookmarkRows(rows, bookmarks ?? [], items ?? {}), [rows, bookmarks, items]);
217
226
  const ticks = useMemo(() => ticksOf(rows, marks), [rows, marks]);
218
227
  // A message a jump is on its way to; it may first have to open the turn its reply folded into.
219
- const [seeking, setSeeking] = useState<{ itemId: string; block?: UiReplyTarget; key: string } | null>(null);
228
+ const [seeking, setSeeking] = useState<{ itemId: string; block?: UiReplyTarget; key: string; options?: ItemJumpOptions } | null>(null);
220
229
  const [landing, setLanding] = useState<{ target: UiReplyTarget; key: string } | null>(null);
221
230
  // A turn a jump is on its way to, which waits for the page its messages are on to come in.
222
231
  const [revealing, setRevealing] = useState<{ turnId: string; target: TurnTarget } | null>(null);
@@ -288,7 +297,7 @@ export function Timeline({ chatId, composer, overlay }: { chatId: string; compos
288
297
  [chatId]
289
298
  );
290
299
 
291
- // The client does not run the React Compiler, so its memoization rule has nothing to break here.
300
+ // Nothing here builds with the React Compiler, so its memoization rule has nothing to break.
292
301
  // oxlint-disable-next-line react/incompatible-library
293
302
  const virtualizer = useVirtualizer({
294
303
  count: rows.length,
@@ -307,11 +316,9 @@ export function Timeline({ chatId, composer, overlay }: { chatId: string; compos
307
316
  });
308
317
  const tail = lastReplyLength ?? rows.length;
309
318
  /*
310
- * The rows are measured as they render, so the end of the list moves while it is drawn: a diff
311
- * that highlights, an image that loads, a tool row that grows a line of output. `scrollToIndex`
312
- * aims at where the row was when it was asked, which is what left the thread short of the end.
313
- * The scroller's own bottom is a fact rather than an estimate, and the total size changing is
314
- * what says a row was measured again, so this runs for every one of those.
319
+ * Rows are measured as they render, so the end moves while it is drawn (a diff that highlights, an
320
+ * image that loads). `scrollToIndex` aims at where a row was when asked and fell short; the
321
+ * scroller's own bottom is a fact, and a new total size says a row was measured again.
315
322
  */
316
323
  const totalSize = virtualizer.getTotalSize();
317
324
  useLayoutEffect(() => {
@@ -361,8 +368,7 @@ export function Timeline({ chatId, composer, overlay }: { chatId: string; compos
361
368
 
362
369
  /*
363
370
  * Where each message starts, from the virtualizer's measurements (an estimate for a row it never
364
- * drew), so reading the scroll position costs no layout. The virtualizer renders this component on
365
- * every scroll already.
371
+ * drew), so reading the scroll position costs no layout.
366
372
  */
367
373
  const measurements = virtualizer.measurementsCache;
368
374
  const starts = ticks.map((tick) => measurements[tick.rowIndex]?.start ?? tick.rowIndex * ESTIMATED_ROW_PX);
@@ -418,8 +424,7 @@ export function Timeline({ chatId, composer, overlay }: { chatId: string; compos
418
424
  setSeeking({ itemId: tick.bookmark.itemId, key: keyOf(chatId) });
419
425
  return;
420
426
  }
421
- followRef.current = false;
422
- virtualizer.scrollToIndex(tick.rowIndex, { align: 'start' });
427
+ showRowAtTop(virtualizer, followRef, tick.rowIndex);
423
428
  };
424
429
 
425
430
  useLayoutEffect(() => {
@@ -439,8 +444,7 @@ export function Timeline({ chatId, composer, overlay }: { chatId: string; compos
439
444
  } else {
440
445
  setSeeking(null);
441
446
  if (jump.kind === 'row') {
442
- followRef.current = false;
443
- virtualizer.scrollToIndex(jump.index, { align: 'start' });
447
+ showRowAtTop(virtualizer, followRef, jump.index);
444
448
  setLanding({ target: seeking.block, key: seeking.key });
445
449
  }
446
450
  }
@@ -449,11 +453,17 @@ export function Timeline({ chatId, composer, overlay }: { chatId: string; compos
449
453
  const index = rows.findIndex((row) => row.id === seeking.itemId);
450
454
  if (index !== -1) {
451
455
  setSeeking(null);
452
- followRef.current = false;
453
- virtualizer.scrollToIndex(index, { align: 'start' });
456
+ showRowAtTop(virtualizer, followRef, index);
457
+ if (seeking.options?.flash) {
458
+ useTimelineFlash.getState().flash(seeking.key, seeking.itemId, false);
459
+ }
460
+ return;
461
+ }
462
+ // The thread has not loaded yet, so nothing says the item is missing; the effect below bounds the wait.
463
+ if (items === undefined) {
454
464
  return;
455
465
  }
456
- const item = items?.[seeking.itemId];
466
+ const item = items[seeking.itemId];
457
467
  if (item === undefined && cursor !== null) {
458
468
  loadEarlier();
459
469
  return;
@@ -464,12 +474,24 @@ export function Timeline({ chatId, composer, overlay }: { chatId: string; compos
464
474
  return;
465
475
  }
466
476
  setSeeking(null);
477
+ seeking.options?.onMissing?.();
467
478
  }, [seeking, rows, items, turns, virtualizer, cursor, loadEarlier, keyOf, chatId]);
479
+ const waitingForThread = seeking !== null && items === undefined;
480
+ useEffect(() => {
481
+ if (!waitingForThread) {
482
+ return;
483
+ }
484
+ const timer = window.setTimeout(() => {
485
+ setSeeking(null);
486
+ seeking?.options?.onMissing?.();
487
+ }, JUMP_THREAD_WAIT_MS);
488
+ return () => window.clearTimeout(timer);
489
+ }, [waitingForThread, seeking]);
468
490
  useEffect(
469
491
  () =>
470
- registerItemJumper(keyOf(chatId), (itemId) => {
492
+ registerItemJumper(keyOf(chatId), (itemId, options) => {
471
493
  setLanding(null);
472
- setSeeking({ itemId, key: keyOf(chatId) });
494
+ setSeeking({ itemId, key: keyOf(chatId), options });
473
495
  }),
474
496
  [keyOf, chatId]
475
497
  );
@@ -505,8 +527,7 @@ export function Timeline({ chatId, composer, overlay }: { chatId: string; compos
505
527
  const found = changes !== -1 ? changes : rows.findIndex((row) => row.id === opening?.id || row.id === `start-${turnId}` || row.id === `fold-${turnId}`);
506
528
  if (found !== -1) {
507
529
  setRevealing(null);
508
- followRef.current = false;
509
- virtualizer.scrollToIndex(found, { align: 'start' });
530
+ showRowAtTop(virtualizer, followRef, found);
510
531
  useTimelineFlash.getState().flash(keyOf(chatId), rows[found]!.id, changes !== -1);
511
532
  return;
512
533
  }
@@ -564,8 +585,7 @@ export function Timeline({ chatId, composer, overlay }: { chatId: string; compos
564
585
  return;
565
586
  }
566
587
  subagents.add(found.branch.id);
567
- followRef.current = false;
568
- virtualizer.scrollToIndex(found.index, { align: 'start' });
588
+ showRowAtTop(virtualizer, followRef, found.index);
569
589
  };
570
590
 
571
591
  return (
@@ -11,11 +11,7 @@ import { useChatRow } from '../../state/chats';
11
11
  import { openFileLink, useFileLinkCwd, useFileLinkScopeId } from './file-links';
12
12
  import { ContextMenu, copyText, DisabledReason, EDIT_SHORTCUTS, Icon, Kbd, selectAllWithin } from '@adecore/ui';
13
13
 
14
- /*
15
- * The menu behind a right-click in a thread. Copy is the reason it exists. Everything in a thread
16
- * is text a person may want out of it, and a whole message, a code block or the markdown an answer
17
- * was written in are each a different amount of that.
18
- */
14
+ /* The menu behind a right-click in a thread, mostly for copying: a whole message, a code block or an answer's markdown. */
19
15
  export function TimelineMenuPopup({
20
16
  target,
21
17
  thread,
@@ -88,10 +84,8 @@ export function TimelineMenuPopup({
88
84
  }
89
85
 
90
86
  /*
91
- * Place a bookmark on a message, or rename and remove the one it has: the same rows from the thread
92
- * and from the strip beside it. `onName` runs when a row opens the name field, which sits on the
93
- * bookmark's line over the message and so has to be on screen; the strip scrolls there, the thread
94
- * was clicked on it.
87
+ * Place a bookmark on a message, or rename and remove the one it has, from the thread and from the strip.
88
+ * `onName` runs when a row opens the name field, which sits over the message and so has to be on screen.
95
89
  */
96
90
  export function BookmarkMenuItems({ chatId, itemId, onName }: { chatId: string; itemId: string; onName?: () => void }) {
97
91
  const { t } = useTranslation('agent-chat');
@@ -7,9 +7,8 @@ import { useDiffTheme } from './diff-theme';
7
7
  import { fullFileDiff, type DiffContents } from './full-diff';
8
8
 
9
9
  /*
10
- * A patch the CLI reported itself (Codex writes unified diffs). The renderer needs a file header
11
- * to know what it is looking at, and a CLI that only sends hunks gets one from the path we know.
12
- * A diff without hunks at all is shown as its own lines, so nothing is ever swallowed.
10
+ * A patch the CLI reported itself. The renderer needs a file header, which a CLI that only sends hunks
11
+ * gets from the path we know; a diff without hunks is shown as its own lines, so nothing is swallowed.
13
12
  */
14
13
  function asPatch(change: ChatFileChange): string | null {
15
14
  if (!/^@@/m.test(change.diff)) {
@@ -23,8 +22,7 @@ function asPatch(change: ChatFileChange): string | null {
23
22
  return `--- ${from}\n+++ ${to}\n${change.diff}`;
24
23
  }
25
24
 
26
- /* Lets a diff with a view of its own run to the bottom of it, so the scrollbar of a short one sits
27
- there instead of under its last line; the lines themselves keep their height. */
25
+ /* Lets a diff with a view of its own run to its bottom, so a short one's scrollbar sits there and not under its last line. */
28
26
  const FILL_CSS = `
29
27
  :host { display: flex; flex-direction: column; flex-grow: 1; }
30
28
  pre { flex-grow: 1; align-content: start; }
@@ -28,10 +28,9 @@ function personActsIn(frame: HTMLIFrameElement): boolean {
28
28
  }
29
29
 
30
30
  /*
31
- * One visual's page in a frame on the thread's own ground. The frame loads the app's sandbox host
32
- * page and the bridge hands it the page and the theme. The frame never gets `allow-same-origin`,
33
- * which keeps the page on an opaque origin even when the host page is served on the app's own. Its
34
- * box keeps one height while the page loads and when it fails, so nothing under it moves.
31
+ * One visual's page in a frame that loads the app's sandbox host page; the bridge hands it the page and
32
+ * the theme. The frame never gets `allow-same-origin`, which keeps the page on an opaque origin even
33
+ * when the host page is served on the app's own. Its box keeps one height while it loads or fails.
35
34
  */
36
35
  export function VisualFrame({ chatId, visual, fill = false, className }: VisualFrameProps) {
37
36
  const { t } = useTranslation('agent-chat');
@@ -60,25 +60,35 @@ export function enterAction(keys: EnterKeys, { inOpenFence, list, column }: Ente
60
60
  return list.empty ? 'leave-list' : 'continue-list';
61
61
  }
62
62
 
63
- /* Whether `pos` sits inside a fenced code block that has an opening fence and no closing one yet. */
64
- export function inOpenFence(tree: Tree, pos: number): boolean {
63
+ /* The innermost fenced code block around `pos`, if any. */
64
+ function enclosingFence(tree: Tree, pos: number): SyntaxNode | null {
65
65
  for (let node: SyntaxNode | null = tree.resolveInner(pos, -1); node !== null; node = node.parent) {
66
66
  if (node.name === 'FencedCode') {
67
- return pos > node.from && node.getChildren('CodeMark').length < 2;
67
+ return node;
68
68
  }
69
69
  }
70
- return false;
70
+ return null;
71
+ }
72
+
73
+ /* A fence nobody has closed yet has its opening mark only. */
74
+ function isOpenFence(fence: SyntaxNode): boolean {
75
+ return fence.getChildren('CodeMark').length < 2;
76
+ }
77
+
78
+ /* Whether `pos` sits inside a fenced code block that has an opening fence and no closing one yet. */
79
+ export function inOpenFence(tree: Tree, pos: number): boolean {
80
+ const fence = enclosingFence(tree, pos);
81
+ return fence !== null && pos > fence.from && isOpenFence(fence);
71
82
  }
72
83
 
73
84
  /* Whether `pos` sits in the body of a fenced code block, open or closed: below its opening line and before its end. */
74
85
  export function inFenceBody(tree: Tree, text: string, pos: number): boolean {
75
- for (let node: SyntaxNode | null = tree.resolveInner(pos, -1); node !== null; node = node.parent) {
76
- if (node.name === 'FencedCode') {
77
- const openingEnd = text.indexOf('\n', node.from);
78
- return openingEnd !== -1 && pos > openingEnd && (pos < node.to || node.getChildren('CodeMark').length < 2);
79
- }
86
+ const fence = enclosingFence(tree, pos);
87
+ if (fence === null) {
88
+ return false;
80
89
  }
81
- return false;
90
+ const openingEnd = text.indexOf('\n', fence.from);
91
+ return openingEnd !== -1 && pos > openingEnd && (pos < fence.to || isOpenFence(fence));
82
92
  }
83
93
 
84
94
  /* The spaces Tab inserts at `column`, up to the next stop of four, the way a code editor lines up. */
@@ -97,7 +107,7 @@ export function inCode(tree: Tree, text: string, pos: number): boolean {
97
107
  let blockFrom: number | null = null;
98
108
  for (let node: SyntaxNode | null = tree.resolveInner(pos, -1); node !== null; node = node.parent) {
99
109
  if (node.name === 'FencedCode') {
100
- return pos > node.from && (pos < node.to || node.getChildren('CodeMark').length < 2);
110
+ return pos > node.from && (pos < node.to || isOpenFence(node));
101
111
  }
102
112
  if (node.name === 'InlineCode' && pos > node.from && pos < node.to) {
103
113
  return true;
@@ -0,0 +1,29 @@
1
+ export interface ComposerMenuAriaInput {
2
+ label: string;
3
+ listId: string;
4
+ open: boolean;
5
+ /* Counts across every group of the menu, the way the keyboard moves through it. */
6
+ activeIndex: number;
7
+ count: number;
8
+ }
9
+
10
+ /* The index that still points at an option once the list has shrunk under a stale one. */
11
+ export function clampMenuIndex(index: number, count: number): number {
12
+ return Math.min(index, Math.max(count - 1, 0));
13
+ }
14
+
15
+ export function optionIdOf(listId: string, index: number): string {
16
+ return `${listId}-${index}`;
17
+ }
18
+
19
+ /*
20
+ * The attributes the editor's content element takes while a suggestion menu stands beside it. The
21
+ * element keeps its `textbox` role; ARIA 1.2 lets it name the option the arrow keys are on.
22
+ */
23
+ export function composerMenuAria({ label, listId, open, activeIndex, count }: ComposerMenuAriaInput): Record<string, string> {
24
+ return {
25
+ 'aria-label': label,
26
+ 'aria-autocomplete': 'list',
27
+ ...(open && count > 0 ? { 'aria-controls': listId, 'aria-activedescendant': optionIdOf(listId, activeIndex) } : {})
28
+ };
29
+ }
@@ -1,9 +1,9 @@
1
1
  import { createContext, type ReactNode } from 'react';
2
2
  import type { UiComponentName, UiProps, UiViewNode } from '@adecore/intelligent-ui';
3
-
4
- export type { UiLinkTarget } from '@adecore/intelligent-ui/links';
5
3
  import type { UiLinkTarget } from '@adecore/intelligent-ui/links';
6
4
 
5
+ export type { UiLinkTarget };
6
+
7
7
  /* How the host lets a link be drawn: a chip it can open, or plain text it cannot. */
8
8
  export interface UiLink {
9
9
  state: 'chip' | 'plain';
@@ -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
  }
@@ -1,6 +1,6 @@
1
1
  import { chipText, tokenizeChips, type ChipSegment } from '../mentions';
2
2
 
3
- /* Just the part of hast this plugin touches; the package's own types are not a dependency of the client. */
3
+ /* Just the part of hast this plugin touches; the package's own types are not a dependency here. */
4
4
  interface HastText {
5
5
  type: 'text';
6
6
  value: string;
@@ -1,4 +1,4 @@
1
- /* Just the part of hast this plugin touches; the package's own types are not a dependency of the client. */
1
+ /* Just the part of hast this plugin touches; the package's own types are not a dependency here. */
2
2
  interface HastText {
3
3
  type: 'text';
4
4
  value: string;
@@ -1,4 +1,4 @@
1
- /* Just the part of mdast this plugin touches; the package's own types are not a dependency of the client. */
1
+ /* Just the part of mdast this plugin touches; the package's own types are not a dependency here. */
2
2
  interface MdastNode {
3
3
  type: string;
4
4
  value?: string;
@@ -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,15 +424,14 @@ 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
  }
392
431
 
393
432
  /*
394
- * The header of a turn the agent started itself, in place of the message of the person that is
395
- * missing. It is one line: what the sub-agent came back with lives on that agent's own row, behind
396
- * "Show result". When the CLI named the sub-agent it woke up about, the header opens that row.
433
+ * The header of a turn the agent started itself, in place of a message of the person. What the
434
+ * sub-agent came back with lives on its own row, which the header opens when the CLI named it.
397
435
  */
398
436
  export function AgentTurnRow({ label, onOpen }: { label: string; onOpen?: () => void }) {
399
437
  const line = 'mb-0.5 flex h-7 w-full items-center gap-2 text-left text-xs text-text-muted';
@@ -430,7 +468,7 @@ export function CompactionRow({ preTokens }: { preTokens: number | null }) {
430
468
  );
431
469
  }
432
470
 
433
- const DECISION_ICON: Record<Exclude<ChatApprovalItem['decision'], 'pending'>, React.ReactNode> = {
471
+ const DECISION_ICON: Record<Exclude<ChatApprovalItem['decision'], 'pending'>, ReactNode> = {
434
472
  allow: <Icon icon={Check} size={12} />,
435
473
  'allow-always': <Icon icon={Check} size={12} />,
436
474
  deny: <Icon icon={X} size={12} />,
@@ -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':