@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,5 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useContext, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
2
+ import { useContext, useEffect, useId, useMemo, useRef, useState, useSyncExternalStore } from 'react';
3
3
  import { useTranslation } from 'react-i18next';
4
4
  import { indentLess, indentMore, insertNewline } from '@codemirror/commands';
5
5
  import { ensureSyntaxTree, syntaxTree } from '@codemirror/language';
@@ -22,12 +22,14 @@ import { PROMPT_MAX_CHARS, pasteBecomesAttachment, pastedTextName, promptGuard,
22
22
  import { withQuote } from '../quote.js';
23
23
  import { rememberChatAccount, rememberChatPreferences, rememberChatSelection } from '../preferences.js';
24
24
  import { STASH_SHORTCUT, stashDraft, useStash } from '../stash.js';
25
- import { pageTimeline, scrollTimelineToEnd, subscribeTimelineEnd, timelineAtEnd } from '../timeline-scroll.js';
25
+ import { jumpToTimelineItem, pageTimeline, scrollTimelineToEnd, subscribeTimelineEnd, timelineAtEnd } from '../timeline-scroll.js';
26
26
  import { ChatActivity } from './ChatActivity.js';
27
27
  import { ChatReferenceChip } from './ChatReferenceChip.js';
28
28
  import { chipDecorations } from './composer/chips.js';
29
+ import { clampMenuIndex, composerMenuAria, optionIdOf } from './composer/menu-aria.js';
29
30
  import { enterAction, inCode, inFenceBody, inOpenFence, listItemAt, recallDirection, tabSpaces } from './composer/keys.js';
30
31
  import { ComposerInput } from './ComposerInput.js';
32
+ import { holdLiftsIn } from '../prompt-hold.js';
31
33
  import { PromptComposer } from './PromptComposer.js';
32
34
  import { QuoteTakerContext } from './quote-selection.js';
33
35
  import { LimitDock } from './LimitState.js';
@@ -49,8 +51,7 @@ const NOTICE_MS = 4000;
49
51
  const PROMPT_HISTORY = 50;
50
52
  // Commands the composer handles itself; the CLI's own ones are sent through as text.
51
53
  const LOCAL_COMMANDS = ['model', 'compact', 'clear'];
52
- /* Files and paths dropped on the card become attachments and chips there. CodeMirror would paste a
53
- file's text, or the drag's own text, at the drop point on top of that. */
54
+ /* The card turns dropped files and paths into attachments and chips; CodeMirror would also paste their text at the drop point. */
54
55
  const LEAVE_DROPS_TO_THE_CARD = EditorView.domEventHandlers({
55
56
  drop: (event) => event.dataTransfer !== null && (event.dataTransfer.types.includes('Files') || carriesMentions(event.dataTransfer.types))
56
57
  });
@@ -88,11 +89,69 @@ function hintedPlaceholder(lead, joiner, hints) {
88
89
  root.append(lead, extra);
89
90
  return root;
90
91
  }
92
+ function sendLabelKey(busy, canSteer, delivery) {
93
+ if (!busy) {
94
+ return 'composer.send';
95
+ }
96
+ if (!canSteer) {
97
+ return 'composer.queueButton';
98
+ }
99
+ return delivery === 'steer' ? 'composer.steerButton' : 'composer.queueWithSteerButton';
100
+ }
101
+ function sameQuery(left, right) {
102
+ return left?.start === right?.start && left?.query === right?.query;
103
+ }
91
104
  function splitPath(path) {
92
105
  const slash = path.lastIndexOf('/');
93
106
  return slash < 0 ? { name: path, dir: '' } : { name: path.slice(slash + 1), dir: path.slice(0, slash) };
94
107
  }
95
108
  // Keep the editor mounted while a pending request takes over, so its selection and draft survive.
109
+ /*
110
+ * Keeps a waiting prompt card behind the draft while the person types. Typing, pasting and dictating
111
+ * all change the draft, so `noteInput` is called from its change handler; the hold lifts after a pause.
112
+ */
113
+ function usePromptHold() {
114
+ const typedAt = useRef(null);
115
+ const composing = useRef(false);
116
+ const timer = useRef(null);
117
+ const [state, setState] = useState({ held: false, releasedBy: null });
118
+ const clearTimer = () => {
119
+ if (timer.current !== null) {
120
+ clearTimeout(timer.current);
121
+ timer.current = null;
122
+ }
123
+ };
124
+ const release = (reason) => {
125
+ clearTimer();
126
+ typedAt.current = null;
127
+ composing.current = false;
128
+ setState((current) => (current.held ? { held: false, releasedBy: reason } : current));
129
+ };
130
+ const schedule = () => {
131
+ clearTimer();
132
+ const wait = holdLiftsIn({ typedAt: typedAt.current, composing: composing.current }, Date.now());
133
+ if (wait !== null) {
134
+ timer.current = setTimeout(() => release('pause'), wait);
135
+ }
136
+ };
137
+ const noteInput = () => {
138
+ typedAt.current = Date.now();
139
+ setState({ held: true, releasedBy: null });
140
+ schedule();
141
+ };
142
+ const setComposing = (next) => {
143
+ composing.current = next;
144
+ if (next) {
145
+ setState({ held: true, releasedBy: null });
146
+ }
147
+ else {
148
+ typedAt.current = Date.now();
149
+ }
150
+ schedule();
151
+ };
152
+ useEffect(() => clearTimer, []);
153
+ return { held: state.held, releasedBy: state.releasedBy, release, noteInput, setComposing };
154
+ }
96
155
  export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabled, readOnly = false, providerFixed, onSend, onRetarget }) {
97
156
  const { t } = useTranslation('agent-chat');
98
157
  const sendDelivery = chatHost().useSendDelivery();
@@ -116,6 +175,7 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
116
175
  const { isApplePlatform, notify } = chatHost();
117
176
  const registerQuoteTaker = useContext(QuoteTakerContext);
118
177
  const inputRef = useRef(null);
178
+ const hold = usePromptHold();
119
179
  // Taking a queued message back waits on the machine, and what was typed meanwhile is what it merges with.
120
180
  const draftRef = useRef(draft);
121
181
  const fileInputRef = useRef(null);
@@ -125,7 +185,6 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
125
185
  const providers = useProviders((s) => s.providers);
126
186
  const { approvals, questions } = usePendingRequests(chatId);
127
187
  const order = useChatRow(chatId, (row) => row?.order);
128
- // The structure, which a delta leaves alone. The prompts and the requests are items of their own.
129
188
  const items = useChatRow(chatId, (row) => row?.structure);
130
189
  const chats = chatHost().useReferableChats();
131
190
  const placeholderLead = chatHost().useComposerPlaceholder(scope.id, chatId);
@@ -136,7 +195,7 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
136
195
  const capabilities = provider?.capabilities;
137
196
  const compaction = capabilities?.compaction;
138
197
  const dismissedTurnId = useResumeCompactionDismissal(chatId);
139
- /* The cheap half of the offer, so a chat that can never make one keeps no timer. */
198
+ // The cheap half of the offer, so a chat that can never make one keeps no timer.
140
199
  const couldOfferCompaction = compaction !== undefined && compaction !== 'none' && info.usage.contextTokens >= RESUME_COMPACTION_TOKENS;
141
200
  const now = useNow(60_000, couldOfferCompaction);
142
201
  const compactionOffer = !couldOfferCompaction
@@ -154,15 +213,12 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
154
213
  const queue = info.queue ?? [];
155
214
  const text = draft.text;
156
215
  const quote = draft.quote;
216
+ const quoteSource = draft.quoteSource;
157
217
  // The CLI announces its session on the first message, so anything before that is still a blank chat.
158
218
  const started = info.agentSessionId !== null || info.usage.turns > 0;
159
- /*
160
- * A chat that has started keeps its provider (the CLI holds the thread), and one opened for a
161
- * named CLI was never going to change it. Either way the models stay switchable. The CLI takes
162
- * the new one on the restart the next send does anyway. With one provider in the list the
163
- * picker drops its group headers on its own, so it reads as that CLI's own catalog.
164
- */
165
219
  const accountChoice = useAccountChoice(info.provider, info.account);
220
+ // A chat that has started keeps its provider (the CLI holds the thread), and so does one opened for a
221
+ // named CLI; its models stay switchable, since the CLI takes a new one on the restart the next send does.
166
222
  const pickable = started || providerFixed
167
223
  ? providers.filter((entry) => entry.kind === info.provider)
168
224
  : providers.filter((entry) => entry.installed && entry.capabilities.chat);
@@ -179,8 +235,8 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
179
235
  setDraft((current) => ({ ...current, text: joinDraftText(current.text, offered) }));
180
236
  inputRef.current?.focus();
181
237
  }), [chatId]);
182
- useEffect(() => registerQuoteTaker?.((selected) => {
183
- setDraft((current) => ({ ...current, quote: selected }));
238
+ useEffect(() => registerQuoteTaker?.((selected, itemId) => {
239
+ setDraft((current) => ({ ...current, quote: selected, quoteSource: itemId }));
184
240
  inputRef.current?.focus();
185
241
  }), [registerQuoteTaker]);
186
242
  useEffect(() => {
@@ -197,11 +253,11 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
197
253
  }
198
254
  let stale = false;
199
255
  const search = () => {
200
- const search = chatHost().searchFiles;
201
- if (search === null) {
256
+ const searchFiles = chatHost().searchFiles;
257
+ if (searchFiles === null) {
202
258
  return;
203
259
  }
204
- search(scope.id, info.cwd, mention.query, MENTION_RESULTS)
260
+ searchFiles(scope.id, info.cwd, mention.query, MENTION_RESULTS)
205
261
  .then((files) => {
206
262
  if (!stale) {
207
263
  setSearched({ query: mention.query, files });
@@ -280,6 +336,22 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
280
336
  const files = mention === null ? [] : searched.files;
281
337
  const chatMatches = useMemo(() => (mention === null ? [] : chatSuggestions(chats, mention.query, [chatId, ...draft.chats], CHAT_RESULTS)), [chatId, chats, draft.chats, mention]);
282
338
  const mentionCount = chatMatches.length + files.length;
339
+ // One menu is open at a time, so they share an id.
340
+ const listId = useId();
341
+ const mentionsLabelId = `${listId}-chats`;
342
+ const filesLabelId = `${listId}-files`;
343
+ const filesLabelShown = mention !== null && (mention.query === '' || chatMatches.length > 0);
344
+ const menuOpen = commandMenuOpen || skillMenuOpen || mentionMenuOpen;
345
+ const menuCount = commandMenuOpen ? commands.length : skillMenuOpen ? skillMatches.length : mentionCount;
346
+ // The list can shrink under a stale index; every reader takes the one that still points at an option.
347
+ const activeIndex = clampMenuIndex(menuIndex, menuCount);
348
+ const inputAria = useMemo(() => composerMenuAria({
349
+ label: t('composer.inputLabel'),
350
+ listId,
351
+ open: menuOpen,
352
+ activeIndex,
353
+ count: menuCount
354
+ }), [t, listId, menuOpen, activeIndex, menuCount]);
283
355
  const editorExtensions = useMemo(() => [chipDecorations({ mentions: draft.mentions, skills: draft.skills }), LEAVE_DROPS_TO_THE_CARD], [draft.mentions, draft.skills]);
284
356
  const guard = promptGuard(withQuote(quote, text));
285
357
  const setText = (next, mentions = draft.mentions, chosen = draft.skills) => {
@@ -289,18 +361,28 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
289
361
  setDraft(EMPTY_DRAFT);
290
362
  };
291
363
  const removeQuote = () => {
292
- setDraft((current) => ({ ...current, quote: '' }));
364
+ setDraft((current) => ({ ...current, quote: '', quoteSource: null }));
293
365
  inputRef.current?.focus();
294
366
  };
295
- const configure = (patch) => {
367
+ const showError = (error) => {
368
+ setNotice(error instanceof Error ? error.message : String(error));
369
+ };
370
+ /* One configuration at a time; a second pick while one is on its way is dropped. */
371
+ const runConfiguration = (work) => {
296
372
  if (configuring.current) {
297
373
  return;
298
374
  }
299
375
  configuring.current = true;
300
376
  setConfigurationPending(true);
301
- void actions
302
- .configure(chatId, patch)
303
- .then(() => {
377
+ void work()
378
+ .catch(showError)
379
+ .finally(() => {
380
+ configuring.current = false;
381
+ setConfigurationPending(false);
382
+ });
383
+ };
384
+ const configure = (patch) => {
385
+ runConfiguration(() => actions.configure(chatId, patch).then(() => {
304
386
  if (patch.selection) {
305
387
  rememberChatSelection(info.provider, patch.selection);
306
388
  }
@@ -310,40 +392,24 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
310
392
  if (patch.account) {
311
393
  rememberChatAccount(scope.id, info.provider, patch.account);
312
394
  }
313
- })
314
- .catch((error) => setNotice(error instanceof Error ? error.message : String(error)))
315
- .finally(() => {
316
- configuring.current = false;
317
- setConfigurationPending(false);
318
- });
395
+ }));
319
396
  };
320
397
  const chooseAccount = (account) => configure({ account });
321
398
  /* A model of another CLI re-points the whole chat; one of this CLI's own is a configure. */
322
399
  const chooseModel = (provider, slug) => {
323
400
  const selection = { model: slug, options: {} };
324
401
  if (provider !== info.provider) {
325
- if (configuring.current) {
326
- return;
327
- }
328
- configuring.current = true;
329
- setConfigurationPending(true);
330
- void onRetarget(provider, selection)
331
- .then(() => rememberChatSelection(provider, selection))
332
- .catch((error) => setNotice(error instanceof Error ? error.message : String(error)))
333
- .finally(() => {
334
- configuring.current = false;
335
- setConfigurationPending(false);
336
- });
402
+ runConfiguration(() => onRetarget(provider, selection).then(() => rememberChatSelection(provider, selection)));
337
403
  return;
338
404
  }
339
405
  configure({ selection });
340
406
  };
341
407
  const stop = (shiftKey) => {
342
408
  if (composerStopOf(shiftKey) === 'turn') {
343
- void actions.stopTurn(chatId, false).catch((error) => setNotice(error instanceof Error ? error.message : String(error)));
409
+ void actions.stopTurn(chatId, false).catch(showError);
344
410
  return;
345
411
  }
346
- chatHost().confirm.stopSubagents(scope.id, chatId, () => void actions.stopTurn(chatId, true).catch((error) => setNotice(error instanceof Error ? error.message : String(error))));
412
+ chatHost().confirm.stopSubagents(scope.id, chatId, () => void actions.stopTurn(chatId, true).catch(showError));
347
413
  };
348
414
  /* The host decides whether a turn is in the way; only its refusal asks the person first. */
349
415
  const clearThread = async () => {
@@ -380,17 +446,16 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
380
446
  return false;
381
447
  }
382
448
  };
383
- const same = (a, b) => a?.start === b?.start && a?.query === b?.query;
384
449
  /* Which picker the caret opens: `@` for a file, `$` for a skill, or neither. In code a sigil is just text. */
385
450
  const trackTriggers = (value, selection, state) => {
386
451
  const empty = selection.from === selection.to;
387
452
  const tree = empty ? (ensureSyntaxTree(state, selection.from, 50) ?? syntaxTree(state)) : null;
388
453
  const caret = tree !== null && !inCode(tree, value, selection.from) ? selection.from : null;
389
454
  const skill = caret === null ? null : findSkillQuery(value, caret);
390
- setSkillQuery((current) => (same(current, skill) ? current : skill));
455
+ setSkillQuery((current) => (sameQuery(current, skill) ? current : skill));
391
456
  // A CLI that does not expand `@path` gets the text as it is, so the picker stays out of the way.
392
457
  const next = caret === null || capabilities?.mentions === false ? null : findMentionQuery(value, caret);
393
- setMention((current) => (same(current, next) ? current : next));
458
+ setMention((current) => (sameQuery(current, next) ? current : next));
394
459
  };
395
460
  // The inserted text reaches the editor with the render it causes, so the caret waits a frame for it.
396
461
  const moveCaret = (caret) => {
@@ -438,8 +503,7 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
438
503
  setSkillQuery(null);
439
504
  moveCaret(result.caret);
440
505
  };
441
- /* Paths dragged in from a list of files. There is no mention query to insert into, so they land
442
- at the end of the prompt and join the draft's mentions, which is what `chat.send` carries. */
506
+ /* Paths dragged in from a list of files. With no mention query to insert into, they land at the end of the prompt. */
443
507
  const addMentions = (paths) => {
444
508
  const fresh = paths.filter((path) => path !== '');
445
509
  if (fresh.length === 0 || capabilities?.mentions === false) {
@@ -488,7 +552,8 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
488
552
  setNotice(`${rejected.name}: ${rejected.reason}`);
489
553
  return;
490
554
  }
491
- const chosen = commands[menuIndex];
555
+ hold.release('send');
556
+ const chosen = commands[activeIndex];
492
557
  if (commandQuery !== null && chosen?.local && runCommand(chosen.name)) {
493
558
  clearDraft();
494
559
  return;
@@ -509,6 +574,7 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
509
574
  mentions: presentMentions(trimmed, draft.mentions),
510
575
  skills: presentSkills(trimmed, draft.skills),
511
576
  chats: draft.chats,
577
+ quoteSource: quote !== '' && quoteSource !== null ? { itemId: quoteSource } : undefined,
512
578
  attachments: draft.attachments
513
579
  });
514
580
  clearDraft();
@@ -516,9 +582,20 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
516
582
  setSkillQuery(null);
517
583
  setHistoryIndex(null);
518
584
  };
519
- /* A stashed prompt comes back as text, mentions and skills; its files were never kept. */
585
+ /*
586
+ * A stashed prompt comes back as text, mentions and skills; its files were never kept. Its quote
587
+ * keeps leading to its reply in the chat it came from, and is plain text in any other.
588
+ */
520
589
  const restoreStashed = (prompt) => {
521
- setDraft((current) => ({ ...current, text: prompt.text, mentions: [...prompt.mentions], skills: [...prompt.skills] }));
590
+ const stashedQuote = prompt.quote;
591
+ const here = stashedQuote !== undefined && stashedQuote.chatId === chatId;
592
+ setDraft((current) => ({
593
+ ...current,
594
+ text: stashedQuote === undefined || here ? prompt.text : withQuote(stashedQuote.text, prompt.text),
595
+ mentions: [...prompt.mentions],
596
+ skills: [...prompt.skills],
597
+ ...(here ? { quote: stashedQuote.text, quoteSource: stashedQuote.itemId } : {})
598
+ }));
522
599
  setHistoryIndex(null);
523
600
  inputRef.current?.focus();
524
601
  };
@@ -597,7 +674,7 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
597
674
  }
598
675
  return;
599
676
  }
600
- if (stashDraft(draft)) {
677
+ if (stashDraft(draft, chatId)) {
601
678
  clearDraft();
602
679
  setMention(null);
603
680
  setSkillQuery(null);
@@ -620,6 +697,18 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
620
697
  setText(history[next].text, history[next].mentions);
621
698
  return true;
622
699
  };
700
+ /* The arrows walk an open picker round; true when the key was one of them. */
701
+ const moveInMenu = (key, count) => {
702
+ if (key === 'ArrowDown') {
703
+ setMenuIndex((i) => (i + 1) % count);
704
+ return true;
705
+ }
706
+ if (key === 'ArrowUp') {
707
+ setMenuIndex((i) => (i - 1 + count) % count);
708
+ return true;
709
+ }
710
+ return false;
711
+ };
623
712
  /* Runs before the editor's own keymap; true means the key is handled and its default prevented. */
624
713
  const onKeyDown = (e, view) => {
625
714
  // Shift with the paste shortcut pastes a large text inline after all; any other key forgets it.
@@ -634,8 +723,7 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
634
723
  setText('', [], []);
635
724
  return true;
636
725
  }
637
- // The composer keeps the keyboard while you type, but the shortcuts that move between views,
638
- // panels and the palette stay the app's, untouched: Mod+Shift+Enter would otherwise send as well.
726
+ // The app's own shortcuts stay the app's while you type: Mod+Shift+Enter would otherwise send as well.
639
727
  if (chatHost().isAppShortcut(e)) {
640
728
  return false;
641
729
  }
@@ -653,16 +741,11 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
653
741
  return true;
654
742
  }
655
743
  if (commandMenuOpen) {
656
- if (e.key === 'ArrowDown') {
657
- setMenuIndex((i) => (i + 1) % commands.length);
658
- return true;
659
- }
660
- if (e.key === 'ArrowUp') {
661
- setMenuIndex((i) => (i - 1 + commands.length) % commands.length);
744
+ if (moveInMenu(e.key, commands.length)) {
662
745
  return true;
663
746
  }
664
747
  if (e.key === 'Tab') {
665
- const chosen = commands[menuIndex];
748
+ const chosen = commands[activeIndex];
666
749
  if (chosen.skill) {
667
750
  chooseSkill(chosen.name);
668
751
  }
@@ -673,30 +756,20 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
673
756
  }
674
757
  }
675
758
  if (skillMenuOpen) {
676
- if (e.key === 'ArrowDown') {
677
- setMenuIndex((i) => (i + 1) % skillMatches.length);
678
- return true;
679
- }
680
- if (e.key === 'ArrowUp') {
681
- setMenuIndex((i) => (i - 1 + skillMatches.length) % skillMatches.length);
759
+ if (moveInMenu(e.key, skillMatches.length)) {
682
760
  return true;
683
761
  }
684
762
  if (e.key === 'Tab' || e.key === 'Enter') {
685
- chooseSkill((skillMatches[menuIndex] ?? skillMatches[0]).name);
763
+ chooseSkill((skillMatches[activeIndex] ?? skillMatches[0]).name);
686
764
  return true;
687
765
  }
688
766
  }
689
767
  if (mentionMenuOpen && mentionCount > 0) {
690
- if (e.key === 'ArrowDown') {
691
- setMenuIndex((i) => (i + 1) % mentionCount);
692
- return true;
693
- }
694
- if (e.key === 'ArrowUp') {
695
- setMenuIndex((i) => (i - 1 + mentionCount) % mentionCount);
768
+ if (moveInMenu(e.key, mentionCount)) {
696
769
  return true;
697
770
  }
698
771
  if (e.key === 'Tab' || e.key === 'Enter') {
699
- chooseMentionAt(menuIndex);
772
+ chooseMentionAt(activeIndex);
700
773
  return true;
701
774
  }
702
775
  }
@@ -823,7 +896,7 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
823
896
  addMentions(droppedMentions(e.dataTransfer));
824
897
  addFiles(filesOf(e.dataTransfer));
825
898
  inputRef.current?.focus();
826
- }, children: _jsxs(PromptComposer, { chatId: chatId, pending: pending, focused: focused, onShowElsewhere: answerPromptsElsewhere, disabled: disabled, hasDraft: !isEmptyDraft(draft), denyReason: capabilities?.denyReason === true, onAllAnswered: () => inputRef.current?.focus(), children: [_jsx(LimitDock, { chatId: chatId, info: info }), compactionOffer && (_jsx(ResumeCompactionDock, { tokens: compactionOffer.tokens, onCompact: compact, onDismiss: () => dismissResumeCompaction(scope, chatId, compactionOffer.turnId) })), commandMenuOpen && (_jsx("div", { className: "border-b border-border px-1.5 py-1.5", children: commands.map((command, index) => (_jsxs("button", { "data-active": index === menuIndex, className: "cursor-row flex w-full items-center gap-2 rounded-md px-2 py-1 text-left text-xs text-text-muted", onMouseEnter: () => setMenuIndex(index), onMouseDown: (e) => e.preventDefault(), onClick: () => {
899
+ }, children: _jsxs(PromptComposer, { chatId: chatId, pending: pending, focused: focused, onShowElsewhere: answerPromptsElsewhere, disabled: disabled, hasDraft: !isEmptyDraft(draft), denyReason: capabilities?.denyReason === true, onAllAnswered: () => inputRef.current?.focus(), hold: hold, children: [_jsx(LimitDock, { chatId: chatId, info: info }), compactionOffer && (_jsx(ResumeCompactionDock, { tokens: compactionOffer.tokens, onCompact: compact, onDismiss: () => dismissResumeCompaction(scope, chatId, compactionOffer.turnId) })), commandMenuOpen && (_jsx("div", { id: listId, role: "listbox", "aria-label": t('composer.menu.commands'), className: "border-b border-border px-1.5 py-1.5", children: commands.map((command, index) => (_jsxs("button", { id: optionIdOf(listId, index), role: "option", "aria-selected": index === activeIndex, tabIndex: -1, "data-active": index === activeIndex, className: "cursor-row flex w-full items-center gap-2 rounded-md px-2 py-1 text-left text-xs text-text-muted", onMouseEnter: () => setMenuIndex(index), onMouseDown: (e) => e.preventDefault(), onClick: () => {
827
900
  setMenuIndex(index);
828
901
  if (command.skill) {
829
902
  chooseSkill(command.name);
@@ -831,10 +904,10 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
831
904
  }
832
905
  setText(`/${command.name}`);
833
906
  inputRef.current?.focus();
834
- }, children: [_jsx(Icon, { icon: command.skill ? Zap : SquareSlash, size: 12, className: clsx('shrink-0', command.skill ? 'text-skill' : 'text-text-faint') }), _jsxs("span", { className: "font-mono text-text", children: ["/", command.name] }), _jsx("span", { className: "text-text-faint", children: command.hint })] }, command.name))) })), skillMenuOpen && (_jsx("div", { className: "border-b border-border px-1.5 py-1.5", children: skillMatches.map((skill, index) => (_jsxs("button", { "data-active": index === menuIndex, className: "cursor-row flex w-full items-start gap-2 rounded-md px-2 py-1 text-left text-xs text-text-muted", onMouseEnter: () => setMenuIndex(index), onMouseDown: (e) => e.preventDefault(), onClick: () => chooseSkill(skill.name), children: [_jsx(Icon, { icon: Zap, size: 12, className: "mt-0.5 shrink-0 text-skill" }), _jsxs("span", { className: "flex min-w-0 flex-col", children: [_jsxs("span", { className: "truncate font-mono text-text", children: ["$", skill.name] }), skill.description !== '' && _jsx("span", { className: "line-clamp-1 text-text-faint", children: skill.description })] })] }, skill.name))) })), mentionMenuOpen && (_jsxs("div", { className: "border-b border-border px-1.5 py-1.5", children: [chatMatches.length > 0 && _jsx(Menu.Label, { children: t('composer.mentions.chats') }), chatMatches.map((chat, index) => (_jsxs("button", { "data-active": index === menuIndex, className: "cursor-row flex w-full items-center gap-2 rounded-md px-2 py-1 text-left text-xs text-text-muted", onMouseEnter: () => setMenuIndex(index), onMouseDown: (e) => e.preventDefault(), onClick: () => chooseChat(chat.id), children: [_jsx(Icon, { icon: MessageSquare, size: 14, className: "shrink-0 text-text-faint" }), _jsx("span", { className: "truncate text-text", children: chat.title || t('chatReference.untitled') })] }, chat.id))), files.length > 0 && (mention.query === '' || chatMatches.length > 0) && (_jsx(Menu.Label, { children: mention.query === '' ? t('composer.mentions.here') : t('composer.mentions.files') })), mentionCount === 0 && (_jsx("div", { className: "px-2 py-1 text-xs text-text-faint", children: mention.query ? t('composer.mentions.noMatch') : t('composer.mentions.empty') })), files.map((path, index) => {
835
- const { name, dir } = splitPath(path);
836
- return (_jsxs("button", { "data-active": chatMatches.length + index === menuIndex, className: "cursor-row flex w-full items-center gap-2 rounded-md px-2 py-1 text-left text-xs text-text-muted", onMouseEnter: () => setMenuIndex(chatMatches.length + index), onMouseDown: (e) => e.preventDefault(), onClick: () => chooseMention(path), children: [_jsx(FileIcon, { path: path, size: 14 }), _jsx("span", { className: "truncate font-mono text-text", children: name }), dir && _jsx("span", { className: "min-w-0 truncate text-text-faint", children: dir })] }, path));
837
- })] })), queue.length > 0 && (_jsxs("div", { className: "flex flex-col gap-1 border-b border-border px-2 py-1.5", children: [info.queuePaused === true && _jsx("p", { className: "px-1.5 text-xs text-text-muted", children: t('composer.queue.paused') }), queue.map((message) => {
907
+ }, children: [_jsx(Icon, { icon: command.skill ? Zap : SquareSlash, size: 12, className: clsx('shrink-0', command.skill ? 'text-skill' : 'text-text-faint') }), _jsxs("span", { className: "font-mono text-text", children: ["/", command.name] }), _jsx("span", { className: "text-text-faint", children: command.hint })] }, command.name))) })), skillMenuOpen && (_jsx("div", { id: listId, role: "listbox", "aria-label": t('composer.menu.skills'), className: "border-b border-border px-1.5 py-1.5", children: skillMatches.map((skill, index) => (_jsxs("button", { id: optionIdOf(listId, index), role: "option", "aria-selected": index === activeIndex, tabIndex: -1, "data-active": index === activeIndex, className: "cursor-row flex w-full items-start gap-2 rounded-md px-2 py-1 text-left text-xs text-text-muted", onMouseEnter: () => setMenuIndex(index), onMouseDown: (e) => e.preventDefault(), onClick: () => chooseSkill(skill.name), children: [_jsx(Icon, { icon: Zap, size: 12, className: "mt-0.5 shrink-0 text-skill" }), _jsxs("span", { className: "flex min-w-0 flex-col", children: [_jsxs("span", { className: "truncate font-mono text-text", children: ["$", skill.name] }), skill.description !== '' && _jsx("span", { className: "line-clamp-1 text-text-faint", children: skill.description })] })] }, skill.name))) })), mentionMenuOpen && (_jsxs("div", { className: "border-b border-border px-1.5 py-1.5", children: [mentionCount > 0 && (_jsxs("div", { id: listId, role: "listbox", "aria-label": t('composer.menu.mentions'), children: [chatMatches.length > 0 && (_jsxs("div", { role: "group", "aria-labelledby": mentionsLabelId, children: [_jsx(Menu.Label, { id: mentionsLabelId, children: t('composer.mentions.chats') }), chatMatches.map((chat, index) => (_jsxs("button", { id: optionIdOf(listId, index), role: "option", "aria-selected": index === activeIndex, tabIndex: -1, "data-active": index === activeIndex, className: "cursor-row flex w-full items-center gap-2 rounded-md px-2 py-1 text-left text-xs text-text-muted", onMouseEnter: () => setMenuIndex(index), onMouseDown: (e) => e.preventDefault(), onClick: () => chooseChat(chat.id), children: [_jsx(Icon, { icon: MessageSquare, size: 14, className: "shrink-0 text-text-faint" }), _jsx("span", { className: "truncate text-text", children: chat.title || t('chatReference.untitled') })] }, chat.id)))] })), files.length > 0 && (_jsxs("div", { role: "group", ...(filesLabelShown ? { 'aria-labelledby': filesLabelId } : { 'aria-label': t('composer.mentions.files') }), children: [filesLabelShown && (_jsx(Menu.Label, { id: filesLabelId, children: mention.query === '' ? t('composer.mentions.here') : t('composer.mentions.files') })), files.map((path, index) => {
908
+ const { name, dir } = splitPath(path);
909
+ return (_jsxs("button", { id: optionIdOf(listId, chatMatches.length + index), role: "option", "aria-selected": chatMatches.length + index === activeIndex, tabIndex: -1, "data-active": chatMatches.length + index === activeIndex, className: "cursor-row flex w-full items-center gap-2 rounded-md px-2 py-1 text-left text-xs text-text-muted", onMouseEnter: () => setMenuIndex(chatMatches.length + index), onMouseDown: (e) => e.preventDefault(), onClick: () => chooseMention(path), children: [_jsx(FileIcon, { path: path, size: 14 }), _jsx("span", { className: "truncate font-mono text-text", children: name }), dir && _jsx("span", { className: "min-w-0 truncate text-text-faint", children: dir })] }, path));
910
+ })] }))] })), mentionCount === 0 && (_jsx("div", { className: "px-2 py-1 text-xs text-text-faint", children: mention.query ? t('composer.mentions.noMatch') : t('composer.mentions.empty') }))] })), queue.length > 0 && (_jsxs("div", { className: "flex flex-col gap-1 border-b border-border px-2 py-1.5", children: [info.queuePaused === true && _jsx("p", { className: "px-1.5 text-xs text-text-muted", children: t('composer.queue.paused') }), queue.map((message) => {
838
911
  const sendNow = () => {
839
912
  scrollTimelineToEnd(chatId, 'instant');
840
913
  void actions.sendNow(chatId, message.id).catch(() => undefined);
@@ -842,7 +915,11 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
842
915
  const unqueue = () => void actions.unqueue(chatId, message.id).catch(() => undefined);
843
916
  const edit = () => void takeBack(message);
844
917
  return (_jsxs(ContextMenu.Root, { children: [_jsxs(ContextMenu.Trigger, { className: "group/queued flex items-center gap-2 rounded-md px-1.5 py-1 text-xs text-text-muted", children: [_jsx(Icon, { icon: Clock, size: 12, className: "shrink-0 text-text-faint" }), _jsx("span", { className: "min-w-0 grow truncate", children: message.text || t('composer.queue.attachments', { count: message.attachments?.length ?? 0 }) }), _jsxs(ButtonGroup, { render: _jsx("span", {}), className: "opacity-0 transition-opacity group-hover/queued:opacity-100 focus-within:opacity-100", children: [_jsx(IconButton, { icon: FastForward, size: "2xs", label: t('composer.queue.sendNow'), onClick: sendNow }), _jsx(IconButton, { icon: Pencil, size: "2xs", label: t('common.action.edit'), disabled: takingBack !== null, onClick: edit }), _jsx(IconButton, { icon: X, size: "2xs", label: t('common.action.remove'), onClick: unqueue })] })] }), _jsxs(ContextMenu.Popup, { children: [_jsxs(ContextMenu.Item, { onClick: sendNow, children: [_jsx(Icon, { icon: FastForward, size: 14 }), " ", t('composer.queue.sendNow')] }), _jsxs(ContextMenu.Item, { disabled: takingBack !== null, onClick: edit, children: [_jsx(Icon, { icon: Pencil, size: 14 }), " ", t('common.action.edit')] }), _jsxs(ContextMenu.Item, { disabled: message.text === '', onClick: () => copyText(message.text), children: [_jsx(Icon, { icon: Copy, size: 14 }), " ", t('common.action.copy')] }), _jsx(ContextMenu.Separator, {}), _jsxs(ContextMenu.Item, { className: "text-status-error", onClick: unqueue, children: [_jsx(Icon, { icon: X, size: 14 }), " ", t('common.action.remove')] })] })] }, message.id));
845
- })] })), quote !== '' && (_jsxs("div", { className: "flex items-center gap-2 px-5 pt-4 @max-md/composer:px-3.5 @max-md/composer:pt-3", children: [_jsx("span", { className: "min-w-0 grow truncate border-l-2 border-border-strong pl-2.5 text-xs text-text-muted", children: quote }), _jsx(IconButton, { icon: X, size: "2xs", label: t('composer.quote.remove'), onClick: removeQuote })] })), draft.chats.length > 0 && (_jsx("div", { className: "flex flex-wrap gap-1.5 px-5 pt-4 @max-md/composer:px-3.5 @max-md/composer:pt-3", children: draft.chats.map((id) => (_jsx(ChatReferenceChip, { chatId: id, onRemove: () => removeChat(id) }, id))) })), draft.attachments.length > 0 && (_jsx("div", { className: "flex flex-wrap gap-2 px-5 pt-4 @max-md/composer:px-3.5 @max-md/composer:pt-3", children: draft.attachments.map((attachment, index) => (_jsxs("div", { className: "group/thumb relative", children: [isImageAttachment(attachment.mime) ? (_jsx(UploadThumb, { upload: attachment })) : (_jsxs("span", { className: "flex h-14 w-36 flex-col justify-center gap-0.5 rounded-lg border border-border bg-surface-sunken px-2.5", children: [_jsxs("span", { className: "flex items-center gap-1.5 text-xs text-text", children: [_jsx(Icon, { icon: Paperclip, size: 12, className: "shrink-0 text-text-faint" }), _jsx("span", { className: "truncate", children: attachment.name })] }), _jsx("span", { className: "pl-5 text-xs text-text-faint", children: formatBytes(uploadBytes(attachment)) })] })), _jsx(IconButton, { icon: X, size: "2xs", label: t('composer.removeAttachment', { name: attachment.name }), className: "absolute -top-1.5 -right-1.5 border border-border bg-surface-raised opacity-0 transition-opacity group-hover/thumb:opacity-100 focus-visible:opacity-100", onClick: () => removeAttachment(index) })] }, `${attachment.name}-${index}`))) })), _jsx(ComposerInput, { ref: inputRef, dictationToolbar: dictationToolbar, className: "composer-input select-text text-sm leading-normal text-text", value: text, placeholder: placeholder, disabled: !writable, tabbable: focused, extensions: editorExtensions, onChange: (value, selection, state) => {
918
+ })] })), quote !== '' && (_jsxs("div", { className: "flex items-center gap-2 px-5 pt-4 @max-md/composer:px-3.5 @max-md/composer:pt-3", children: [quoteSource === null ? (_jsx("span", { className: "min-w-0 grow truncate border-l-2 border-border-strong pl-2.5 text-xs text-text-muted", children: quote })) : (_jsx(Tooltip, { label: t('composer.quote.goToSource'), children: _jsx("button", { type: "button", 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", onClick: () => jumpToTimelineItem(scope.keyOf(chatId), quoteSource, {
919
+ flash: true,
920
+ onMissing: () => setNotice(t('rows.user.quoteGone'))
921
+ }), children: quote }) })), _jsx(IconButton, { icon: X, size: "2xs", label: t('composer.quote.remove'), onClick: removeQuote })] })), draft.chats.length > 0 && (_jsx("div", { className: "flex flex-wrap gap-1.5 px-5 pt-4 @max-md/composer:px-3.5 @max-md/composer:pt-3", children: draft.chats.map((id) => (_jsx(ChatReferenceChip, { chatId: id, onRemove: () => removeChat(id) }, id))) })), draft.attachments.length > 0 && (_jsx("div", { className: "flex flex-wrap gap-2 px-5 pt-4 @max-md/composer:px-3.5 @max-md/composer:pt-3", children: draft.attachments.map((attachment, index) => (_jsxs("div", { className: "group/thumb relative", children: [isImageAttachment(attachment.mime) ? (_jsx(UploadThumb, { upload: attachment })) : (_jsxs("span", { className: "flex h-14 w-36 flex-col justify-center gap-0.5 rounded-lg border border-border bg-surface-sunken px-2.5", children: [_jsxs("span", { className: "flex items-center gap-1.5 text-xs text-text", children: [_jsx(Icon, { icon: Paperclip, size: 12, className: "shrink-0 text-text-faint" }), _jsx("span", { className: "truncate", children: attachment.name })] }), _jsx("span", { className: "pl-5 text-xs text-text-faint", children: formatBytes(uploadBytes(attachment)) })] })), _jsx(IconButton, { icon: X, size: "2xs", label: t('composer.removeAttachment', { name: attachment.name }), className: "absolute -top-1.5 -right-1.5 border border-border bg-surface-raised opacity-0 transition-opacity group-hover/thumb:opacity-100 focus-visible:opacity-100", onClick: () => removeAttachment(index) })] }, `${attachment.name}-${index}`))) })), _jsx(ComposerInput, { ref: inputRef, dictationToolbar: dictationToolbar, className: "composer-input select-text text-sm leading-normal text-text", value: text, placeholder: placeholder, disabled: !writable, tabbable: focused, extensions: editorExtensions, aria: inputAria, onChange: (value, selection, state) => {
922
+ hold.noteInput();
846
923
  setText(value);
847
924
  setMenuIndex(0);
848
925
  trackTriggers(value, selection, state);
@@ -852,15 +929,10 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
852
929
  }, onSelectionChange: trackTriggers, onBlur: () => {
853
930
  setMention(null);
854
931
  setSkillQuery(null);
855
- }, onKeyDown: onKeyDown, onPaste: onPaste }), notice && (_jsx("div", { className: "px-5 pb-1 text-xs text-status-error @max-md/composer:px-3.5", role: "alert", children: notice })), guard.visible && (_jsxs("div", { className: clsx('px-5 pb-1 text-right text-xs tabular-nums @max-md/composer:px-3.5', guard.tooLong ? 'text-status-error' : 'text-text-faint'), children: [formatNumber(guard.count), " / ", formatNumber(PROMPT_MAX_CHARS), guard.tooLong && ` ${t('composer.tooLong')}`] })), _jsxs("div", { className: "flex items-center gap-2 pt-3 pr-3.5 pb-3.5 pl-5 @max-md/composer:gap-1.5 @max-md/composer:p-3 @max-md/composer:pt-2.5", children: [attachable && (_jsx(Tooltip, { label: t('composer.attach'), name: true, children: _jsx("button", { type: "button", className: "grid h-8 w-8 shrink-0 place-items-center rounded-full border border-border text-text-muted hover:bg-surface-hover hover:text-text disabled:opacity-50", disabled: !writable, onClick: () => fileInputRef.current?.click(), children: _jsx(Icon, { icon: Plus, size: 16 }) }) })), _jsx("input", { ref: fileInputRef, type: "file", multiple: true, hidden: true, onChange: (e) => {
932
+ hold.release('blur');
933
+ }, onComposing: hold.setComposing, onKeyDown: onKeyDown, onPaste: onPaste }), notice && (_jsx("div", { className: "px-5 pb-1 text-xs text-status-error @max-md/composer:px-3.5", role: "alert", children: notice })), guard.visible && (_jsxs("div", { className: clsx('px-5 pb-1 text-right text-xs tabular-nums @max-md/composer:px-3.5', guard.tooLong ? 'text-status-error' : 'text-text-faint'), children: [formatNumber(guard.count), " / ", formatNumber(PROMPT_MAX_CHARS), guard.tooLong && ` ${t('composer.tooLong')}`] })), _jsxs("div", { className: "flex items-center gap-2 pt-3 pr-3.5 pb-3.5 pl-5 @max-md/composer:gap-1.5 @max-md/composer:p-3 @max-md/composer:pt-2.5", children: [attachable && (_jsx(Tooltip, { label: t('composer.attach'), name: true, children: _jsx("button", { type: "button", className: "grid h-8 w-8 shrink-0 place-items-center rounded-full border border-border text-text-muted hover:bg-surface-hover hover:text-text disabled:opacity-50", disabled: !writable, onClick: () => fileInputRef.current?.click(), children: _jsx(Icon, { icon: Plus, size: 16 }) }) })), _jsx("input", { ref: fileInputRef, type: "file", multiple: true, hidden: true, onChange: (e) => {
856
934
  addFiles(Array.from(e.target.files ?? []));
857
935
  e.target.value = '';
858
936
  inputRef.current?.focus();
859
- } }), _jsx(RunSettings, { disabled: configurationPending || !writable, providers: pickable, provider: info.provider, selection: info.selection, model: model, runtimeMode: info.runtimeMode, effectiveRuntimeMode: info.effectiveRuntimeMode, permissionMode: info.permissionMode, open: modelPickerOpen, onOpenChange: setModelPickerOpen, onModel: chooseModel, onOption: (id, value) => configure({ selection: { ...info.selection, options: { ...info.selection.options, [id]: value } } }), onMode: (runtimeMode) => configure({ runtimeMode }), usage: info.usage, compactDisabled: busy || !writable, onCompact: compact, account: accountChoice === null ? null : { choice: accountChoice, started, onPick: chooseAccount } }), _jsx(StashPicker, { onRestore: restoreStashed }), Slot !== null && (_jsx(ErrorBoundary, { label: t('composer.slotFailed'), resetKeys: [chatId], compact: true, className: "relative rounded-lg", children: _jsx(Slot, { scopeId: scope.id, chatId: chatId, disabled: !writable, insert: insertIntoDraft }) })), _jsx("span", { className: "grow" }), _jsxs("div", { className: "flex h-9 shrink-0 items-center gap-0.5 rounded-full bg-surface-hover", children: [_jsx("div", { ref: setDictationToolbar, className: "flex items-center pl-1 empty:hidden [&_.icon-btn]:rounded-full" }), busy && (_jsx(Tooltip, { label: composerStopLabel(), name: true, children: _jsx("button", { className: "flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-status-error text-accent-text", onClick: (event) => stop(event.shiftKey), children: _jsx(Icon, { icon: Square, size: 16 }) }) })), (!busy || !isEmptyDraft(draft)) && (_jsx(Tooltip, { label: busy
860
- ? t(canSteer
861
- ? sendDelivery === 'steer'
862
- ? 'composer.steerButton'
863
- : 'composer.queueWithSteerButton'
864
- : 'composer.queueButton')
865
- : t('composer.send'), kbd: KEY_SHORTCUTS.modEnter, name: true, children: _jsx("button", { 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", disabled: isEmptyDraft(draft) || !writable || guard.tooLong, onClick: (event) => submit(event.altKey), children: _jsx(Icon, { icon: ArrowUp, size: 16 }) }) }))] })] })] }) }), _jsx(PromptDialog, { open: confirmClear, title: t('composer.clear.title'), description: t('composer.clear.description'), confirmLabel: t('composer.clear.confirm'), danger: true, fallbackMessage: t('composer.notice.clearFailed'), onConfirm: forceClearThread, onOpenChange: () => setConfirmClear(false) })] }));
937
+ } }), _jsx(RunSettings, { disabled: configurationPending || !writable, providers: pickable, provider: info.provider, selection: info.selection, model: model, runtimeMode: info.runtimeMode, effectiveRuntimeMode: info.effectiveRuntimeMode, permissionMode: info.permissionMode, open: modelPickerOpen, onOpenChange: setModelPickerOpen, onModel: chooseModel, onOption: (id, value) => configure({ selection: { ...info.selection, options: { ...info.selection.options, [id]: value } } }), onMode: (runtimeMode) => configure({ runtimeMode }), usage: info.usage, compactDisabled: busy || !writable, onCompact: compact, account: accountChoice === null ? null : { choice: accountChoice, started, onPick: chooseAccount } }), _jsx(StashPicker, { onRestore: restoreStashed }), Slot !== null && (_jsx(ErrorBoundary, { label: t('composer.slotFailed'), resetKeys: [chatId], compact: true, className: "relative rounded-lg", children: _jsx(Slot, { scopeId: scope.id, chatId: chatId, disabled: !writable, insert: insertIntoDraft }) })), _jsx("span", { className: "grow" }), _jsxs("div", { className: "flex h-9 shrink-0 items-center gap-0.5 rounded-full bg-surface-hover", children: [_jsx("div", { ref: setDictationToolbar, className: "flex items-center pl-1 empty:hidden [&_.icon-btn]:rounded-full" }), busy && (_jsx(Tooltip, { label: composerStopLabel(), name: true, children: _jsx("button", { className: "flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-status-error text-accent-text", onClick: (event) => stop(event.shiftKey), children: _jsx(Icon, { icon: Square, size: 16 }) }) })), (!busy || !isEmptyDraft(draft)) && (_jsx(Tooltip, { label: t(sendLabelKey(busy, canSteer, sendDelivery)), kbd: KEY_SHORTCUTS.modEnter, name: true, children: _jsx("button", { 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", disabled: isEmptyDraft(draft) || !writable || guard.tooLong, onClick: (event) => submit(event.altKey), children: _jsx(Icon, { icon: ArrowUp, size: 16 }) }) }))] })] })] }) }), _jsx(PromptDialog, { open: confirmClear, title: t('composer.clear.title'), description: t('composer.clear.description'), confirmLabel: t('composer.clear.confirm'), danger: true, fallbackMessage: t('composer.notice.clearFailed'), onConfirm: forceClearThread, onOpenChange: () => setConfirmClear(false) })] }));
866
938
  }
@@ -19,11 +19,13 @@ interface ComposerInputProps {
19
19
  tabbable: boolean;
20
20
  className?: string;
21
21
  extensions?: Extension;
22
+ aria?: Readonly<Record<string, string>>;
22
23
  onChange(text: string, selection: InputSelection, state: EditorState): void;
23
24
  onSelectionChange?(text: string, selection: InputSelection, state: EditorState): void;
24
25
  onBlur?(): void;
26
+ onComposing?(composing: boolean): void;
25
27
  onKeyDown?(event: KeyboardEvent, view: EditorView): boolean;
26
28
  onPaste?(event: ClipboardEvent, view: EditorView): boolean;
27
29
  }
28
- export declare function ComposerInput({ ref, value, placeholder, disabled, tabbable, className, dictationToolbar, extensions, ...handlers }: ComposerInputProps): import("react").JSX.Element;
30
+ export declare function ComposerInput({ ref, value, placeholder, disabled, tabbable, className, dictationToolbar, extensions, aria, ...handlers }: ComposerInputProps): import("react").JSX.Element;
29
31
  export {};
@@ -6,12 +6,13 @@ import { chatHost } from '../../host.js';
6
6
  import { composerEditorExtensions, externalChange } from './composer/editor.js';
7
7
  import { insertAtSelection } from './composer/insert.js';
8
8
  const NO_EXTENSIONS = [];
9
+ const NO_ARIA = {};
9
10
  /*
10
11
  * A multi-line text field on CodeMirror, controlled by a plain string. The editor draws the text
11
12
  * itself, so a decoration may change a font or add padding without walking the caret off its
12
13
  * character, which a textarea with a painted layer behind it could not.
13
14
  */
14
- export function ComposerInput({ ref, value, placeholder, disabled, tabbable, className, dictationToolbar, extensions = NO_EXTENSIONS, ...handlers }) {
15
+ export function ComposerInput({ ref, value, placeholder, disabled, tabbable, className, dictationToolbar, extensions = NO_EXTENSIONS, aria = NO_ARIA, ...handlers }) {
15
16
  const hostRef = useRef(null);
16
17
  const targetRef = useRef(null);
17
18
  const viewRef = useRef(null);
@@ -47,7 +48,15 @@ export function ComposerInput({ ref, value, placeholder, disabled, tabbable, cla
47
48
  compartments.extensions.of([]),
48
49
  Prec.highest(EditorView.domEventHandlers({
49
50
  keydown: (event, target) => latest.current.handlers.onKeyDown?.(event, target) ?? false,
50
- paste: (event, target) => latest.current.handlers.onPaste?.(event, target) ?? false
51
+ paste: (event, target) => latest.current.handlers.onPaste?.(event, target) ?? false,
52
+ compositionstart: () => {
53
+ latest.current.handlers.onComposing?.(true);
54
+ return false;
55
+ },
56
+ compositionend: () => {
57
+ latest.current.handlers.onComposing?.(false);
58
+ return false;
59
+ }
51
60
  })),
52
61
  EditorView.updateListener.of((update) => {
53
62
  const current = latest.current.handlers;
@@ -80,13 +89,13 @@ export function ComposerInput({ ref, value, placeholder, disabled, tabbable, cla
80
89
  viewRef.current?.dispatch({
81
90
  effects: [
82
91
  // Spellcheck stays on, as it was on the textarea this editor replaced.
83
- compartments.attributes.reconfigure(EditorView.contentAttributes.of({ spellcheck: 'true', tabindex: tabbable ? '0' : '-1' })),
92
+ compartments.attributes.reconfigure(EditorView.contentAttributes.of({ ...aria, spellcheck: 'true', tabindex: tabbable ? '0' : '-1' })),
84
93
  compartments.editable.reconfigure([EditorView.editable.of(!disabled), EditorState.readOnly.of(disabled)]),
85
94
  compartments.placeholder.reconfigure(placeholderText(placeholder)),
86
95
  compartments.extensions.reconfigure(extensions)
87
96
  ]
88
97
  });
89
- }, [compartments, tabbable, disabled, placeholder, extensions]);
98
+ }, [compartments, tabbable, disabled, placeholder, extensions, aria]);
90
99
  useLayoutEffect(() => {
91
100
  const view = viewRef.current;
92
101
  if (!view || value === reported.current) {