@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,4 +1,4 @@
1
- import { useContext, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
1
+ import { useContext, useEffect, useId, useMemo, useRef, useState, useSyncExternalStore } from 'react';
2
2
  import { useTranslation } from 'react-i18next';
3
3
  import { indentLess, indentMore, insertNewline } from '@codemirror/commands';
4
4
  import { ensureSyntaxTree, syntaxTree } from '@codemirror/language';
@@ -52,13 +52,15 @@ import { PROMPT_MAX_CHARS, pasteBecomesAttachment, pastedTextName, promptGuard,
52
52
  import { withQuote } from '../quote';
53
53
  import { rememberChatAccount, rememberChatPreferences, rememberChatSelection } from '../preferences';
54
54
  import { STASH_SHORTCUT, stashDraft, type StashedPrompt, useStash } from '../stash';
55
- import { pageTimeline, scrollTimelineToEnd, subscribeTimelineEnd, timelineAtEnd } from '../timeline-scroll';
55
+ import { jumpToTimelineItem, pageTimeline, scrollTimelineToEnd, subscribeTimelineEnd, timelineAtEnd } from '../timeline-scroll';
56
56
  import { ChatActivity } from './ChatActivity';
57
57
  import { ChatReferenceChip } from './ChatReferenceChip';
58
58
  import { chipDecorations } from './composer/chips';
59
+ import { clampMenuIndex, composerMenuAria, optionIdOf } from './composer/menu-aria';
59
60
  import { enterAction, inCode, inFenceBody, inOpenFence, listItemAt, recallDirection, tabSpaces } from './composer/keys';
60
61
  import { ComposerInput, type ComposerInputHandle } from './ComposerInput';
61
- import { PromptComposer } from './PromptComposer';
62
+ import { holdLiftsIn, type HoldRelease } from '../prompt-hold';
63
+ import { PromptComposer, type PromptHold } from './PromptComposer';
62
64
  import { QuoteTakerContext } from './quote-selection';
63
65
  import { LimitDock } from './LimitState';
64
66
  import { ResumeCompactionDock } from './ResumeCompactionDock';
@@ -177,6 +179,59 @@ function splitPath(path: string): { name: string; dir: string } {
177
179
  }
178
180
 
179
181
  // Keep the editor mounted while a pending request takes over, so its selection and draft survive.
182
+ /*
183
+ * Keeps a waiting prompt card behind the draft while the person types. Typing, pasting and dictating
184
+ * all change the draft, so `noteInput` is called from its change handler; the hold lifts after a pause.
185
+ */
186
+ function usePromptHold(): PromptHold & { noteInput(): void; setComposing(composing: boolean): void } {
187
+ const typedAt = useRef<number | null>(null);
188
+ const composing = useRef(false);
189
+ const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
190
+ const [state, setState] = useState<{ held: boolean; releasedBy: HoldRelease | null }>({ held: false, releasedBy: null });
191
+
192
+ const clearTimer = (): void => {
193
+ if (timer.current !== null) {
194
+ clearTimeout(timer.current);
195
+ timer.current = null;
196
+ }
197
+ };
198
+
199
+ const release = (reason: HoldRelease): void => {
200
+ clearTimer();
201
+ typedAt.current = null;
202
+ composing.current = false;
203
+ setState((current) => (current.held ? { held: false, releasedBy: reason } : current));
204
+ };
205
+
206
+ const schedule = (): void => {
207
+ clearTimer();
208
+ const wait = holdLiftsIn({ typedAt: typedAt.current, composing: composing.current }, Date.now());
209
+ if (wait !== null) {
210
+ timer.current = setTimeout(() => release('pause'), wait);
211
+ }
212
+ };
213
+
214
+ const noteInput = (): void => {
215
+ typedAt.current = Date.now();
216
+ setState({ held: true, releasedBy: null });
217
+ schedule();
218
+ };
219
+
220
+ const setComposing = (next: boolean): void => {
221
+ composing.current = next;
222
+ if (next) {
223
+ setState({ held: true, releasedBy: null });
224
+ } else {
225
+ typedAt.current = Date.now();
226
+ }
227
+ schedule();
228
+ };
229
+
230
+ useEffect(() => clearTimer, []);
231
+
232
+ return { held: state.held, releasedBy: state.releasedBy, release, noteInput, setComposing };
233
+ }
234
+
180
235
  export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabled, readOnly = false, providerFixed, onSend, onRetarget }: ComposerProps) {
181
236
  const { t } = useTranslation('agent-chat');
182
237
  const sendDelivery = chatHost().useSendDelivery();
@@ -200,6 +255,7 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
200
255
  const { isApplePlatform, notify } = chatHost();
201
256
  const registerQuoteTaker = useContext(QuoteTakerContext);
202
257
  const inputRef = useRef<ComposerInputHandle>(null);
258
+ const hold = usePromptHold();
203
259
  // Taking a queued message back waits on the machine, and what was typed meanwhile is what it merges with.
204
260
  const draftRef = useRef(draft);
205
261
  const fileInputRef = useRef<HTMLInputElement>(null);
@@ -238,6 +294,7 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
238
294
  const queue = info.queue ?? [];
239
295
  const text = draft.text;
240
296
  const quote = draft.quote;
297
+ const quoteSource = draft.quoteSource;
241
298
  // The CLI announces its session on the first message, so anything before that is still a blank chat.
242
299
  const started = info.agentSessionId !== null || info.usage.turns > 0;
243
300
  const accountChoice = useAccountChoice(info.provider, info.account);
@@ -270,8 +327,8 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
270
327
 
271
328
  useEffect(
272
329
  () =>
273
- registerQuoteTaker?.((selected) => {
274
- setDraft((current) => ({ ...current, quote: selected }));
330
+ registerQuoteTaker?.((selected, itemId) => {
331
+ setDraft((current) => ({ ...current, quote: selected, quoteSource: itemId }));
275
332
  inputRef.current?.focus();
276
333
  }),
277
334
  [registerQuoteTaker]
@@ -383,6 +440,26 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
383
440
  [chatId, chats, draft.chats, mention]
384
441
  );
385
442
  const mentionCount = chatMatches.length + files.length;
443
+ // One menu is open at a time, so they share an id.
444
+ const listId = useId();
445
+ const mentionsLabelId = `${listId}-chats`;
446
+ const filesLabelId = `${listId}-files`;
447
+ const filesLabelShown = mention !== null && (mention.query === '' || chatMatches.length > 0);
448
+ const menuOpen = commandMenuOpen || skillMenuOpen || mentionMenuOpen;
449
+ const menuCount = commandMenuOpen ? commands.length : skillMenuOpen ? skillMatches.length : mentionCount;
450
+ // The list can shrink under a stale index; every reader takes the one that still points at an option.
451
+ const activeIndex = clampMenuIndex(menuIndex, menuCount);
452
+ const inputAria = useMemo(
453
+ () =>
454
+ composerMenuAria({
455
+ label: t('composer.inputLabel'),
456
+ listId,
457
+ open: menuOpen,
458
+ activeIndex,
459
+ count: menuCount
460
+ }),
461
+ [t, listId, menuOpen, activeIndex, menuCount]
462
+ );
386
463
  const editorExtensions = useMemo(
387
464
  () => [chipDecorations({ mentions: draft.mentions, skills: draft.skills }), LEAVE_DROPS_TO_THE_CARD],
388
465
  [draft.mentions, draft.skills]
@@ -398,7 +475,7 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
398
475
  };
399
476
 
400
477
  const removeQuote = (): void => {
401
- setDraft((current) => ({ ...current, quote: '' }));
478
+ setDraft((current) => ({ ...current, quote: '', quoteSource: null }));
402
479
  inputRef.current?.focus();
403
480
  };
404
481
 
@@ -618,7 +695,8 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
618
695
  setNotice(`${rejected.name}: ${rejected.reason}`);
619
696
  return;
620
697
  }
621
- const chosen = commands[menuIndex];
698
+ hold.release('send');
699
+ const chosen = commands[activeIndex];
622
700
  if (commandQuery !== null && chosen?.local && runCommand(chosen.name)) {
623
701
  clearDraft();
624
702
  return;
@@ -639,6 +717,7 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
639
717
  mentions: presentMentions(trimmed, draft.mentions),
640
718
  skills: presentSkills(trimmed, draft.skills),
641
719
  chats: draft.chats,
720
+ quoteSource: quote !== '' && quoteSource !== null ? { itemId: quoteSource } : undefined,
642
721
  attachments: draft.attachments
643
722
  });
644
723
  clearDraft();
@@ -647,9 +726,20 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
647
726
  setHistoryIndex(null);
648
727
  };
649
728
 
650
- /* A stashed prompt comes back as text, mentions and skills; its files were never kept. */
729
+ /*
730
+ * A stashed prompt comes back as text, mentions and skills; its files were never kept. Its quote
731
+ * keeps leading to its reply in the chat it came from, and is plain text in any other.
732
+ */
651
733
  const restoreStashed = (prompt: StashedPrompt): void => {
652
- setDraft((current) => ({ ...current, text: prompt.text, mentions: [...prompt.mentions], skills: [...prompt.skills] }));
734
+ const stashedQuote = prompt.quote;
735
+ const here = stashedQuote !== undefined && stashedQuote.chatId === chatId;
736
+ setDraft((current) => ({
737
+ ...current,
738
+ text: stashedQuote === undefined || here ? prompt.text : withQuote(stashedQuote.text, prompt.text),
739
+ mentions: [...prompt.mentions],
740
+ skills: [...prompt.skills],
741
+ ...(here ? { quote: stashedQuote.text, quoteSource: stashedQuote.itemId } : {})
742
+ }));
653
743
  setHistoryIndex(null);
654
744
  inputRef.current?.focus();
655
745
  };
@@ -682,7 +772,7 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
682
772
  failed(e);
683
773
  return;
684
774
  }
685
- const taken: Omit<ChatDraft, 'quote'> = {
775
+ const taken: Omit<ChatDraft, 'quote' | 'quoteSource'> = {
686
776
  text: message.text,
687
777
  mentions: message.mentions ?? [],
688
778
  skills: message.skills ?? [],
@@ -726,7 +816,7 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
726
816
  }
727
817
  return;
728
818
  }
729
- if (stashDraft(draft)) {
819
+ if (stashDraft(draft, chatId)) {
730
820
  clearDraft();
731
821
  setMention(null);
732
822
  setSkillQuery(null);
@@ -800,7 +890,7 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
800
890
  return true;
801
891
  }
802
892
  if (e.key === 'Tab') {
803
- const chosen = commands[menuIndex]!;
893
+ const chosen = commands[activeIndex]!;
804
894
  if (chosen.skill) {
805
895
  chooseSkill(chosen.name);
806
896
  } else {
@@ -814,7 +904,7 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
814
904
  return true;
815
905
  }
816
906
  if (e.key === 'Tab' || e.key === 'Enter') {
817
- chooseSkill((skillMatches[menuIndex] ?? skillMatches[0]!).name);
907
+ chooseSkill((skillMatches[activeIndex] ?? skillMatches[0]!).name);
818
908
  return true;
819
909
  }
820
910
  }
@@ -823,7 +913,7 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
823
913
  return true;
824
914
  }
825
915
  if (e.key === 'Tab' || e.key === 'Enter') {
826
- chooseMentionAt(menuIndex);
916
+ chooseMentionAt(activeIndex);
827
917
  return true;
828
918
  }
829
919
  }
@@ -996,6 +1086,7 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
996
1086
  hasDraft={!isEmptyDraft(draft)}
997
1087
  denyReason={capabilities?.denyReason === true}
998
1088
  onAllAnswered={() => inputRef.current?.focus()}
1089
+ hold={hold}
999
1090
  >
1000
1091
  <LimitDock chatId={chatId} info={info} />
1001
1092
  {compactionOffer && (
@@ -1006,11 +1097,15 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
1006
1097
  />
1007
1098
  )}
1008
1099
  {commandMenuOpen && (
1009
- <div className="border-b border-border px-1.5 py-1.5">
1100
+ <div id={listId} role="listbox" aria-label={t('composer.menu.commands')} className="border-b border-border px-1.5 py-1.5">
1010
1101
  {commands.map((command, index) => (
1011
1102
  <button
1012
1103
  key={command.name}
1013
- data-active={index === menuIndex}
1104
+ id={optionIdOf(listId, index)}
1105
+ role="option"
1106
+ aria-selected={index === activeIndex}
1107
+ tabIndex={-1}
1108
+ data-active={index === activeIndex}
1014
1109
  className="cursor-row flex w-full items-center gap-2 rounded-md px-2 py-1 text-left text-xs text-text-muted"
1015
1110
  onMouseEnter={() => setMenuIndex(index)}
1016
1111
  onMouseDown={(e) => e.preventDefault()}
@@ -1036,11 +1131,15 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
1036
1131
  </div>
1037
1132
  )}
1038
1133
  {skillMenuOpen && (
1039
- <div className="border-b border-border px-1.5 py-1.5">
1134
+ <div id={listId} role="listbox" aria-label={t('composer.menu.skills')} className="border-b border-border px-1.5 py-1.5">
1040
1135
  {skillMatches.map((skill, index) => (
1041
1136
  <button
1042
1137
  key={skill.name}
1043
- data-active={index === menuIndex}
1138
+ id={optionIdOf(listId, index)}
1139
+ role="option"
1140
+ aria-selected={index === activeIndex}
1141
+ tabIndex={-1}
1142
+ data-active={index === activeIndex}
1044
1143
  className="cursor-row flex w-full items-start gap-2 rounded-md px-2 py-1 text-left text-xs text-text-muted"
1045
1144
  onMouseEnter={() => setMenuIndex(index)}
1046
1145
  onMouseDown={(e) => e.preventDefault()}
@@ -1057,45 +1156,70 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
1057
1156
  )}
1058
1157
  {mentionMenuOpen && (
1059
1158
  <div className="border-b border-border px-1.5 py-1.5">
1060
- {chatMatches.length > 0 && <Menu.Label>{t('composer.mentions.chats')}</Menu.Label>}
1061
- {chatMatches.map((chat, index) => (
1062
- <button
1063
- key={chat.id}
1064
- data-active={index === menuIndex}
1065
- className="cursor-row flex w-full items-center gap-2 rounded-md px-2 py-1 text-left text-xs text-text-muted"
1066
- onMouseEnter={() => setMenuIndex(index)}
1067
- onMouseDown={(e) => e.preventDefault()}
1068
- onClick={() => chooseChat(chat.id)}
1069
- >
1070
- <Icon icon={MessageSquare} size={14} className="shrink-0 text-text-faint" />
1071
- <span className="truncate text-text">{chat.title || t('chatReference.untitled')}</span>
1072
- </button>
1073
- ))}
1074
- {files.length > 0 && (mention.query === '' || chatMatches.length > 0) && (
1075
- <Menu.Label>{mention.query === '' ? t('composer.mentions.here') : t('composer.mentions.files')}</Menu.Label>
1159
+ {mentionCount > 0 && (
1160
+ <div id={listId} role="listbox" aria-label={t('composer.menu.mentions')}>
1161
+ {chatMatches.length > 0 && (
1162
+ <div role="group" aria-labelledby={mentionsLabelId}>
1163
+ <Menu.Label id={mentionsLabelId}>{t('composer.mentions.chats')}</Menu.Label>
1164
+ {chatMatches.map((chat, index) => (
1165
+ <button
1166
+ key={chat.id}
1167
+ id={optionIdOf(listId, index)}
1168
+ role="option"
1169
+ aria-selected={index === activeIndex}
1170
+ tabIndex={-1}
1171
+ data-active={index === activeIndex}
1172
+ className="cursor-row flex w-full items-center gap-2 rounded-md px-2 py-1 text-left text-xs text-text-muted"
1173
+ onMouseEnter={() => setMenuIndex(index)}
1174
+ onMouseDown={(e) => e.preventDefault()}
1175
+ onClick={() => chooseChat(chat.id)}
1176
+ >
1177
+ <Icon icon={MessageSquare} size={14} className="shrink-0 text-text-faint" />
1178
+ <span className="truncate text-text">{chat.title || t('chatReference.untitled')}</span>
1179
+ </button>
1180
+ ))}
1181
+ </div>
1182
+ )}
1183
+ {files.length > 0 && (
1184
+ <div
1185
+ role="group"
1186
+ {...(filesLabelShown ? { 'aria-labelledby': filesLabelId } : { 'aria-label': t('composer.mentions.files') })}
1187
+ >
1188
+ {filesLabelShown && (
1189
+ <Menu.Label id={filesLabelId}>
1190
+ {mention.query === '' ? t('composer.mentions.here') : t('composer.mentions.files')}
1191
+ </Menu.Label>
1192
+ )}
1193
+ {files.map((path, index) => {
1194
+ const { name, dir } = splitPath(path);
1195
+ return (
1196
+ <button
1197
+ key={path}
1198
+ id={optionIdOf(listId, chatMatches.length + index)}
1199
+ role="option"
1200
+ aria-selected={chatMatches.length + index === activeIndex}
1201
+ tabIndex={-1}
1202
+ data-active={chatMatches.length + index === activeIndex}
1203
+ className="cursor-row flex w-full items-center gap-2 rounded-md px-2 py-1 text-left text-xs text-text-muted"
1204
+ onMouseEnter={() => setMenuIndex(chatMatches.length + index)}
1205
+ onMouseDown={(e) => e.preventDefault()}
1206
+ onClick={() => chooseMention(path)}
1207
+ >
1208
+ <FileIcon path={path} size={14} />
1209
+ <span className="truncate font-mono text-text">{name}</span>
1210
+ {dir && <span className="min-w-0 truncate text-text-faint">{dir}</span>}
1211
+ </button>
1212
+ );
1213
+ })}
1214
+ </div>
1215
+ )}
1216
+ </div>
1076
1217
  )}
1077
1218
  {mentionCount === 0 && (
1078
1219
  <div className="px-2 py-1 text-xs text-text-faint">
1079
1220
  {mention.query ? t('composer.mentions.noMatch') : t('composer.mentions.empty')}
1080
1221
  </div>
1081
1222
  )}
1082
- {files.map((path, index) => {
1083
- const { name, dir } = splitPath(path);
1084
- return (
1085
- <button
1086
- key={path}
1087
- data-active={chatMatches.length + index === menuIndex}
1088
- className="cursor-row flex w-full items-center gap-2 rounded-md px-2 py-1 text-left text-xs text-text-muted"
1089
- onMouseEnter={() => setMenuIndex(chatMatches.length + index)}
1090
- onMouseDown={(e) => e.preventDefault()}
1091
- onClick={() => chooseMention(path)}
1092
- >
1093
- <FileIcon path={path} size={14} />
1094
- <span className="truncate font-mono text-text">{name}</span>
1095
- {dir && <span className="min-w-0 truncate text-text-faint">{dir}</span>}
1096
- </button>
1097
- );
1098
- })}
1099
1223
  </div>
1100
1224
  )}
1101
1225
  {queue.length > 0 && (
@@ -1152,7 +1276,24 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
1152
1276
  )}
1153
1277
  {quote !== '' && (
1154
1278
  <div className="flex items-center gap-2 px-5 pt-4 @max-md/composer:px-3.5 @max-md/composer:pt-3">
1155
- <span className="min-w-0 grow truncate border-l-2 border-border-strong pl-2.5 text-xs text-text-muted">{quote}</span>
1279
+ {quoteSource === null ? (
1280
+ <span className="min-w-0 grow truncate border-l-2 border-border-strong pl-2.5 text-xs text-text-muted">{quote}</span>
1281
+ ) : (
1282
+ <Tooltip label={t('composer.quote.goToSource')}>
1283
+ <button
1284
+ type="button"
1285
+ className="min-w-0 grow truncate border-l-2 border-border-strong pl-2.5 text-left text-xs text-text-muted hover:text-text"
1286
+ onClick={() =>
1287
+ jumpToTimelineItem(scope.keyOf(chatId), quoteSource, {
1288
+ flash: true,
1289
+ onMissing: () => setNotice(t('rows.user.quoteGone'))
1290
+ })
1291
+ }
1292
+ >
1293
+ {quote}
1294
+ </button>
1295
+ </Tooltip>
1296
+ )}
1156
1297
  <IconButton icon={X} size="2xs" label={t('composer.quote.remove')} onClick={removeQuote} />
1157
1298
  </div>
1158
1299
  )}
@@ -1198,7 +1339,9 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
1198
1339
  disabled={!writable}
1199
1340
  tabbable={focused}
1200
1341
  extensions={editorExtensions}
1342
+ aria={inputAria}
1201
1343
  onChange={(value, selection, state) => {
1344
+ hold.noteInput();
1202
1345
  setText(value);
1203
1346
  setMenuIndex(0);
1204
1347
  trackTriggers(value, selection, state);
@@ -1210,7 +1353,9 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
1210
1353
  onBlur={() => {
1211
1354
  setMention(null);
1212
1355
  setSkillQuery(null);
1356
+ hold.release('blur');
1213
1357
  }}
1358
+ onComposing={hold.setComposing}
1214
1359
  onKeyDown={onKeyDown}
1215
1360
  onPaste={onPaste}
1216
1361
  />
@@ -29,9 +29,13 @@ interface ComposerInputProps {
29
29
  className?: string;
30
30
  /* Keep the reference stable. A new one reconfigures the editor. */
31
31
  extensions?: Extension;
32
+ /* Attributes for the editable element, such as its label and the option a menu points at; keep the reference stable. */
33
+ aria?: Readonly<Record<string, string>>;
32
34
  onChange(text: string, selection: InputSelection, state: EditorState): void;
33
35
  onSelectionChange?(text: string, selection: InputSelection, state: EditorState): void;
34
36
  onBlur?(): void;
37
+ /* An IME composition starts or ends; the text it builds is not in the document until it ends. */
38
+ onComposing?(composing: boolean): void;
35
39
  /* Runs before the editor's own keymap; true means the key is handled and its default prevented. */
36
40
  onKeyDown?(event: KeyboardEvent, view: EditorView): boolean;
37
41
  /* Runs before the editor pastes the plain text itself; true means the paste is handled. */
@@ -40,6 +44,8 @@ interface ComposerInputProps {
40
44
 
41
45
  const NO_EXTENSIONS: Extension = [];
42
46
 
47
+ const NO_ARIA: Readonly<Record<string, string>> = {};
48
+
43
49
  /*
44
50
  * A multi-line text field on CodeMirror, controlled by a plain string. The editor draws the text
45
51
  * itself, so a decoration may change a font or add padding without walking the caret off its
@@ -54,6 +60,7 @@ export function ComposerInput({
54
60
  className,
55
61
  dictationToolbar,
56
62
  extensions = NO_EXTENSIONS,
63
+ aria = NO_ARIA,
57
64
  ...handlers
58
65
  }: ComposerInputProps) {
59
66
  const hostRef = useRef<HTMLDivElement>(null);
@@ -94,7 +101,15 @@ export function ComposerInput({
94
101
  Prec.highest(
95
102
  EditorView.domEventHandlers({
96
103
  keydown: (event, target) => latest.current.handlers.onKeyDown?.(event, target) ?? false,
97
- paste: (event, target) => latest.current.handlers.onPaste?.(event, target) ?? false
104
+ paste: (event, target) => latest.current.handlers.onPaste?.(event, target) ?? false,
105
+ compositionstart: () => {
106
+ latest.current.handlers.onComposing?.(true);
107
+ return false;
108
+ },
109
+ compositionend: () => {
110
+ latest.current.handlers.onComposing?.(false);
111
+ return false;
112
+ }
98
113
  })
99
114
  ),
100
115
  EditorView.updateListener.of((update) => {
@@ -128,13 +143,13 @@ export function ComposerInput({
128
143
  viewRef.current?.dispatch({
129
144
  effects: [
130
145
  // Spellcheck stays on, as it was on the textarea this editor replaced.
131
- compartments.attributes.reconfigure(EditorView.contentAttributes.of({ spellcheck: 'true', tabindex: tabbable ? '0' : '-1' })),
146
+ compartments.attributes.reconfigure(EditorView.contentAttributes.of({ ...aria, spellcheck: 'true', tabindex: tabbable ? '0' : '-1' })),
132
147
  compartments.editable.reconfigure([EditorView.editable.of(!disabled), EditorState.readOnly.of(disabled)]),
133
148
  compartments.placeholder.reconfigure(placeholderText(placeholder)),
134
149
  compartments.extensions.reconfigure(extensions)
135
150
  ]
136
151
  });
137
- }, [compartments, tabbable, disabled, placeholder, extensions]);
152
+ }, [compartments, tabbable, disabled, placeholder, extensions, aria]);
138
153
 
139
154
  useLayoutEffect(() => {
140
155
  const view = viewRef.current;
@@ -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
  )}