@adecore/agents-react 0.19.0 → 0.20.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 (38) hide show
  1. package/dist/chat/chat-client.d.ts +1 -0
  2. package/dist/chat/logic/timeline-target.d.ts +6 -0
  3. package/dist/chat/logic/timeline-target.js +23 -3
  4. package/dist/chat/logic/timeline.js +9 -7
  5. package/dist/chat/ui/CodeBlock.d.ts +3 -1
  6. package/dist/chat/ui/CodeBlock.js +38 -7
  7. package/dist/chat/ui/Composer.js +13 -3
  8. package/dist/chat/ui/Markdown.d.ts +7 -1
  9. package/dist/chat/ui/Markdown.js +80 -23
  10. package/dist/chat/ui/MessageActions.js +6 -7
  11. package/dist/chat/ui/SubagentTimeline.js +1 -1
  12. package/dist/chat/ui/Timeline.js +1 -1
  13. package/dist/chat/ui/TimelineMenu.js +7 -3
  14. package/dist/chat/ui/file-links.d.ts +2 -1
  15. package/dist/chat/ui/file-links.js +8 -3
  16. package/dist/chat/ui/rows/MessageRows.js +6 -3
  17. package/dist/host.d.ts +14 -2
  18. package/dist/host.js +1 -0
  19. package/dist/locales/en/agent-chat.json +7 -1
  20. package/dist/locales/nl/agent-chat.json +7 -1
  21. package/dist/theme.css +19 -2
  22. package/package.json +3 -3
  23. package/src/chat/chat-client.ts +1 -0
  24. package/src/chat/logic/timeline-target.ts +28 -3
  25. package/src/chat/logic/timeline.ts +9 -7
  26. package/src/chat/ui/CodeBlock.tsx +70 -21
  27. package/src/chat/ui/Composer.tsx +22 -5
  28. package/src/chat/ui/Markdown.tsx +123 -26
  29. package/src/chat/ui/MessageActions.tsx +15 -8
  30. package/src/chat/ui/SubagentTimeline.tsx +2 -0
  31. package/src/chat/ui/Timeline.tsx +1 -1
  32. package/src/chat/ui/TimelineMenu.tsx +6 -3
  33. package/src/chat/ui/file-links.ts +9 -3
  34. package/src/chat/ui/rows/MessageRows.tsx +9 -3
  35. package/src/host.ts +19 -2
  36. package/src/locales/en/agent-chat.json +7 -1
  37. package/src/locales/nl/agent-chat.json +7 -1
  38. package/src/theme.css +19 -2
package/dist/host.d.ts CHANGED
@@ -40,8 +40,18 @@ export interface ChatActions {
40
40
  export interface FileRef {
41
41
  path: string;
42
42
  line?: number;
43
+ column?: number;
44
+ endLine?: number;
43
45
  directory: boolean;
44
46
  }
47
+ export interface ShellCodeBlockContext {
48
+ scopeId: string;
49
+ chatId: string;
50
+ itemId: string;
51
+ language: 'sh' | 'bash' | 'zsh';
52
+ code: string;
53
+ complete: boolean;
54
+ }
45
55
  export interface ChatPlace {
46
56
  title: string | null;
47
57
  go(): void;
@@ -145,10 +155,12 @@ export interface ChatHost {
145
155
  }>;
146
156
  };
147
157
  useStreaming(): 'words' | 'blocks' | 'whole';
158
+ useSendDelivery(): 'queue' | 'steer';
148
159
  fileLinks: {
149
- target(text: string, cwd: string | null): FileRef | null;
150
- open(cwd: string | null, ref: FileRef): void;
160
+ target(text: string, cwd: string | null, scopeId?: string | null): FileRef | null;
161
+ open(cwd: string | null, ref: FileRef, scopeId?: string | null): void;
151
162
  } | null;
163
+ renderShellCodeBlock?: (context: ShellCodeBlockContext) => ReactNode;
152
164
  useTimelineFind(options: TimelineFindOptions): TimelineFind;
153
165
  dictation: {
154
166
  Textarea: ComponentType<TextareaProps>;
package/dist/host.js CHANGED
@@ -20,6 +20,7 @@ const DEFAULT_HOST = {
20
20
  ReadImage: null,
21
21
  code: { useMode: () => 'light', useThemes: () => ({ light: 'github-light', dark: 'github-dark' }), custom: [] },
22
22
  useStreaming: () => 'words',
23
+ useSendDelivery: () => 'queue',
23
24
  fileLinks: null,
24
25
  useTimelineFind: () => NO_FIND,
25
26
  dictation: { Textarea: PlainTextarea, composer: null },
@@ -307,7 +307,9 @@
307
307
  "description": "A turn is running. Stop it and clear the thread?",
308
308
  "confirm": "Stop and clear"
309
309
  },
310
- "waitingBelow": "Waiting for your answer below"
310
+ "waitingBelow": "Waiting for your answer below",
311
+ "steerButton": "Send to the running agent. Hold Option or Alt to queue after this turn.",
312
+ "queueWithSteerButton": "Queue after this turn. Hold Option or Alt to send to the running agent."
311
313
  },
312
314
  "activity": {
313
315
  "subagents_one": "{{count}} sub-agent",
@@ -454,5 +456,9 @@
454
456
  "failed": "failed",
455
457
  "cancelled": "cancelled"
456
458
  }
459
+ },
460
+ "code": {
461
+ "actions": "Code actions",
462
+ "copyFailed": "Could not copy the code. Try again."
457
463
  }
458
464
  }
@@ -307,7 +307,9 @@
307
307
  "description": "Er loopt een turn. Die stoppen en de thread wissen?",
308
308
  "confirm": "Stoppen en wissen"
309
309
  },
310
- "waitingBelow": "Wacht hieronder op je antwoord"
310
+ "waitingBelow": "Wacht hieronder op je antwoord",
311
+ "steerButton": "Stuur naar de werkende agent. Houd Option of Alt ingedrukt om te wachten tot deze beurt klaar is.",
312
+ "queueWithSteerButton": "Wacht tot deze beurt klaar is. Houd Option of Alt ingedrukt om naar de werkende agent te sturen."
311
313
  },
312
314
  "activity": {
313
315
  "subagents_one": "{{count}} sub-agent",
@@ -454,5 +456,9 @@
454
456
  "failed": "mislukt",
455
457
  "cancelled": "geannuleerd"
456
458
  }
459
+ },
460
+ "code": {
461
+ "actions": "Codeacties",
462
+ "copyFailed": "De code kon niet worden gekopieerd. Probeer het opnieuw."
457
463
  }
458
464
  }
package/dist/theme.css CHANGED
@@ -193,9 +193,26 @@
193
193
  .chat-markdown :is(th, td) { border: 1px solid var(--tw-prose-td-borders); padding: 4px 8px; text-align: left; }
194
194
 
195
195
  /* A code block is shiki's own `<pre>`, which brings a background of its own to override. */
196
- .chat-code { border-radius: 8px; border: 1px solid var(--border); background: var(--chat-code-bg); overflow: auto; }
197
- .chat-code pre { margin: 0; padding: round(0.625rem, 1px) round(0.75rem, 1px); background: transparent !important; font-family: var(--font-mono); font-size: var(--text-code); line-height: var(--text-code--line-height); }
196
+ .chat-code { position: relative; min-height: 38px; border-radius: 8px; border: 1px solid var(--border); background: var(--chat-code-bg); overflow: hidden; }
197
+ .chat-code pre { margin: 0; padding: round(0.625rem, 1px) round(0.75rem, 1px); overflow: auto; user-select: text; background: transparent !important; font-family: var(--font-mono); font-size: var(--text-code); line-height: var(--text-code--line-height); }
198
198
  .chat-code code { font-family: inherit; }
199
+ .chat-code-actions { position: absolute; top: 4px; right: 4px; border-radius: 6px; background: var(--surface-raised); color: var(--text-muted); box-shadow: 0 0 0 1px var(--border); }
200
+ .chat-code-error { margin: 0; padding: 0 12px 10px; color: var(--status-error); font-size: var(--text-sm); }
201
+ @media (hover: hover) and (pointer: fine) {
202
+ .chat-code-actions { opacity: 0; pointer-events: none; }
203
+ .chat-code:hover .chat-code-actions, .chat-code:focus-within .chat-code-actions,
204
+ .chat-code[data-copy-state="failed"] .chat-code-actions, .chat-code-actions:has([aria-busy="true"]) { opacity: 1; pointer-events: auto; }
205
+ }
206
+ @media (pointer: coarse) {
207
+ .chat-code { min-height: 54px; }
208
+ .chat-code-actions { padding: 8px; gap: 17px; }
209
+ .chat-code-actions .icon-btn { position: relative; }
210
+ .chat-code-actions .icon-btn::after { content: ''; position: absolute; inset: -8px; }
211
+ }
212
+ @media (prefers-reduced-motion: reduce) {
213
+ .chat-code-actions .icon-btn { transition: none; }
214
+ .chat-code-actions .icon-btn:active { scale: 1; }
215
+ }
199
216
 
200
217
  /* The chat composer is a CodeMirror editor, whose base theme lands in an unlayered style tag at
201
218
  mount. These rules stay unlayered too, and carry one class more than the theme's selectors. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adecore/agents-react",
3
- "version": "0.19.0",
3
+ "version": "0.20.1",
4
4
  "description": "React agent chat threads, composers, approvals, provider accounts and usage views.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -899,7 +899,7 @@
899
899
  "@lezer/highlight": "^1.2.3",
900
900
  "@lezer/markdown": "^1.7.2",
901
901
  "@pierre/diffs": "^1.4.3",
902
- "@adecore/agent-contracts": "0.19.0",
902
+ "@adecore/agent-contracts": "0.20.1",
903
903
  "@tanstack/react-virtual": "^3.14.13",
904
904
  "clsx": "^2.1.1",
905
905
  "lucide-react": "^1.47.0",
@@ -912,7 +912,7 @@
912
912
  "zustand": "^5.0.15"
913
913
  },
914
914
  "peerDependencies": {
915
- "@adecore/ui": "^0.19.0",
915
+ "@adecore/ui": "^0.20.1",
916
916
  "i18next": "^26.4.2",
917
917
  "react": "^19.3.0",
918
918
  "react-dom": "^19.3.0",
@@ -28,6 +28,7 @@ interface ChatOpenOptions {
28
28
  }
29
29
 
30
30
  export interface ChatSendExtras {
31
+ delivery?: 'steer' | 'queue';
31
32
  /* Paths picked with `@`; they also sit in the text, this is what the timeline highlights. */
32
33
  mentions?: string[];
33
34
  /* Skills picked with `$`; they also sit in the text, this is what the timeline chips. */
@@ -1,6 +1,7 @@
1
1
  import type { ChatItem } from '@adecore/agent-contracts';
2
2
  import type { TimelineRow } from './timeline';
3
3
  import { selectionWithin } from '@adecore/ui';
4
+ import type { FileRef } from '../../host';
4
5
 
5
6
  /* What the right-click landed on, read once when the menu opens. */
6
7
  export interface TimelineTarget {
@@ -13,6 +14,8 @@ export interface TimelineTarget {
13
14
  path: string | null;
14
15
  /* The line that file reference named, where it named one. */
15
16
  line: number | null;
17
+ /* Captured at menu opening, before the rendering scope or cwd can change. */
18
+ file?: { ref: FileRef; cwd: string | null; scopeId: string | null } | null;
16
19
  }
17
20
 
18
21
  export const EMPTY_TARGET: TimelineTarget = { selection: '', row: null, code: null, path: null, line: null };
@@ -41,12 +44,34 @@ export function withCurrentText(rows: TimelineRow[], items: Record<string, ChatI
41
44
  export function readTimelineTarget(element: HTMLElement, scroller: HTMLElement | null, rows: TimelineRow[]): TimelineTarget {
42
45
  const id = element.closest<HTMLElement>('[data-item-id]')?.dataset.itemId;
43
46
  const file = element.closest<HTMLElement>('[data-file-path]');
44
- const line = Number(file?.dataset.fileLine);
47
+ const line = positiveInteger(file?.dataset.fileLine);
48
+ const column = positiveInteger(file?.dataset.fileColumn);
49
+ const endLine = positiveInteger(file?.dataset.fileEndLine);
50
+ const path = file?.dataset.filePath ?? null;
45
51
  return {
46
52
  selection: selectionWithin(scroller),
47
53
  row: rows.find((row) => row.id === id) ?? null,
48
54
  code: element.closest('pre')?.textContent ?? null,
49
- path: file?.dataset.filePath ?? null,
50
- line: Number.isFinite(line) && line > 0 ? line : null
55
+ path,
56
+ line,
57
+ file:
58
+ path === null
59
+ ? null
60
+ : {
61
+ ref: {
62
+ path,
63
+ ...(line === null ? {} : { line }),
64
+ ...(column === null ? {} : { column }),
65
+ ...(line === null || endLine === null || endLine < line ? {} : { endLine }),
66
+ directory: file?.dataset.fileDirectory === 'true'
67
+ },
68
+ cwd: file?.closest<HTMLElement>('[data-file-cwd]')?.dataset.fileCwd || null,
69
+ scopeId: file?.closest<HTMLElement>('[data-file-scope-id]')?.dataset.fileScopeId || null
70
+ }
51
71
  };
52
72
  }
73
+
74
+ function positiveInteger(value: string | undefined): number | null {
75
+ const number = Number(value);
76
+ return Number.isSafeInteger(number) && number > 0 ? number : null;
77
+ }
@@ -368,23 +368,25 @@ export function deriveTimelineRows(items: ChatItem[], options: TimelineOptions):
368
368
  }
369
369
  const turnId = chunk.turnId;
370
370
  const turn = chunk.items.find((item): item is ChatTurnItem => item.kind === 'turn');
371
- const user = chunk.items.filter((item) => item.kind === 'user');
372
- const rest = chunk.items.filter((item) => item.kind !== 'user' && item.kind !== 'turn');
371
+ const conversation = chunk.items.filter((item) => item.kind !== 'turn');
373
372
  if (turn?.origin === 'agent') {
374
373
  // The agent started this one itself, so there is no message of the person to show above it.
375
374
  rows.push({ kind: 'turn-start', id: `start-${turnId}`, turn, label: agentTurnLabel(turn) });
376
375
  }
377
- rows.push(...rowsForItems(user, options, children));
378
376
  const active = turnId === options.activeTurnId || turn?.state === 'running';
379
- const work = rowsForItems(rest, options, children);
380
377
  if (!turn || active) {
381
- rows.push(...work);
378
+ rows.push(...rowsForItems(conversation, options, children));
382
379
  if (active) {
383
380
  rows.push({ kind: 'working', id: `working-${turnId}`, startedAt: turn?.createdAt ?? Date.now() });
384
381
  }
385
382
  continue;
386
383
  }
387
- // The closing answer stays visible; everything before it folds behind the label.
384
+ // Steering splits the conversation inside a turn. Earlier responses keep their place above it.
385
+ const lastUser = conversation.findLastIndex((item) => item.kind === 'user');
386
+ rows.push(...rowsForItems(conversation.slice(0, lastUser + 1), options, children));
387
+ const rest = conversation.slice(lastUser + 1);
388
+ const work = rowsForItems(rest, options, children);
389
+ // The closing answer stays visible; the work after the last input folds behind the label.
388
390
  const finalAssistant = lastAssistantRow(work);
389
391
  const before = finalAssistant ? work.slice(0, work.indexOf(finalAssistant)) : work;
390
392
  // A workflow runs on past the turn that launched it, so its row stays in sight until it ends.
@@ -407,7 +409,7 @@ export function deriveTimelineRows(items: ChatItem[], options: TimelineOptions):
407
409
  }
408
410
  }
409
411
  rows.push(...standing);
410
- const changed = changedFilesRow(turn, rest);
412
+ const changed = changedFilesRow(turn, conversation);
411
413
  if (changed) {
412
414
  rows.push(changed);
413
415
  }
@@ -1,5 +1,8 @@
1
- import { Fragment, memo, useContext, useEffect, useMemo, useState, type CSSProperties } from 'react';
1
+ import { Fragment, memo, useContext, useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from 'react';
2
2
  import clsx from 'clsx';
3
+ import { useTranslation } from 'react-i18next';
4
+ import { Check, Copy } from 'lucide-react';
5
+ import { ButtonGroup, IconButton } from '@adecore/ui';
3
6
  import type { BundledLanguage, BundledTheme, createHighlighter, ThemeRegistration } from 'shiki/bundle/web';
4
7
  import { IncrementalLines, type CodeToken, type Tokenize } from './code-lines';
5
8
  import { CodeStreamingContext } from './code-streaming';
@@ -10,6 +13,7 @@ type Highlighter = Awaited<ReturnType<typeof createHighlighter>>;
10
13
  type GrammarState = ReturnType<Highlighter['getLastGrammarState']>;
11
14
  // A plain language needs no grammar; shiki draws it in the theme's own foreground.
12
15
  const PLAIN = 'text';
16
+ const COPIED_MS = 1500;
13
17
 
14
18
  let highlighter: Highlighter | null = null;
15
19
  let highlighterLoad: Promise<Highlighter> | null = null;
@@ -123,9 +127,12 @@ const CodeLine = memo(function CodeLine({ tokens }: { tokens: CodeToken[] }) {
123
127
  * fence that is still open is tokenized as it grows, and the same component carries on once it
124
128
  * closes, so closing it does not draw it again.
125
129
  */
126
- export function CodeBlock({ code, lang }: { code: string; lang: string }) {
130
+ export function CodeBlock({ code, lang, actions }: { code: string; lang: string; actions?: ReactNode }) {
131
+ const { t } = useTranslation('agent-chat');
127
132
  const theme = useCodeTheme();
128
133
  const streaming = useContext(CodeStreamingContext);
134
+ const [copyState, setCopyState] = useState<'idle' | 'copying' | 'copied' | 'failed'>('idle');
135
+ const copying = useRef(false);
129
136
  // What loaded last, so a block whose theme changes draws again once the new one is in.
130
137
  const [loaded, setLoaded] = useState<string | null>(null);
131
138
  const tokenizer = tokenizerFor(lang, theme);
@@ -134,6 +141,31 @@ export function CodeBlock({ code, lang }: { code: string; lang: string }) {
134
141
  const lines = useMemo(() => (tokenizer ? new IncrementalLines(tokenizer.tokenize) : null), [tokenizer]);
135
142
  const tokens = useMemo(() => lines?.update(code, !streaming) ?? null, [lines, code, streaming]);
136
143
 
144
+ useEffect(() => {
145
+ if (copyState !== 'copied') {
146
+ return;
147
+ }
148
+ const timer = setTimeout(() => setCopyState('idle'), COPIED_MS);
149
+ return () => clearTimeout(timer);
150
+ }, [copyState]);
151
+
152
+ async function copy(): Promise<void> {
153
+ if (copying.current) {
154
+ return;
155
+ }
156
+ copying.current = true;
157
+ setCopyState('copying');
158
+ try {
159
+ // The best-effort clipboard helper cannot distinguish a refusal from success.
160
+ await navigator.clipboard.writeText(code);
161
+ setCopyState('copied');
162
+ } catch {
163
+ setCopyState('failed');
164
+ } finally {
165
+ copying.current = false;
166
+ }
167
+ }
168
+
137
169
  useEffect(() => {
138
170
  if (tokenizer !== null) {
139
171
  return;
@@ -147,29 +179,46 @@ export function CodeBlock({ code, lang }: { code: string; lang: string }) {
147
179
  };
148
180
  }, [lang, theme, tokenizer]);
149
181
 
150
- if (tokenizer === null || tokens === null) {
151
- const failed = loaded === 'failed';
152
- return (
153
- <div className={clsx('chat-code', !failed && 'invisible')} aria-hidden={failed ? undefined : true}>
154
- <pre>
155
- <code>{code}</code>
156
- </pre>
157
- </div>
158
- );
159
- }
182
+ const pending = (tokenizer === null || tokens === null) && loaded !== 'failed';
160
183
  return (
161
- <div className={clsx('chat-code', waited && WHOLE_FADE_CLASS)}>
162
- <pre style={{ color: tokenizer.fg }}>
184
+ <div className="chat-code" data-copy-state={copyState}>
185
+ <pre
186
+ className={clsx(pending && 'invisible', !pending && tokens !== null && waited && WHOLE_FADE_CLASS)}
187
+ aria-hidden={pending || undefined}
188
+ style={{ color: tokenizer?.fg }}
189
+ >
163
190
  <code>
164
- {tokens.map((line, index) => (
165
- // Lines only ever grow at the end, so the place of a line is a stable key.
166
- <Fragment key={index}>
167
- <CodeLine tokens={line} />
168
- {index < tokens.length - 1 && '\n'}
169
- </Fragment>
170
- ))}
191
+ {tokens === null
192
+ ? code
193
+ : tokens.map((line, index) => (
194
+ // Lines only ever grow at the end, so the place of a line is a stable key.
195
+ <Fragment key={index}>
196
+ <CodeLine tokens={line} />
197
+ {index < tokens.length - 1 && '\n'}
198
+ </Fragment>
199
+ ))}
171
200
  </code>
172
201
  </pre>
202
+ <ButtonGroup className="chat-code-actions" role="group" aria-label={t('code.actions')}>
203
+ <IconButton
204
+ icon={copyState === 'copied' ? Check : Copy}
205
+ size="sm"
206
+ label={copyState === 'copied' ? t('timeline.actions.copied') : t('timeline.menu.copyCode')}
207
+ busy={copyState === 'copying'}
208
+ aria-busy={copyState === 'copying'}
209
+ aria-disabled={copyState === 'copying'}
210
+ onClick={() => void copy()}
211
+ />
212
+ {actions}
213
+ </ButtonGroup>
214
+ <span className="sr-only" role="status">
215
+ {copyState === 'copied' ? t('timeline.actions.copied') : ''}
216
+ </span>
217
+ {copyState === 'failed' && (
218
+ <p className="chat-code-error" role="alert">
219
+ {t('code.copyFailed')}
220
+ </p>
221
+ )}
173
222
  </div>
174
223
  );
175
224
  }
@@ -166,6 +166,8 @@ function splitPath(path: string): { name: string; dir: string } {
166
166
  // Keep the editor mounted while a pending request takes over, so its selection and draft survive.
167
167
  export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabled, readOnly = false, providerFixed, onSend, onRetarget }: ComposerProps) {
168
168
  const { t } = useTranslation('agent-chat');
169
+ const sendDelivery = chatHost().useSendDelivery();
170
+ const canSteer = info.provider === 'claude' || info.provider === 'codex';
169
171
  const [draft, setDraft] = useState<ChatDraft>(() => readDraft(chatId));
170
172
  const [historyIndex, setHistoryIndex] = useState<number | null>(null);
171
173
  const [menuIndex, setMenuIndex] = useState(0);
@@ -602,7 +604,8 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
602
604
  setDraft((current) => ({ ...current, attachments: current.attachments.filter((_, i) => i !== index) }));
603
605
  };
604
606
 
605
- const submit = (): void => {
607
+ const submit = (alternate = false): void => {
608
+ const delivery = canSteer && (alternate ? sendDelivery === 'queue' : sendDelivery === 'steer') ? 'steer' : 'queue';
606
609
  const trimmed = text.trim();
607
610
  if (isEmptyDraft(draft) || !writable || guard.tooLong) {
608
611
  return;
@@ -633,6 +636,7 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
633
636
  return;
634
637
  }
635
638
  onSend(withQuote(quote, trimmed), {
639
+ delivery,
636
640
  mentions: presentMentions(trimmed, draft.mentions),
637
641
  skills: presentSkills(trimmed, draft.skills),
638
642
  chats: draft.chats,
@@ -871,7 +875,7 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
871
875
  view.dispatch({ changes: { from: line.from, to: line.to }, selection: { anchor: line.from }, userEvent: 'delete' });
872
876
  return true;
873
877
  }
874
- submit();
878
+ submit(e.altKey);
875
879
  return true;
876
880
  }
877
881
  const { from, to } = view.state.selection.main;
@@ -1295,13 +1299,26 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
1295
1299
  </button>
1296
1300
  </Tooltip>
1297
1301
  )}
1298
- {/* While a turn runs the same button queues the message instead of sending it. */}
1299
1302
  {(!busy || !isEmptyDraft(draft)) && (
1300
- <Tooltip label={busy ? t('composer.queueButton') : t('composer.send')} kbd={KEY_SHORTCUTS.modEnter} name>
1303
+ <Tooltip
1304
+ label={
1305
+ busy
1306
+ ? t(
1307
+ canSteer
1308
+ ? sendDelivery === 'steer'
1309
+ ? 'composer.steerButton'
1310
+ : 'composer.queueWithSteerButton'
1311
+ : 'composer.queueButton'
1312
+ )
1313
+ : t('composer.send')
1314
+ }
1315
+ kbd={KEY_SHORTCUTS.modEnter}
1316
+ name
1317
+ >
1301
1318
  <button
1302
1319
  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"
1303
1320
  disabled={isEmptyDraft(draft) || !writable || guard.tooLong}
1304
- onClick={submit}
1321
+ onClick={(event) => submit(event.altKey)}
1305
1322
  >
1306
1323
  <Icon icon={ArrowUp} size={16} />
1307
1324
  </button>