@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
@@ -1,10 +1,15 @@
1
- import { useEffect, useMemo, useRef, useState, type ReactNode, type RefObject } from 'react';
1
+ import { useContext, useEffect, useMemo, useRef, useState, type ReactNode, type RefObject } from 'react';
2
2
  import { useTranslation } from 'react-i18next';
3
3
  import { Bookmark, ChevronDown, ChevronRight, Search, Trash2 } from 'lucide-react';
4
- import type { AgentKind, ModelInfo, ModelSelection, ProviderInfo, UsageProvider } from '@adecore/agent-contracts';
4
+ import type { AgentKind, ModelSelection, ProviderInfo, UsageProvider } from '@adecore/agent-contracts';
5
5
  import { AgentIcon } from '../../agents/AgentIcon';
6
6
  import { ProviderLogo } from '../../agents/ProviderLogo';
7
7
  import { agentChipOf, modelName } from '../../agents/model-name';
8
+ import { modelRequirementOf } from '../../agents/model-requirement';
9
+ import { chatHost } from '../../host';
10
+ import { ChatScopeContext } from '../../scope';
11
+ import { useReportedModels } from '../../state/chats';
12
+ import { pickerEntriesOf, type PickerEntry } from '../logic/picker-entries';
8
13
  import { forgetStashed, STASH_SHORTCUT, useStash, type StashedPrompt } from '../stash';
9
14
  import { Icon, IconButton, Menu, Popover, Tooltip } from '@adecore/ui';
10
15
 
@@ -14,13 +19,6 @@ const triggerClass =
14
19
  const chipClass =
15
20
  'flex h-6 min-w-0 shrink items-center gap-1.5 whitespace-nowrap rounded-md bg-surface-hover px-2 text-xs text-text hover:bg-surface-active data-[popup-open]:bg-surface-active';
16
21
 
17
- /* A model row, or the one row that folds the legacy models open. Both take a turn in the arrow keys. */
18
- type PickerEntry = { kind: 'model'; provider: ProviderInfo; model: ModelInfo } | { kind: 'legacy'; count: number };
19
-
20
- function matches(model: ModelInfo, query: string): boolean {
21
- return model.name.toLowerCase().includes(query) || model.slug.toLowerCase().includes(query);
22
- }
23
-
24
22
  interface ModelPickerProps {
25
23
  /* Whose models may be picked: every installed chat CLI before the first message, the chat's own after it. */
26
24
  providers: ProviderInfo[];
@@ -73,18 +71,13 @@ export function ModelPicker({
73
71
  const grouped = providers.length > 1;
74
72
  const trimmed = query.trim().toLowerCase();
75
73
 
76
- const entries = useMemo((): PickerEntry[] => {
77
- const rows = providers.flatMap((entry) => entry.models.map((model) => ({ kind: 'model' as const, provider: entry, model })));
78
- if (trimmed !== '') {
79
- return rows.filter((row) => matches(row.model, trimmed));
80
- }
81
- const live = rows.filter((row) => !row.model.legacy);
82
- const legacy = rows.filter((row) => row.model.legacy);
83
- if (legacy.length === 0) {
84
- return live;
85
- }
86
- return legacyOpen ? [...live, { kind: 'legacy', count: legacy.length }, ...legacy] : [...live, { kind: 'legacy', count: legacy.length }];
87
- }, [providers, trimmed, legacyOpen]);
74
+ const entries = useMemo(() => pickerEntriesOf(providers, trimmed, legacyOpen), [providers, trimmed, legacyOpen]);
75
+ const scope = useContext(ChatScopeContext);
76
+ const machine = chatHost().useMachineLabel(scope?.id ?? '');
77
+ const reported = useReportedModels(provider);
78
+ const requirementOf = (entry: PickerEntry & { kind: 'model' }) => modelRequirementOf(entry.model, entry.provider, reported);
79
+ // A CLI that is too old blocks all its models; one way to its settings under the group says it once.
80
+ const blockedKinds = new Set(entries.flatMap((entry) => (entry.kind === 'model' && requirementOf(entry) !== null ? [entry.provider.kind] : [])));
88
81
 
89
82
  /* Closing forgets the search, so the next open (a click or `/model`) starts on the whole list. */
90
83
  const setOpen = (next: boolean): void => {
@@ -110,6 +103,9 @@ export function ModelPicker({
110
103
  inputRef.current?.focus();
111
104
  return;
112
105
  }
106
+ if (requirementOf(entry) !== null) {
107
+ return;
108
+ }
113
109
  onChange(entry.provider.kind, entry.model.slug);
114
110
  setOpen(false);
115
111
  };
@@ -184,6 +180,9 @@ export function ModelPicker({
184
180
  }
185
181
  const previous = entries[i - 1];
186
182
  const first = grouped && (previous === undefined || previous.kind !== 'model' || previous.provider.kind !== entry.provider.kind);
183
+ const requirement = requirementOf(entry);
184
+ const next = entries[i + 1];
185
+ const lastOfGroup = next === undefined || next.kind !== 'model' || next.provider.kind !== entry.provider.kind;
187
186
  const chosen = entry.provider.kind === provider && entry.model.slug === selection.model;
188
187
  return (
189
188
  <div key={`${entry.provider.kind}/${entry.model.slug}`}>
@@ -196,21 +195,43 @@ export function ModelPicker({
196
195
  <button
197
196
  role="option"
198
197
  aria-selected={chosen}
198
+ aria-disabled={requirement !== null || undefined}
199
199
  data-active={i === index}
200
- className="cursor-row flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-left text-sm text-text-muted"
200
+ className={`cursor-row flex w-full flex-col items-stretch gap-0.5 rounded-md px-2.5 py-1.5 text-left text-sm ${requirement === null ? 'text-text-muted' : 'text-text-faint'}`}
201
201
  onMouseEnter={() => setIndex(i)}
202
202
  onClick={() => choose(entry)}
203
203
  >
204
- <Menu.Check kind="radio" checked={chosen} />
205
- <span className="min-w-0 truncate">{entry.model.name}</span>
206
- {entry.model.badge && (
207
- <span className="rounded bg-accent-soft px-1 text-[11px] leading-5 font-medium uppercase text-accent">
208
- {entry.model.badge}
204
+ <span className="flex w-full items-center gap-2">
205
+ <Menu.Check kind="radio" checked={chosen} />
206
+ <span className="min-w-0 truncate">{entry.model.name}</span>
207
+ {entry.model.badge && (
208
+ <span className="rounded bg-accent-soft px-1 text-[11px] leading-5 font-medium uppercase text-accent">
209
+ {entry.model.badge}
210
+ </span>
211
+ )}
212
+ <span className="grow" />
213
+ {entry.model.legacy && <span className="text-xs text-text-faint">{t('pickers.model.legacy')}</span>}
214
+ </span>
215
+ {requirement !== null && (
216
+ <span className="pl-6 text-xs text-text-faint">
217
+ {machine === null
218
+ ? t('pickers.model.requiresHere', { cli: requirement.cli, version: requirement.minVersion })
219
+ : t('pickers.model.requires', { cli: requirement.cli, version: requirement.minVersion, machine })}
209
220
  </span>
210
221
  )}
211
- <span className="grow" />
212
- {entry.model.legacy && <span className="text-xs text-text-faint">{t('pickers.model.legacy')}</span>}
213
222
  </button>
223
+ {lastOfGroup && blockedKinds.has(entry.provider.kind) && (
224
+ <button
225
+ type="button"
226
+ className="cursor-row flex w-full items-center rounded-md px-2.5 py-1.5 text-left text-xs text-text-muted"
227
+ onClick={() => {
228
+ setOpen(false);
229
+ chatHost().openSettings('providers');
230
+ }}
231
+ >
232
+ {t('pickers.model.providerSettings')}
233
+ </button>
234
+ )}
214
235
  </div>
215
236
  );
216
237
  })}
@@ -247,7 +268,7 @@ export function StashPicker({ onRestore }: { onRestore(prompt: StashedPrompt): v
247
268
  className="flex min-w-0 grow flex-col gap-0.5 rounded-md px-2 py-1.5 text-left text-xs text-text-muted hover:bg-surface-hover hover:text-text"
248
269
  onClick={() => onRestore(prompt)}
249
270
  >
250
- <span className="line-clamp-2 whitespace-pre-wrap">{prompt.text || t('pickers.stash.noText')}</span>
271
+ <span className="line-clamp-2 whitespace-pre-wrap">{prompt.text || prompt.quote?.text || t('pickers.stash.noText')}</span>
251
272
  {prompt.attachments.length > 0 && (
252
273
  <span className="text-text-faint">{t('pickers.stash.files', { count: prompt.attachments.length })}</span>
253
274
  )}
@@ -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
+ }
@@ -2,14 +2,14 @@ import { useId, useLayoutEffect, useRef, useState, type ReactNode, type RefObjec
2
2
  import clsx from 'clsx';
3
3
  import { useTranslation } from 'react-i18next';
4
4
  import type { UiViewNode } from '@adecore/intelligent-ui';
5
- import { Lock, Radio, TriangleAlert, Wrench } from 'lucide-react';
5
+ import { Info, Lock, Radio, TriangleAlert } from 'lucide-react';
6
6
  import { Icon, Skeleton, Spinner, Tooltip, useTickingText } from '@adecore/ui';
7
7
  import { formatAgo, formatMoment } from '@adecore/ui/format';
8
8
  import { uiBlockHead, uiReasonText } from './node-text';
9
9
  import { UiHeadContext, type UiLiveStatus } from './render-context';
10
10
  import { useLater } from './use-later';
11
11
 
12
- /* Something the compiler repaired, such as a prop it dropped; never an error to a person. */
12
+ /* Something the compiler noted about a block: a prop it dropped, or a part it could only draw as text. */
13
13
  export interface UiBlockFix {
14
14
  code: string;
15
15
  message: string;
@@ -82,7 +82,7 @@ function LivePart({ live }: { live: UiLiveStatus }) {
82
82
  }
83
83
 
84
84
  /*
85
- * The line under a block, only when it has something to say: live, answered, repaired or drawn as
85
+ * The line under a block, only when it has something to say: live, answered, noted or drawn as
86
86
  * text, in that order. A hairline of the soft border runs the full width of the card over it.
87
87
  */
88
88
  function UiBlockFooter({ live = null, answered = null, fixes = NO_FIXES, shownAsText = null }: UiBlockFooterProps) {
@@ -111,8 +111,8 @@ function UiBlockFooter({ live = null, answered = null, fixes = NO_FIXES, shownAs
111
111
  className="flex items-center gap-1.5 rounded-sm hover:text-text-muted"
112
112
  onClick={() => setFixesOpen(!fixesOpen)}
113
113
  >
114
- <Icon icon={Wrench} size={12} />
115
- {t('blocks.fixes', { count: fixes.length })}
114
+ <Icon icon={Info} size={12} />
115
+ {t('blocks.notes', { count: fixes.length })}
116
116
  </button>
117
117
  )}
118
118
  {shownAsText !== null && (
@@ -15,7 +15,12 @@ export function UiFallbackPart({ fallback, problem }: { fallback: string; proble
15
15
  <span className="text-xs text-text-faint">
16
16
  {problem.kind === 'unknown' ? t('blocks.unknownComponent', { component: problem.component }) : t('blocks.renderFailed')}
17
17
  </span>
18
- {fallback !== '' && <Markdown text={fallback} fileLinks={false} />}
18
+ {fallback !== '' && (
19
+ // A fallback is often data without a space in it, such as the JSON of a chart.
20
+ <div className="min-w-0 wrap-anywhere">
21
+ <Markdown text={fallback} fileLinks={false} />
22
+ </div>
23
+ )}
19
24
  </div>
20
25
  );
21
26
  }