@adecore/agents-react 0.21.2 → 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 (78) 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/quote-selection.d.ts +5 -2
  38. package/dist/chat/ui/quote-selection.js +3 -2
  39. package/dist/chat/ui/rows/MessageRows.js +24 -5
  40. package/dist/chat/ui/rows/Rows.js +1 -1
  41. package/dist/chat/ui/rows/WorkRows.d.ts +3 -1
  42. package/dist/chat/ui/rows/WorkRows.js +4 -4
  43. package/dist/host.d.ts +1 -0
  44. package/dist/host.js +1 -0
  45. package/dist/locales/en/agent-chat.json +30 -7
  46. package/dist/locales/nl/agent-chat.json +30 -7
  47. package/dist/state/chats.d.ts +2 -1
  48. package/dist/state/chats.js +19 -1
  49. package/package.json +24 -4
  50. package/src/agents/model-requirement.ts +36 -0
  51. package/src/chat/chat-client.ts +5 -0
  52. package/src/chat/drafts.ts +27 -7
  53. package/src/chat/logic/picker-entries.ts +25 -0
  54. package/src/chat/logic/timeline.ts +17 -2
  55. package/src/chat/prompt-hold.ts +27 -0
  56. package/src/chat/quote.ts +20 -0
  57. package/src/chat/stash.ts +31 -5
  58. package/src/chat/subagent-list.ts +19 -5
  59. package/src/chat/timeline-scroll.ts +24 -10
  60. package/src/chat/ui/ChatActivity.tsx +35 -9
  61. package/src/chat/ui/Composer.tsx +197 -52
  62. package/src/chat/ui/ComposerInput.tsx +18 -3
  63. package/src/chat/ui/Pickers.tsx +51 -30
  64. package/src/chat/ui/PromptComposer.tsx +37 -8
  65. package/src/chat/ui/QuoteButton.tsx +3 -2
  66. package/src/chat/ui/SubagentControls.tsx +22 -19
  67. package/src/chat/ui/SubagentInfo.tsx +24 -42
  68. package/src/chat/ui/SubagentStopButton.tsx +2 -1
  69. package/src/chat/ui/Timeline.tsx +26 -4
  70. package/src/chat/ui/composer/menu-aria.ts +29 -0
  71. package/src/chat/ui/quote-selection.ts +6 -4
  72. package/src/chat/ui/rows/MessageRows.tsx +44 -5
  73. package/src/chat/ui/rows/Rows.tsx +3 -1
  74. package/src/chat/ui/rows/WorkRows.tsx +15 -1
  75. package/src/host.ts +3 -0
  76. package/src/locales/en/agent-chat.json +30 -7
  77. package/src/locales/nl/agent-chat.json +30 -7
  78. package/src/state/chats.ts +31 -2
@@ -1,6 +1,7 @@
1
1
  import { useEffect, useMemo, useRef, type ReactNode } from 'react';
2
2
  import { useTranslation } from 'react-i18next';
3
3
  import { ArrowDown, Hand, MessageCircleQuestionMark } from 'lucide-react';
4
+ import type { HoldRelease } from '../prompt-hold';
4
5
  import { focusPromptHeading, focusPromptStart } from '../../prompts/logic/focus';
5
6
  import { orderPrompts, type PendingPrompt } from '../../prompts/logic/prompts';
6
7
  import { answerPrompt, isBlockingSubject, promptCreatedAt, promptIdOf, type PromptSubject } from '../../prompts/logic/subjects';
@@ -10,7 +11,14 @@ import { PromptView } from '../../prompts/ui/PromptView';
10
11
  import { chatHost } from '../../host';
11
12
  import { useChatScope } from '../../scope';
12
13
  import { useChatActions } from '../actions';
13
- import { Icon } from '@adecore/ui';
14
+ import { Button, Icon } from '@adecore/ui';
15
+
16
+ /* Keeps a prompt card behind the draft while the person types; `released` says what lifted the hold last. */
17
+ export interface PromptHold {
18
+ held: boolean;
19
+ releasedBy: HoldRelease | null;
20
+ release(reason: HoldRelease): void;
21
+ }
14
22
 
15
23
  /* The one being read stays in front; otherwise blocking before optional, oldest first, as everywhere else. */
16
24
  function pickSubject(waiting: readonly PromptSubject[], activeId: string | null): PromptSubject | null {
@@ -26,6 +34,7 @@ export function PromptComposer({
26
34
  hasDraft,
27
35
  denyReason,
28
36
  onAllAnswered,
37
+ hold,
29
38
  children
30
39
  }: {
31
40
  chatId: string;
@@ -38,6 +47,8 @@ export function PromptComposer({
38
47
  denyReason: boolean;
39
48
  /* Takes the keyboard back to the composer once the last prompt was answered from its card. */
40
49
  onAllAnswered?: () => void;
50
+ /* Without it a card takes the composer's place at once. */
51
+ hold?: PromptHold;
41
52
  children: ReactNode;
42
53
  }) {
43
54
  const { t } = useTranslation('agent-chat');
@@ -56,10 +67,23 @@ export function PromptComposer({
56
67
  const session = usePromptSession({ prompts: subjects, idOf: promptIdOf, pick: pickSubject, disabled });
57
68
  const ref = useRef<HTMLDivElement>(null);
58
69
  const { active, activeId: activeKey } = session;
59
- const expanded = !!active;
70
+ const asksApproval = active?.kind === 'chat' ? active.item.kind === 'approval' : active?.prompt.asks === 'approval';
71
+ const held = !!active && (hold?.held ?? false);
72
+ const expanded = !!active && !held;
73
+ // The card that waited behind the draft, so a blur that released it is not mistaken for one released long before.
74
+ const heldKey = useRef<string | null>(null);
60
75
  const refocus = useFocusAfterAnswer(activeKey, ref, () => onAllAnswered?.());
61
76
 
62
77
  useEffect(() => {
78
+ if (held) {
79
+ heldKey.current = activeKey;
80
+ }
81
+ }, [held, activeKey]);
82
+
83
+ useEffect(() => {
84
+ if (hold?.releasedBy === 'blur' && heldKey.current === activeKey) {
85
+ return;
86
+ }
63
87
  if (focused && expanded && !elsewhere && !ref.current?.querySelector('.prompt-card')?.contains(document.activeElement)) {
64
88
  // Keys meant for the draft may still be coming, and on a button the next Space would answer the card.
65
89
  if (hasDraft) {
@@ -68,7 +92,7 @@ export function PromptComposer({
68
92
  focusPromptStart(ref.current);
69
93
  }
70
94
  }
71
- }, [activeKey, expanded, focused, elsewhere, hasDraft]);
95
+ }, [activeKey, expanded, focused, elsewhere, hasDraft, hold?.releasedBy]);
72
96
 
73
97
  return (
74
98
  <div ref={ref} onPointerDownCapture={session.onPointerDownCapture} onClickCapture={session.onClickCapture}>
@@ -78,11 +102,7 @@ export function PromptComposer({
78
102
  className="flex w-full items-center gap-2 px-3.5 py-3 text-left text-sm text-text-muted hover:text-text"
79
103
  onClick={onShowElsewhere}
80
104
  >
81
- <Icon
82
- icon={(active.kind === 'chat' ? active.item.kind === 'approval' : active.prompt.asks === 'approval') ? Hand : MessageCircleQuestionMark}
83
- size={16}
84
- className="shrink-0 text-status-needs-you"
85
- />
105
+ <Icon icon={asksApproval ? Hand : MessageCircleQuestionMark} size={16} className="shrink-0 text-status-needs-you" />
86
106
  <span className="grow">{t('composer.waitingBelow')}</span>
87
107
  <Icon icon={ArrowDown} size={16} className="shrink-0" />
88
108
  </button>
@@ -113,6 +133,15 @@ export function PromptComposer({
113
133
  />
114
134
  </div>
115
135
  )}
136
+ {held && active && (
137
+ <div role="status" className="flex items-center gap-2 border-b border-border px-3.5 py-2.5">
138
+ <Icon icon={asksApproval ? Hand : MessageCircleQuestionMark} size={16} className="shrink-0 text-status-needs-you" />
139
+ <span className="grow text-sm text-text-muted">{t(asksApproval ? 'composer.held.approval' : 'composer.held.question')}</span>
140
+ <Button size="sm" onMouseDown={(e) => e.preventDefault()} onClick={() => hold?.release('show')}>
141
+ {t('composer.held.show')}
142
+ </Button>
143
+ </div>
144
+ )}
116
145
  <div hidden={expanded}>{children}</div>
117
146
  </div>
118
147
  );
@@ -10,6 +10,7 @@ const EDGE_PX = 40;
10
10
 
11
11
  interface Offer {
12
12
  quote: string;
13
+ itemId: string | null;
13
14
  x: number;
14
15
  y: number;
15
16
  }
@@ -52,7 +53,7 @@ export function QuoteButton({
52
53
  const zoom = box.offsetWidth > 0 ? bounds.width / box.offsetWidth : 1;
53
54
  const x = Math.min(Math.max((line.right - bounds.left) / zoom, EDGE_PX), box.offsetWidth - EDGE_PX);
54
55
  const y = (line.bottom - bounds.top) / zoom + GAP_PX;
55
- setOffer({ quote, x: Math.round(x), y: Math.round(y) });
56
+ setOffer({ quote: quote.text, itemId: quote.itemId, x: Math.round(x), y: Math.round(y) });
56
57
  };
57
58
  const press = (): void => {
58
59
  pressed = true;
@@ -91,7 +92,7 @@ export function QuoteButton({
91
92
  onMouseDown={(e) => e.preventDefault()}
92
93
  onClick={() => {
93
94
  document.getSelection()?.removeAllRanges();
94
- taker.current?.(offer.quote);
95
+ taker.current?.(offer.quote, offer.itemId);
95
96
  setOffer(null);
96
97
  }}
97
98
  >
@@ -21,31 +21,18 @@ export function SubagentTitleCrumb({ chatId, className, children }: { chatId: st
21
21
  );
22
22
  }
23
23
 
24
- /*
25
- * The way down after the chat's title, each step a way back up, and a close that goes straight back
26
- * to the main agent. A bar that does not draw the title itself passes it, and it opens the way.
27
- * Nothing while the main agent itself is on screen.
28
- */
29
- export function SubagentBreadcrumb({ chatId, title, className }: { chatId: string; title?: string; className?: string }) {
24
+ /* The way down to the sub-agent on screen, each step but the last a way back up. Nothing while the main agent is on screen. */
25
+ export function SubagentBreadcrumb({ chatId, className }: { chatId: string; className?: string }) {
30
26
  const { t } = useTranslation('agent-chat');
31
27
  const { trail, show } = useSubagentTrail(chatId);
32
- const subagents = useOpenableSubagents(chatId);
33
28
  if (trail.length === 0) {
34
29
  return null;
35
30
  }
36
- const steps = breadcrumbOf(trail);
37
- // Only a row of the chat's own thread can be stopped; a grandchild belongs to the sub-agent that opened it.
38
- const shown = trail.length === 1 ? subagents.find((item) => item.toolUseId === trail[0]!.toolUseId) : undefined;
39
31
  return (
40
- <nav aria-label={t('subagents.title')} className={clsx('flex min-w-0 items-center gap-1 text-xs', className)}>
41
- {title !== undefined && (
42
- <button type="button" className={clsx(CRUMB_LINK, 'min-w-0 shrink')} onClick={() => show(MAIN_AGENT)}>
43
- {title}
44
- </button>
45
- )}
46
- {steps.map((step) => (
32
+ <nav aria-label={t('subagents.title')} className={clsx('flex min-w-0 items-center gap-1', className)}>
33
+ {breadcrumbOf(trail).map((step) => (
47
34
  <Fragment key={step.depth}>
48
- <Icon icon={ChevronRight} size={12} className="shrink-0 text-text-faint" />
35
+ {step.depth > 1 && <Icon icon={ChevronRight} size={12} className="shrink-0 text-text-faint" />}
49
36
  {step.current ? (
50
37
  <span aria-current="page" className="min-w-0 truncate text-text">
51
38
  {step.label}
@@ -57,8 +44,24 @@ export function SubagentBreadcrumb({ chatId, title, className }: { chatId: strin
57
44
  )}
58
45
  </Fragment>
59
46
  ))}
47
+ </nav>
48
+ );
49
+ }
50
+
51
+ /* Stops the sub-agent on screen where that is on offer, and closes it straight back to the main agent. */
52
+ export function SubagentActions({ chatId }: { chatId: string }) {
53
+ const { t } = useTranslation('agent-chat');
54
+ const { trail, show } = useSubagentTrail(chatId);
55
+ const subagents = useOpenableSubagents(chatId);
56
+ if (trail.length === 0) {
57
+ return null;
58
+ }
59
+ // Only a row of the chat's own thread can be stopped; a grandchild belongs to the sub-agent that opened it.
60
+ const shown = trail.length === 1 ? subagents.find((item) => item.toolUseId === trail[0]!.toolUseId) : undefined;
61
+ return (
62
+ <div className="flex shrink-0 items-center gap-1">
60
63
  {shown !== undefined && <SubagentStopButton chatId={chatId} item={shown} size="sm" />}
61
64
  <IconButton icon={X} size="sm" label={t('subagents.backToMain')} onClick={() => show(MAIN_AGENT)} />
62
- </nav>
65
+ </div>
63
66
  );
64
67
  }
@@ -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
  };
@@ -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,8 @@ 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;
69
72
 
70
73
  /* A jump puts its row at the top and stops following the end, so the reader stays where they landed. */
71
74
  function showRowAtTop(virtualizer: Virtualizer<HTMLDivElement, Element>, followRef: RefObject<boolean>, index: number): void {
@@ -222,7 +225,7 @@ export function Timeline({ chatId, composer, overlay }: { chatId: string; compos
222
225
  const marks = useMemo(() => bookmarkRows(rows, bookmarks ?? [], items ?? {}), [rows, bookmarks, items]);
223
226
  const ticks = useMemo(() => ticksOf(rows, marks), [rows, marks]);
224
227
  // A message a jump is on its way to; it may first have to open the turn its reply folded into.
225
- 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);
226
229
  const [landing, setLanding] = useState<{ target: UiReplyTarget; key: string } | null>(null);
227
230
  // A turn a jump is on its way to, which waits for the page its messages are on to come in.
228
231
  const [revealing, setRevealing] = useState<{ turnId: string; target: TurnTarget } | null>(null);
@@ -451,9 +454,16 @@ export function Timeline({ chatId, composer, overlay }: { chatId: string; compos
451
454
  if (index !== -1) {
452
455
  setSeeking(null);
453
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
  );
@@ -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
+ }
@@ -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,