@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,6 +1,9 @@
1
1
  // A page is a screen minus a little, so the line you were reading is still on it after the jump.
2
2
  const PAGE_OVERLAP = 0.9;
3
3
 
4
+ // How long a jump asked before its thread registered stays up for that thread.
5
+ const PENDING_JUMP_TTL_MS = 10_000;
6
+
4
7
  type Scroller = {
5
8
  element: HTMLElement;
6
9
  follow: (enabled: boolean) => void;
@@ -103,14 +106,17 @@ export function stepTimelineMessage(key: string, direction: -1 | 1): boolean {
103
106
  */
104
107
  function waitingJumps<T>(): { register(key: string, jump: (target: T) => void): () => void; jump(key: string, target: T): boolean } {
105
108
  const jumpers = new Map<string, (target: T) => void>();
106
- const waiting = new Map<string, T>();
109
+ const waiting = new Map<string, { target: T; at: number }>();
107
110
  return {
108
111
  register(key, jump) {
109
112
  jumpers.set(key, jump);
110
- if (waiting.has(key)) {
111
- const target = waiting.get(key)!;
113
+ const pending = waiting.get(key);
114
+ if (pending !== undefined) {
112
115
  waiting.delete(key);
113
- jump(target);
116
+ // A jump nobody picked up for long is not what the person wants any more.
117
+ if (Date.now() - pending.at <= PENDING_JUMP_TTL_MS) {
118
+ jump(pending.target);
119
+ }
114
120
  }
115
121
  return () => {
116
122
  if (jumpers.get(key) === jump) {
@@ -121,7 +127,7 @@ function waitingJumps<T>(): { register(key: string, jump: (target: T) => void):
121
127
  jump(key, target) {
122
128
  const jump = jumpers.get(key);
123
129
  if (jump === undefined) {
124
- waiting.set(key, target);
130
+ waiting.set(key, { target, at: Date.now() });
125
131
  return false;
126
132
  }
127
133
  jump(target);
@@ -130,15 +136,23 @@ function waitingJumps<T>(): { register(key: string, jump: (target: T) => void):
130
136
  };
131
137
  }
132
138
 
133
- const itemJumps = waitingJumps<string>();
139
+ /* What a jump to an item may ask beyond scrolling to it. */
140
+ export interface ItemJumpOptions {
141
+ /* Lights the row up once it is in view. */
142
+ flash?: boolean;
143
+ /* Runs when the item is nowhere in the thread, after every earlier page has been tried. */
144
+ onMissing?(): void;
145
+ }
146
+
147
+ const itemJumps = waitingJumps<{ itemId: string; options: ItemJumpOptions }>();
134
148
 
135
- export function registerItemJumper(key: string, jump: (itemId: string) => void): () => void {
136
- return itemJumps.register(key, jump);
149
+ export function registerItemJumper(key: string, jump: (itemId: string, options: ItemJumpOptions) => void): () => void {
150
+ return itemJumps.register(key, ({ itemId, options }) => jump(itemId, options));
137
151
  }
138
152
 
139
153
  /* False when that chat has no thread on screen; the jump then waits for the next one that registers. */
140
- export function jumpToTimelineItem(key: string, itemId: string): boolean {
141
- return itemJumps.jump(key, itemId);
154
+ export function jumpToTimelineItem(key: string, itemId: string, options: ItemJumpOptions = {}): boolean {
155
+ return itemJumps.jump(key, { itemId, options });
142
156
  }
143
157
 
144
158
  /* Which part of a turn a jump lands on: the message that opened it, or the files it changed. */
@@ -1,16 +1,16 @@
1
1
  import { useMemo, type ReactNode } from 'react';
2
2
  import clsx from 'clsx';
3
3
  import { useTranslation } from 'react-i18next';
4
- import { Activity, Square, SquareTerminal } from 'lucide-react';
4
+ import { Activity, MessageCircleQuestionMark, Square, SquareTerminal } from 'lucide-react';
5
5
  import type { ChatBackgroundTask, ChatSubagentItem } from '@adecore/agent-contracts';
6
6
  import { backgroundCounts } from '../activity';
7
- import { badgeCountOf, entryTimeOf, flyoutSubagents, statusWordOf, subagentTitle, summaryWordOf, taskIdOf } from '../subagent-list';
7
+ import { badgeCountOf, entryTimeOf, flyoutSubagents, progressOf, statusWordOf, subagentTitle, summaryWordOf, taskIdOf, taskRowStateOf } from '../subagent-list';
8
8
  import { useSubagentSupport } from '../subagent-support';
9
9
  import { canOpenSubagent, crumbOf, openFromMain, useSubagentTrail } from '../subagent-view';
10
10
  import { SubagentStopButton } from './SubagentStopButton';
11
11
  import { formatElapsedShort } from '@adecore/ui/format';
12
12
  import { Icon, IconButton, Popover, Surface, useNow } from '@adecore/ui';
13
- import { useChatRow } from '../../state/chats';
13
+ import { useChatRow, useChats } from '../../state/chats';
14
14
  import { chatHost } from '../../host';
15
15
  import { useChatScope } from '../../scope';
16
16
  import { useChatActions } from '../actions';
@@ -28,8 +28,20 @@ export function ChatActivity({ chatId }: { chatId: string }) {
28
28
  const order = useChatRow(chatId, (row) => row?.order) ?? NO_ITEMS;
29
29
  const structure = useChatRow(chatId, (row) => row?.structure);
30
30
  const subagents = useMemo(() => (structure === undefined ? [] : flyoutSubagents(order, structure)), [order, structure]);
31
- const { id } = useChatScope();
31
+ const { id, keyOf } = useChatScope();
32
32
  const tasks = chatHost().tasks.useTasks(id);
33
+ const statuses = useChats((s) => s.statusByKey);
34
+ const progress = useMemo(
35
+ () =>
36
+ progressOf(
37
+ subagents.map((item) => {
38
+ const taskId = taskIdOf(item);
39
+ const child = item.childId === undefined ? undefined : statuses[keyOf(item.childId)]?.info;
40
+ return taskRowStateOf(item, taskId === null ? null : (tasks?.[taskId] ?? null), child);
41
+ })
42
+ ),
43
+ [subagents, tasks, statuses, keyOf]
44
+ );
33
45
  const words = useMemo(
34
46
  () =>
35
47
  subagents.map((item) => {
@@ -44,13 +56,25 @@ export function ChatActivity({ chatId }: { chatId: string }) {
44
56
  }
45
57
  const { shells, monitors } = backgroundCounts(background);
46
58
  const Slot = chatHost().SubagentSlot;
59
+ const counting = progress.active > 0 && progress.total > 1;
60
+ const label = counting
61
+ ? [t('activity.progress', { done: progress.done, total: progress.total }), progress.waiting > 0 && t('activity.needsYou', { count: progress.waiting })]
62
+ .filter(Boolean)
63
+ .join(', ')
64
+ : t('activity.subagents', { count: badgeCountOf(words) });
47
65
  return (
48
66
  <div className="mb-3 flex flex-wrap items-center gap-2">
49
67
  {subagents.length > 0 &&
50
68
  (Slot === null ? (
51
69
  <Flyout
52
- icon={<StatusIcon word={summaryWordOf(words)} />}
53
- label={t('activity.subagents', { count: badgeCountOf(words) })}
70
+ icon={
71
+ progress.waiting > 0 ? (
72
+ <Icon icon={MessageCircleQuestionMark} size={14} className="shrink-0 text-status-needs-you" />
73
+ ) : (
74
+ <StatusIcon word={summaryWordOf(words)} />
75
+ )
76
+ }
77
+ label={label}
54
78
  heading={t('activity.subagents', { count: subagents.length })}
55
79
  >
56
80
  <SubagentRows chatId={chatId} items={subagents} />
@@ -83,7 +107,7 @@ function Flyout({ icon, label, heading = label, children }: { icon: ReactNode; l
83
107
  <Popover.Root>
84
108
  <Surface
85
109
  render={<Popover.Trigger />}
86
- className="pointer-events-auto inline-flex h-7.5 items-center gap-1.5 rounded-full px-3 text-xs text-text-muted hover:text-text"
110
+ className="pointer-events-auto inline-flex h-7.5 items-center gap-1.5 rounded-full px-3 text-xs text-text-muted tabular-nums hover:text-text"
87
111
  >
88
112
  {icon}
89
113
  {label}
@@ -148,11 +172,13 @@ function SubagentRows({ chatId, items }: { chatId: string; items: readonly ChatS
148
172
 
149
173
  function SubagentEntry({ chatId, item, now }: { chatId: string; item: ChatSubagentItem; now: number }) {
150
174
  const { t } = useTranslation('agent-chat');
151
- const { id } = useChatScope();
175
+ const { id, keyOf } = useChatScope();
152
176
  const refused = useSubagentSupport((s) => s.unsupported[id] === true);
153
177
  const task = chatHost().tasks.useTask(id, taskIdOf(item));
154
178
  const { show } = useSubagentTrail(chatId);
179
+ const child = useChats((s) => (item.childId === undefined ? undefined : s.statusByKey[keyOf(item.childId)]?.info));
155
180
  const word = statusWordOf(item, task);
181
+ const state = taskRowStateOf(item, task, child);
156
182
  return (
157
183
  <ActivityRow
158
184
  icon={<StatusIcon word={word} />}
@@ -160,7 +186,7 @@ function SubagentEntry({ chatId, item, now }: { chatId: string; item: ChatSubage
160
186
  <>
161
187
  {subagentTitle(item)}
162
188
  {/* The icon says the state to the eye; a screen reader hears it with the title. */}
163
- <span className="sr-only">, {t(`common.status.${word}`)}</span>
189
+ <span className="sr-only">, {state === 'waiting' ? t('rows.task.waiting') : t(`common.status.${word}`)}</span>
164
190
  </>
165
191
  }
166
192
  time={entryTimeOf(item, task, now)}
@@ -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;