@adecore/agents-react 0.21.1 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (145) hide show
  1. package/dist/agents/model-requirement.d.ts +6 -0
  2. package/dist/agents/model-requirement.js +26 -0
  3. package/dist/agents/status-look.js +2 -3
  4. package/dist/chat/chat-client.d.ts +3 -2
  5. package/dist/chat/chat-client.js +4 -2
  6. package/dist/chat/drafts.d.ts +2 -1
  7. package/dist/chat/drafts.js +21 -17
  8. package/dist/chat/logic/ansi.js +5 -1
  9. package/dist/chat/logic/fork.js +8 -9
  10. package/dist/chat/logic/limit.js +17 -14
  11. package/dist/chat/logic/picker-entries.d.ts +10 -0
  12. package/dist/chat/logic/picker-entries.js +19 -0
  13. package/dist/chat/logic/resume-compaction.js +2 -11
  14. package/dist/chat/logic/scrubber.js +9 -18
  15. package/dist/chat/logic/timeline.d.ts +5 -0
  16. package/dist/chat/logic/timeline.js +25 -11
  17. package/dist/chat/logic/tool-catalog.js +3 -4
  18. package/dist/chat/mentions.js +5 -6
  19. package/dist/chat/prompt-hold.d.ts +8 -0
  20. package/dist/chat/prompt-hold.js +16 -0
  21. package/dist/chat/quote.d.ts +4 -0
  22. package/dist/chat/quote.js +19 -0
  23. package/dist/chat/runtime-modes.js +2 -4
  24. package/dist/chat/stash.d.ts +8 -2
  25. package/dist/chat/stash.js +22 -8
  26. package/dist/chat/subagent-list.d.ts +7 -1
  27. package/dist/chat/subagent-list.js +13 -1
  28. package/dist/chat/thumbnails.js +3 -4
  29. package/dist/chat/timeline-scroll.d.ts +6 -2
  30. package/dist/chat/timeline-scroll.js +45 -46
  31. package/dist/chat/ui/BookmarkSubmenu.js +1 -5
  32. package/dist/chat/ui/ChatActivity.js +22 -8
  33. package/dist/chat/ui/CodeBlock.js +2 -3
  34. package/dist/chat/ui/Composer.js +160 -88
  35. package/dist/chat/ui/ComposerInput.d.ts +3 -1
  36. package/dist/chat/ui/ComposerInput.js +13 -4
  37. package/dist/chat/ui/Markdown.js +11 -11
  38. package/dist/chat/ui/Pickers.js +31 -27
  39. package/dist/chat/ui/PromptComposer.d.ts +8 -1
  40. package/dist/chat/ui/PromptComposer.js +18 -6
  41. package/dist/chat/ui/QuoteButton.js +4 -5
  42. package/dist/chat/ui/RunSettings.js +8 -2
  43. package/dist/chat/ui/Scrubber.js +2 -3
  44. package/dist/chat/ui/SubagentControls.d.ts +4 -2
  45. package/dist/chat/ui/SubagentControls.js +13 -11
  46. package/dist/chat/ui/SubagentInfo.d.ts +1 -1
  47. package/dist/chat/ui/SubagentInfo.js +9 -15
  48. package/dist/chat/ui/SubagentStopButton.js +2 -1
  49. package/dist/chat/ui/Timeline.js +39 -21
  50. package/dist/chat/ui/TimelineMenu.js +3 -9
  51. package/dist/chat/ui/UnifiedDiff.js +3 -5
  52. package/dist/chat/ui/VisualFrame.js +3 -4
  53. package/dist/chat/ui/composer/keys.js +19 -11
  54. package/dist/chat/ui/composer/menu-aria.d.ts +10 -0
  55. package/dist/chat/ui/composer/menu-aria.js +18 -0
  56. package/dist/chat/ui/intelligent-ui/render-context.d.ts +1 -1
  57. package/dist/chat/ui/quote-selection.d.ts +5 -2
  58. package/dist/chat/ui/quote-selection.js +3 -2
  59. package/dist/chat/ui/rows/MessageRows.js +26 -8
  60. package/dist/chat/ui/rows/Rows.js +1 -1
  61. package/dist/chat/ui/rows/WorkRows.d.ts +7 -4
  62. package/dist/chat/ui/rows/WorkRows.js +22 -17
  63. package/dist/chat/visual-viewport.d.ts +1 -1
  64. package/dist/chat/visual-viewport.js +9 -8
  65. package/dist/host.d.ts +1 -0
  66. package/dist/host.js +1 -0
  67. package/dist/locales/en/agent-chat.json +30 -7
  68. package/dist/locales/nl/agent-chat.json +30 -7
  69. package/dist/port-transport.js +4 -8
  70. package/dist/state/chats.d.ts +2 -1
  71. package/dist/state/chats.js +29 -15
  72. package/dist/state/usage.js +44 -49
  73. package/dist/storage.js +7 -7
  74. package/dist/theme.css +6 -14
  75. package/dist/usage/LimitsList.js +1 -2
  76. package/dist/usage/UsageBreakdown.js +1 -3
  77. package/dist/usage/UsageDialog.js +2 -4
  78. package/dist/usage/format.js +6 -6
  79. package/dist/usage/money.js +1 -5
  80. package/dist/usage/summary.js +2 -4
  81. package/package.json +24 -4
  82. package/src/agents/model-requirement.ts +36 -0
  83. package/src/agents/status-look.ts +2 -3
  84. package/src/chat/chat-client.ts +7 -3
  85. package/src/chat/drafts.ts +33 -21
  86. package/src/chat/logic/ansi.ts +5 -1
  87. package/src/chat/logic/fork.ts +8 -9
  88. package/src/chat/logic/limit.ts +17 -16
  89. package/src/chat/logic/picker-entries.ts +25 -0
  90. package/src/chat/logic/resume-compaction.ts +2 -11
  91. package/src/chat/logic/scrubber.ts +13 -17
  92. package/src/chat/logic/timeline.ts +39 -13
  93. package/src/chat/logic/tool-catalog.ts +7 -6
  94. package/src/chat/mentions.ts +5 -6
  95. package/src/chat/prompt-hold.ts +27 -0
  96. package/src/chat/quote.ts +20 -0
  97. package/src/chat/runtime-modes.ts +2 -4
  98. package/src/chat/stash.ts +32 -8
  99. package/src/chat/subagent-list.ts +19 -5
  100. package/src/chat/thumbnails.ts +3 -4
  101. package/src/chat/timeline-scroll.ts +58 -53
  102. package/src/chat/ui/BookmarkSubmenu.tsx +1 -5
  103. package/src/chat/ui/ChatActivity.tsx +35 -9
  104. package/src/chat/ui/CodeBlock.tsx +2 -3
  105. package/src/chat/ui/Composer.tsx +263 -138
  106. package/src/chat/ui/ComposerInput.tsx +18 -3
  107. package/src/chat/ui/Markdown.tsx +13 -17
  108. package/src/chat/ui/Pickers.tsx +56 -39
  109. package/src/chat/ui/PromptComposer.tsx +37 -8
  110. package/src/chat/ui/QuoteButton.tsx +5 -5
  111. package/src/chat/ui/RunSettings.tsx +9 -2
  112. package/src/chat/ui/Scrubber.tsx +2 -3
  113. package/src/chat/ui/SubagentControls.tsx +23 -22
  114. package/src/chat/ui/SubagentInfo.tsx +24 -42
  115. package/src/chat/ui/SubagentStopButton.tsx +2 -1
  116. package/src/chat/ui/SubagentTimeline.tsx +1 -2
  117. package/src/chat/ui/Timeline.tsx +44 -24
  118. package/src/chat/ui/TimelineMenu.tsx +3 -9
  119. package/src/chat/ui/UnifiedDiff.tsx +3 -5
  120. package/src/chat/ui/VisualFrame.tsx +3 -4
  121. package/src/chat/ui/composer/keys.ts +21 -11
  122. package/src/chat/ui/composer/menu-aria.ts +29 -0
  123. package/src/chat/ui/intelligent-ui/render-context.ts +2 -2
  124. package/src/chat/ui/quote-selection.ts +6 -4
  125. package/src/chat/ui/rehype-chips.ts +1 -1
  126. package/src/chat/ui/rehype-fade.ts +1 -1
  127. package/src/chat/ui/remark-html-as-text.ts +1 -1
  128. package/src/chat/ui/rows/MessageRows.tsx +47 -9
  129. package/src/chat/ui/rows/Rows.tsx +3 -1
  130. package/src/chat/ui/rows/WorkRows.tsx +101 -70
  131. package/src/chat/visual-viewport.ts +10 -8
  132. package/src/host.ts +4 -3
  133. package/src/locales/en/agent-chat.json +30 -7
  134. package/src/locales/nl/agent-chat.json +30 -7
  135. package/src/port-transport.ts +4 -8
  136. package/src/state/chats.ts +42 -16
  137. package/src/state/usage.ts +49 -60
  138. package/src/storage.ts +7 -7
  139. package/src/theme.css +6 -14
  140. package/src/usage/LimitsList.tsx +2 -4
  141. package/src/usage/UsageBreakdown.tsx +2 -5
  142. package/src/usage/UsageDialog.tsx +2 -4
  143. package/src/usage/format.ts +6 -6
  144. package/src/usage/money.ts +1 -5
  145. package/src/usage/summary.ts +2 -4
@@ -1,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';
@@ -98,8 +100,7 @@ const PROMPT_HISTORY = 50;
98
100
  // Commands the composer handles itself; the CLI's own ones are sent through as text.
99
101
  const LOCAL_COMMANDS = ['model', 'compact', 'clear'];
100
102
 
101
- /* Files and paths dropped on the card become attachments and chips there. CodeMirror would paste a
102
- file's text, or the drag's own text, at the drop point on top of that. */
103
+ /* The card turns dropped files and paths into attachments and chips; CodeMirror would also paste their text at the drop point. */
103
104
  const LEAVE_DROPS_TO_THE_CARD = EditorView.domEventHandlers({
104
105
  drop: (event) => event.dataTransfer !== null && (event.dataTransfer.types.includes('Files') || carriesMentions(event.dataTransfer.types))
105
106
  });
@@ -158,12 +159,79 @@ function hintedPlaceholder(lead: string, joiner: string, hints: ReadonlyArray<{
158
159
  return root;
159
160
  }
160
161
 
162
+ function sendLabelKey(busy: boolean, canSteer: boolean, delivery: 'queue' | 'steer'): string {
163
+ if (!busy) {
164
+ return 'composer.send';
165
+ }
166
+ if (!canSteer) {
167
+ return 'composer.queueButton';
168
+ }
169
+ return delivery === 'steer' ? 'composer.steerButton' : 'composer.queueWithSteerButton';
170
+ }
171
+
172
+ function sameQuery(left: MentionQuery | null, right: MentionQuery | null): boolean {
173
+ return left?.start === right?.start && left?.query === right?.query;
174
+ }
175
+
161
176
  function splitPath(path: string): { name: string; dir: string } {
162
177
  const slash = path.lastIndexOf('/');
163
178
  return slash < 0 ? { name: path, dir: '' } : { name: path.slice(slash + 1), dir: path.slice(0, slash) };
164
179
  }
165
180
 
166
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
+
167
235
  export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabled, readOnly = false, providerFixed, onSend, onRetarget }: ComposerProps) {
168
236
  const { t } = useTranslation('agent-chat');
169
237
  const sendDelivery = chatHost().useSendDelivery();
@@ -187,6 +255,7 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
187
255
  const { isApplePlatform, notify } = chatHost();
188
256
  const registerQuoteTaker = useContext(QuoteTakerContext);
189
257
  const inputRef = useRef<ComposerInputHandle>(null);
258
+ const hold = usePromptHold();
190
259
  // Taking a queued message back waits on the machine, and what was typed meanwhile is what it merges with.
191
260
  const draftRef = useRef(draft);
192
261
  const fileInputRef = useRef<HTMLInputElement>(null);
@@ -196,7 +265,6 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
196
265
  const providers = useProviders((s) => s.providers);
197
266
  const { approvals, questions } = usePendingRequests(chatId);
198
267
  const order = useChatRow(chatId, (row) => row?.order);
199
- // The structure, which a delta leaves alone. The prompts and the requests are items of their own.
200
268
  const items = useChatRow(chatId, (row) => row?.structure);
201
269
  const chats = chatHost().useReferableChats();
202
270
  const placeholderLead = chatHost().useComposerPlaceholder(scope.id, chatId);
@@ -208,7 +276,7 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
208
276
  const capabilities = provider?.capabilities;
209
277
  const compaction = capabilities?.compaction;
210
278
  const dismissedTurnId = useResumeCompactionDismissal(chatId);
211
- /* The cheap half of the offer, so a chat that can never make one keeps no timer. */
279
+ // The cheap half of the offer, so a chat that can never make one keeps no timer.
212
280
  const couldOfferCompaction = compaction !== undefined && compaction !== 'none' && info.usage.contextTokens >= RESUME_COMPACTION_TOKENS;
213
281
  const now = useNow(60_000, couldOfferCompaction);
214
282
  const compactionOffer = !couldOfferCompaction
@@ -226,15 +294,12 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
226
294
  const queue = info.queue ?? [];
227
295
  const text = draft.text;
228
296
  const quote = draft.quote;
297
+ const quoteSource = draft.quoteSource;
229
298
  // The CLI announces its session on the first message, so anything before that is still a blank chat.
230
299
  const started = info.agentSessionId !== null || info.usage.turns > 0;
231
- /*
232
- * A chat that has started keeps its provider (the CLI holds the thread), and one opened for a
233
- * named CLI was never going to change it. Either way the models stay switchable. The CLI takes
234
- * the new one on the restart the next send does anyway. With one provider in the list the
235
- * picker drops its group headers on its own, so it reads as that CLI's own catalog.
236
- */
237
300
  const accountChoice = useAccountChoice(info.provider, info.account);
301
+ // A chat that has started keeps its provider (the CLI holds the thread), and so does one opened for a
302
+ // named CLI; its models stay switchable, since the CLI takes a new one on the restart the next send does.
238
303
  const pickable =
239
304
  started || providerFixed
240
305
  ? providers.filter((entry) => entry.kind === info.provider)
@@ -262,8 +327,8 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
262
327
 
263
328
  useEffect(
264
329
  () =>
265
- registerQuoteTaker?.((selected) => {
266
- setDraft((current) => ({ ...current, quote: selected }));
330
+ registerQuoteTaker?.((selected, itemId) => {
331
+ setDraft((current) => ({ ...current, quote: selected, quoteSource: itemId }));
267
332
  inputRef.current?.focus();
268
333
  }),
269
334
  [registerQuoteTaker]
@@ -284,11 +349,11 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
284
349
  }
285
350
  let stale = false;
286
351
  const search = (): void => {
287
- const search = chatHost().searchFiles;
288
- if (search === null) {
352
+ const searchFiles = chatHost().searchFiles;
353
+ if (searchFiles === null) {
289
354
  return;
290
355
  }
291
- search(scope.id, info.cwd, mention.query, MENTION_RESULTS)
356
+ searchFiles(scope.id, info.cwd, mention.query, MENTION_RESULTS)
292
357
  .then((files) => {
293
358
  if (!stale) {
294
359
  setSearched({ query: mention.query, files });
@@ -375,6 +440,26 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
375
440
  [chatId, chats, draft.chats, mention]
376
441
  );
377
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
+ );
378
463
  const editorExtensions = useMemo(
379
464
  () => [chipDecorations({ mentions: draft.mentions, skills: draft.skills }), LEAVE_DROPS_TO_THE_CARD],
380
465
  [draft.mentions, draft.skills]
@@ -390,19 +475,32 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
390
475
  };
391
476
 
392
477
  const removeQuote = (): void => {
393
- setDraft((current) => ({ ...current, quote: '' }));
478
+ setDraft((current) => ({ ...current, quote: '', quoteSource: null }));
394
479
  inputRef.current?.focus();
395
480
  };
396
481
 
397
- const configure = (patch: { selection?: ModelSelection; runtimeMode?: RuntimeMode; account?: string }): void => {
482
+ const showError = (error: unknown): void => {
483
+ setNotice(error instanceof Error ? error.message : String(error));
484
+ };
485
+
486
+ /* One configuration at a time; a second pick while one is on its way is dropped. */
487
+ const runConfiguration = (work: () => Promise<void>): void => {
398
488
  if (configuring.current) {
399
489
  return;
400
490
  }
401
491
  configuring.current = true;
402
492
  setConfigurationPending(true);
403
- void actions
404
- .configure(chatId, patch)
405
- .then(() => {
493
+ void work()
494
+ .catch(showError)
495
+ .finally(() => {
496
+ configuring.current = false;
497
+ setConfigurationPending(false);
498
+ });
499
+ };
500
+
501
+ const configure = (patch: { selection?: ModelSelection; runtimeMode?: RuntimeMode; account?: string }): void => {
502
+ runConfiguration(() =>
503
+ actions.configure(chatId, patch).then(() => {
406
504
  if (patch.selection) {
407
505
  rememberChatSelection(info.provider, patch.selection);
408
506
  }
@@ -413,11 +511,7 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
413
511
  rememberChatAccount(scope.id, info.provider, patch.account);
414
512
  }
415
513
  })
416
- .catch((error: unknown) => setNotice(error instanceof Error ? error.message : String(error)))
417
- .finally(() => {
418
- configuring.current = false;
419
- setConfigurationPending(false);
420
- });
514
+ );
421
515
  };
422
516
 
423
517
  const chooseAccount = (account: string): void => configure({ account });
@@ -426,18 +520,7 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
426
520
  const chooseModel = (provider: AgentKind, slug: string): void => {
427
521
  const selection: ModelSelection = { model: slug, options: {} };
428
522
  if (provider !== info.provider) {
429
- if (configuring.current) {
430
- return;
431
- }
432
- configuring.current = true;
433
- setConfigurationPending(true);
434
- void onRetarget(provider, selection)
435
- .then(() => rememberChatSelection(provider, selection))
436
- .catch((error: unknown) => setNotice(error instanceof Error ? error.message : String(error)))
437
- .finally(() => {
438
- configuring.current = false;
439
- setConfigurationPending(false);
440
- });
523
+ runConfiguration(() => onRetarget(provider, selection).then(() => rememberChatSelection(provider, selection)));
441
524
  return;
442
525
  }
443
526
  configure({ selection });
@@ -445,14 +528,10 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
445
528
 
446
529
  const stop = (shiftKey: boolean): void => {
447
530
  if (composerStopOf(shiftKey) === 'turn') {
448
- void actions.stopTurn(chatId, false).catch((error: unknown) => setNotice(error instanceof Error ? error.message : String(error)));
531
+ void actions.stopTurn(chatId, false).catch(showError);
449
532
  return;
450
533
  }
451
- chatHost().confirm.stopSubagents(
452
- scope.id,
453
- chatId,
454
- () => void actions.stopTurn(chatId, true).catch((error: unknown) => setNotice(error instanceof Error ? error.message : String(error)))
455
- );
534
+ chatHost().confirm.stopSubagents(scope.id, chatId, () => void actions.stopTurn(chatId, true).catch(showError));
456
535
  };
457
536
 
458
537
  /* The host decides whether a turn is in the way; only its refusal asks the person first. */
@@ -493,18 +572,16 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
493
572
  }
494
573
  };
495
574
 
496
- const same = (a: MentionQuery | null, b: MentionQuery | null): boolean => a?.start === b?.start && a?.query === b?.query;
497
-
498
575
  /* Which picker the caret opens: `@` for a file, `$` for a skill, or neither. In code a sigil is just text. */
499
576
  const trackTriggers = (value: string, selection: TextRange, state: EditorState): void => {
500
577
  const empty = selection.from === selection.to;
501
578
  const tree = empty ? (ensureSyntaxTree(state, selection.from, 50) ?? syntaxTree(state)) : null;
502
579
  const caret = tree !== null && !inCode(tree, value, selection.from) ? selection.from : null;
503
580
  const skill = caret === null ? null : findSkillQuery(value, caret);
504
- setSkillQuery((current) => (same(current, skill) ? current : skill));
581
+ setSkillQuery((current) => (sameQuery(current, skill) ? current : skill));
505
582
  // A CLI that does not expand `@path` gets the text as it is, so the picker stays out of the way.
506
583
  const next = caret === null || capabilities?.mentions === false ? null : findMentionQuery(value, caret);
507
- setMention((current) => (same(current, next) ? current : next));
584
+ setMention((current) => (sameQuery(current, next) ? current : next));
508
585
  };
509
586
 
510
587
  // The inserted text reaches the editor with the render it causes, so the caret waits a frame for it.
@@ -558,8 +635,7 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
558
635
  moveCaret(result.caret);
559
636
  };
560
637
 
561
- /* Paths dragged in from a list of files. There is no mention query to insert into, so they land
562
- at the end of the prompt and join the draft's mentions, which is what `chat.send` carries. */
638
+ /* Paths dragged in from a list of files. With no mention query to insert into, they land at the end of the prompt. */
563
639
  const addMentions = (paths: string[]): void => {
564
640
  const fresh = paths.filter((path) => path !== '');
565
641
  if (fresh.length === 0 || capabilities?.mentions === false) {
@@ -619,7 +695,8 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
619
695
  setNotice(`${rejected.name}: ${rejected.reason}`);
620
696
  return;
621
697
  }
622
- const chosen = commands[menuIndex];
698
+ hold.release('send');
699
+ const chosen = commands[activeIndex];
623
700
  if (commandQuery !== null && chosen?.local && runCommand(chosen.name)) {
624
701
  clearDraft();
625
702
  return;
@@ -640,6 +717,7 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
640
717
  mentions: presentMentions(trimmed, draft.mentions),
641
718
  skills: presentSkills(trimmed, draft.skills),
642
719
  chats: draft.chats,
720
+ quoteSource: quote !== '' && quoteSource !== null ? { itemId: quoteSource } : undefined,
643
721
  attachments: draft.attachments
644
722
  });
645
723
  clearDraft();
@@ -648,9 +726,20 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
648
726
  setHistoryIndex(null);
649
727
  };
650
728
 
651
- /* 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
+ */
652
733
  const restoreStashed = (prompt: StashedPrompt): void => {
653
- 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
+ }));
654
743
  setHistoryIndex(null);
655
744
  inputRef.current?.focus();
656
745
  };
@@ -683,7 +772,7 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
683
772
  failed(e);
684
773
  return;
685
774
  }
686
- const taken: Omit<ChatDraft, 'quote'> = {
775
+ const taken: Omit<ChatDraft, 'quote' | 'quoteSource'> = {
687
776
  text: message.text,
688
777
  mentions: message.mentions ?? [],
689
778
  skills: message.skills ?? [],
@@ -727,7 +816,7 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
727
816
  }
728
817
  return;
729
818
  }
730
- if (stashDraft(draft)) {
819
+ if (stashDraft(draft, chatId)) {
731
820
  clearDraft();
732
821
  setMention(null);
733
822
  setSkillQuery(null);
@@ -752,6 +841,19 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
752
841
  return true;
753
842
  };
754
843
 
844
+ /* The arrows walk an open picker round; true when the key was one of them. */
845
+ const moveInMenu = (key: string, count: number): boolean => {
846
+ if (key === 'ArrowDown') {
847
+ setMenuIndex((i) => (i + 1) % count);
848
+ return true;
849
+ }
850
+ if (key === 'ArrowUp') {
851
+ setMenuIndex((i) => (i - 1 + count) % count);
852
+ return true;
853
+ }
854
+ return false;
855
+ };
856
+
755
857
  /* Runs before the editor's own keymap; true means the key is handled and its default prevented. */
756
858
  const onKeyDown = (e: KeyboardEvent, view: EditorView): boolean => {
757
859
  // Shift with the paste shortcut pastes a large text inline after all; any other key forgets it.
@@ -766,8 +868,7 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
766
868
  setText('', [], []);
767
869
  return true;
768
870
  }
769
- // The composer keeps the keyboard while you type, but the shortcuts that move between views,
770
- // panels and the palette stay the app's, untouched: Mod+Shift+Enter would otherwise send as well.
871
+ // The app's own shortcuts stay the app's while you type: Mod+Shift+Enter would otherwise send as well.
771
872
  if (chatHost().isAppShortcut(e)) {
772
873
  return false;
773
874
  }
@@ -785,16 +886,11 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
785
886
  return true;
786
887
  }
787
888
  if (commandMenuOpen) {
788
- if (e.key === 'ArrowDown') {
789
- setMenuIndex((i) => (i + 1) % commands.length);
790
- return true;
791
- }
792
- if (e.key === 'ArrowUp') {
793
- setMenuIndex((i) => (i - 1 + commands.length) % commands.length);
889
+ if (moveInMenu(e.key, commands.length)) {
794
890
  return true;
795
891
  }
796
892
  if (e.key === 'Tab') {
797
- const chosen = commands[menuIndex]!;
893
+ const chosen = commands[activeIndex]!;
798
894
  if (chosen.skill) {
799
895
  chooseSkill(chosen.name);
800
896
  } else {
@@ -804,30 +900,20 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
804
900
  }
805
901
  }
806
902
  if (skillMenuOpen) {
807
- if (e.key === 'ArrowDown') {
808
- setMenuIndex((i) => (i + 1) % skillMatches.length);
809
- return true;
810
- }
811
- if (e.key === 'ArrowUp') {
812
- setMenuIndex((i) => (i - 1 + skillMatches.length) % skillMatches.length);
903
+ if (moveInMenu(e.key, skillMatches.length)) {
813
904
  return true;
814
905
  }
815
906
  if (e.key === 'Tab' || e.key === 'Enter') {
816
- chooseSkill((skillMatches[menuIndex] ?? skillMatches[0]!).name);
907
+ chooseSkill((skillMatches[activeIndex] ?? skillMatches[0]!).name);
817
908
  return true;
818
909
  }
819
910
  }
820
911
  if (mentionMenuOpen && mentionCount > 0) {
821
- if (e.key === 'ArrowDown') {
822
- setMenuIndex((i) => (i + 1) % mentionCount);
823
- return true;
824
- }
825
- if (e.key === 'ArrowUp') {
826
- setMenuIndex((i) => (i - 1 + mentionCount) % mentionCount);
912
+ if (moveInMenu(e.key, mentionCount)) {
827
913
  return true;
828
914
  }
829
915
  if (e.key === 'Tab' || e.key === 'Enter') {
830
- chooseMentionAt(menuIndex);
916
+ chooseMentionAt(activeIndex);
831
917
  return true;
832
918
  }
833
919
  }
@@ -946,9 +1032,8 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
946
1032
 
947
1033
  return (
948
1034
  <div className="chat-composer-content pointer-events-none relative z-10 w-full">
949
- {/* Only while there is something below the fold. It sits over the composer rather than
950
- in the thread, because the composer is the one thing whose height it always clears. */}
951
- {/* A read-only composer sits under something that stands in the thread's place, whose end is not this one. */}
1035
+ {/* Over the composer rather than in the thread, since the composer's height is the one it always clears.
1036
+ A read-only composer sits under something that stands in the thread's place, whose end is not this one. */}
952
1037
  {!atEnd && !readOnly && (
953
1038
  <div className="absolute inset-x-0 bottom-full mb-2 flex justify-center">
954
1039
  <Tooltip label={t('composer.jumpToEnd')} name>
@@ -969,8 +1054,7 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
969
1054
  ? 'border-accent bg-[color-mix(in_srgb,var(--accent-soft)_60%,var(--surface-raised))]'
970
1055
  : 'border-border bg-[color-mix(in_srgb,var(--surface-raised)_92%,transparent)]'
971
1056
  )}
972
- /* The whole card takes a dropped file as a mention, so a layout around it that takes drops
973
- of its own leaves it alone. */
1057
+ /* The whole card takes a dropped file, so a layout around it that takes drops of its own leaves it alone. */
974
1058
  data-takes-drop="all"
975
1059
  onDragOver={(e) => {
976
1060
  if (writable && (e.dataTransfer.types.includes('Files') || carriesMentions(e.dataTransfer.types))) {
@@ -1002,6 +1086,7 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
1002
1086
  hasDraft={!isEmptyDraft(draft)}
1003
1087
  denyReason={capabilities?.denyReason === true}
1004
1088
  onAllAnswered={() => inputRef.current?.focus()}
1089
+ hold={hold}
1005
1090
  >
1006
1091
  <LimitDock chatId={chatId} info={info} />
1007
1092
  {compactionOffer && (
@@ -1012,11 +1097,15 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
1012
1097
  />
1013
1098
  )}
1014
1099
  {commandMenuOpen && (
1015
- <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">
1016
1101
  {commands.map((command, index) => (
1017
1102
  <button
1018
1103
  key={command.name}
1019
- 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}
1020
1109
  className="cursor-row flex w-full items-center gap-2 rounded-md px-2 py-1 text-left text-xs text-text-muted"
1021
1110
  onMouseEnter={() => setMenuIndex(index)}
1022
1111
  onMouseDown={(e) => e.preventDefault()}
@@ -1042,11 +1131,15 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
1042
1131
  </div>
1043
1132
  )}
1044
1133
  {skillMenuOpen && (
1045
- <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">
1046
1135
  {skillMatches.map((skill, index) => (
1047
1136
  <button
1048
1137
  key={skill.name}
1049
- 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}
1050
1143
  className="cursor-row flex w-full items-start gap-2 rounded-md px-2 py-1 text-left text-xs text-text-muted"
1051
1144
  onMouseEnter={() => setMenuIndex(index)}
1052
1145
  onMouseDown={(e) => e.preventDefault()}
@@ -1063,45 +1156,70 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
1063
1156
  )}
1064
1157
  {mentionMenuOpen && (
1065
1158
  <div className="border-b border-border px-1.5 py-1.5">
1066
- {chatMatches.length > 0 && <Menu.Label>{t('composer.mentions.chats')}</Menu.Label>}
1067
- {chatMatches.map((chat, index) => (
1068
- <button
1069
- key={chat.id}
1070
- data-active={index === menuIndex}
1071
- className="cursor-row flex w-full items-center gap-2 rounded-md px-2 py-1 text-left text-xs text-text-muted"
1072
- onMouseEnter={() => setMenuIndex(index)}
1073
- onMouseDown={(e) => e.preventDefault()}
1074
- onClick={() => chooseChat(chat.id)}
1075
- >
1076
- <Icon icon={MessageSquare} size={14} className="shrink-0 text-text-faint" />
1077
- <span className="truncate text-text">{chat.title || t('chatReference.untitled')}</span>
1078
- </button>
1079
- ))}
1080
- {files.length > 0 && (mention.query === '' || chatMatches.length > 0) && (
1081
- <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>
1082
1217
  )}
1083
1218
  {mentionCount === 0 && (
1084
1219
  <div className="px-2 py-1 text-xs text-text-faint">
1085
1220
  {mention.query ? t('composer.mentions.noMatch') : t('composer.mentions.empty')}
1086
1221
  </div>
1087
1222
  )}
1088
- {files.map((path, index) => {
1089
- const { name, dir } = splitPath(path);
1090
- return (
1091
- <button
1092
- key={path}
1093
- data-active={chatMatches.length + index === menuIndex}
1094
- className="cursor-row flex w-full items-center gap-2 rounded-md px-2 py-1 text-left text-xs text-text-muted"
1095
- onMouseEnter={() => setMenuIndex(chatMatches.length + index)}
1096
- onMouseDown={(e) => e.preventDefault()}
1097
- onClick={() => chooseMention(path)}
1098
- >
1099
- <FileIcon path={path} size={14} />
1100
- <span className="truncate font-mono text-text">{name}</span>
1101
- {dir && <span className="min-w-0 truncate text-text-faint">{dir}</span>}
1102
- </button>
1103
- );
1104
- })}
1105
1223
  </div>
1106
1224
  )}
1107
1225
  {queue.length > 0 && (
@@ -1158,7 +1276,24 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
1158
1276
  )}
1159
1277
  {quote !== '' && (
1160
1278
  <div className="flex items-center gap-2 px-5 pt-4 @max-md/composer:px-3.5 @max-md/composer:pt-3">
1161
- <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
+ )}
1162
1297
  <IconButton icon={X} size="2xs" label={t('composer.quote.remove')} onClick={removeQuote} />
1163
1298
  </div>
1164
1299
  )}
@@ -1204,7 +1339,9 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
1204
1339
  disabled={!writable}
1205
1340
  tabbable={focused}
1206
1341
  extensions={editorExtensions}
1342
+ aria={inputAria}
1207
1343
  onChange={(value, selection, state) => {
1344
+ hold.noteInput();
1208
1345
  setText(value);
1209
1346
  setMenuIndex(0);
1210
1347
  trackTriggers(value, selection, state);
@@ -1216,7 +1353,9 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
1216
1353
  onBlur={() => {
1217
1354
  setMention(null);
1218
1355
  setSkillQuery(null);
1356
+ hold.release('blur');
1219
1357
  }}
1358
+ onComposing={hold.setComposing}
1220
1359
  onKeyDown={onKeyDown}
1221
1360
  onPaste={onPaste}
1222
1361
  />
@@ -1300,21 +1439,7 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
1300
1439
  </Tooltip>
1301
1440
  )}
1302
1441
  {(!busy || !isEmptyDraft(draft)) && (
1303
- <Tooltip
1304
- label={
1305
- busy
1306
- ? t(
1307
- canSteer
1308
- ? sendDelivery === 'steer'
1309
- ? 'composer.steerButton'
1310
- : 'composer.queueWithSteerButton'
1311
- : 'composer.queueButton'
1312
- )
1313
- : t('composer.send')
1314
- }
1315
- kbd={KEY_SHORTCUTS.modEnter}
1316
- name
1317
- >
1442
+ <Tooltip label={t(sendLabelKey(busy, canSteer, sendDelivery))} kbd={KEY_SHORTCUTS.modEnter} name>
1318
1443
  <button
1319
1444
  className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-accent text-accent-text hover:brightness-90 disabled:opacity-50 disabled:hover:brightness-100"
1320
1445
  disabled={isEmptyDraft(draft) || !writable || guard.tooLong}