@adecore/agents-react 0.21.1 → 0.21.2

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 (97) hide show
  1. package/dist/agents/status-look.js +2 -3
  2. package/dist/chat/chat-client.d.ts +1 -1
  3. package/dist/chat/chat-client.js +2 -2
  4. package/dist/chat/drafts.js +6 -12
  5. package/dist/chat/logic/ansi.js +5 -1
  6. package/dist/chat/logic/fork.js +8 -9
  7. package/dist/chat/logic/limit.js +17 -14
  8. package/dist/chat/logic/resume-compaction.js +2 -11
  9. package/dist/chat/logic/scrubber.js +9 -18
  10. package/dist/chat/logic/timeline.js +15 -10
  11. package/dist/chat/logic/tool-catalog.js +3 -4
  12. package/dist/chat/mentions.js +5 -6
  13. package/dist/chat/runtime-modes.js +2 -4
  14. package/dist/chat/stash.js +1 -3
  15. package/dist/chat/thumbnails.js +3 -4
  16. package/dist/chat/timeline-scroll.js +39 -45
  17. package/dist/chat/ui/BookmarkSubmenu.js +1 -5
  18. package/dist/chat/ui/CodeBlock.js +2 -3
  19. package/dist/chat/ui/Composer.js +57 -69
  20. package/dist/chat/ui/Markdown.js +11 -11
  21. package/dist/chat/ui/Pickers.js +5 -9
  22. package/dist/chat/ui/QuoteButton.js +2 -3
  23. package/dist/chat/ui/RunSettings.js +8 -2
  24. package/dist/chat/ui/Scrubber.js +2 -3
  25. package/dist/chat/ui/SubagentControls.js +1 -3
  26. package/dist/chat/ui/Timeline.js +15 -18
  27. package/dist/chat/ui/TimelineMenu.js +3 -9
  28. package/dist/chat/ui/UnifiedDiff.js +3 -5
  29. package/dist/chat/ui/VisualFrame.js +3 -4
  30. package/dist/chat/ui/composer/keys.js +19 -11
  31. package/dist/chat/ui/intelligent-ui/render-context.d.ts +1 -1
  32. package/dist/chat/ui/rows/MessageRows.js +2 -3
  33. package/dist/chat/ui/rows/WorkRows.d.ts +4 -3
  34. package/dist/chat/ui/rows/WorkRows.js +19 -14
  35. package/dist/chat/visual-viewport.d.ts +1 -1
  36. package/dist/chat/visual-viewport.js +9 -8
  37. package/dist/port-transport.js +4 -8
  38. package/dist/state/chats.js +10 -14
  39. package/dist/state/usage.js +44 -49
  40. package/dist/storage.js +7 -7
  41. package/dist/theme.css +6 -14
  42. package/dist/usage/LimitsList.js +1 -2
  43. package/dist/usage/UsageBreakdown.js +1 -3
  44. package/dist/usage/UsageDialog.js +2 -4
  45. package/dist/usage/format.js +6 -6
  46. package/dist/usage/money.js +1 -5
  47. package/dist/usage/summary.js +2 -4
  48. package/package.json +4 -4
  49. package/src/agents/status-look.ts +2 -3
  50. package/src/chat/chat-client.ts +2 -3
  51. package/src/chat/drafts.ts +6 -14
  52. package/src/chat/logic/ansi.ts +5 -1
  53. package/src/chat/logic/fork.ts +8 -9
  54. package/src/chat/logic/limit.ts +17 -16
  55. package/src/chat/logic/resume-compaction.ts +2 -11
  56. package/src/chat/logic/scrubber.ts +13 -17
  57. package/src/chat/logic/timeline.ts +22 -11
  58. package/src/chat/logic/tool-catalog.ts +7 -6
  59. package/src/chat/mentions.ts +5 -6
  60. package/src/chat/runtime-modes.ts +2 -4
  61. package/src/chat/stash.ts +1 -3
  62. package/src/chat/thumbnails.ts +3 -4
  63. package/src/chat/timeline-scroll.ts +43 -52
  64. package/src/chat/ui/BookmarkSubmenu.tsx +1 -5
  65. package/src/chat/ui/CodeBlock.tsx +2 -3
  66. package/src/chat/ui/Composer.tsx +66 -86
  67. package/src/chat/ui/Markdown.tsx +13 -17
  68. package/src/chat/ui/Pickers.tsx +5 -9
  69. package/src/chat/ui/QuoteButton.tsx +2 -3
  70. package/src/chat/ui/RunSettings.tsx +9 -2
  71. package/src/chat/ui/Scrubber.tsx +2 -3
  72. package/src/chat/ui/SubagentControls.tsx +1 -3
  73. package/src/chat/ui/SubagentTimeline.tsx +1 -2
  74. package/src/chat/ui/Timeline.tsx +18 -20
  75. package/src/chat/ui/TimelineMenu.tsx +3 -9
  76. package/src/chat/ui/UnifiedDiff.tsx +3 -5
  77. package/src/chat/ui/VisualFrame.tsx +3 -4
  78. package/src/chat/ui/composer/keys.ts +21 -11
  79. package/src/chat/ui/intelligent-ui/render-context.ts +2 -2
  80. package/src/chat/ui/rehype-chips.ts +1 -1
  81. package/src/chat/ui/rehype-fade.ts +1 -1
  82. package/src/chat/ui/remark-html-as-text.ts +1 -1
  83. package/src/chat/ui/rows/MessageRows.tsx +3 -4
  84. package/src/chat/ui/rows/WorkRows.tsx +86 -69
  85. package/src/chat/visual-viewport.ts +10 -8
  86. package/src/host.ts +1 -3
  87. package/src/port-transport.ts +4 -8
  88. package/src/state/chats.ts +11 -14
  89. package/src/state/usage.ts +49 -60
  90. package/src/storage.ts +7 -7
  91. package/src/theme.css +6 -14
  92. package/src/usage/LimitsList.tsx +2 -4
  93. package/src/usage/UsageBreakdown.tsx +2 -5
  94. package/src/usage/UsageDialog.tsx +2 -4
  95. package/src/usage/format.ts +6 -6
  96. package/src/usage/money.ts +1 -5
  97. package/src/usage/summary.ts +2 -4
@@ -98,8 +98,7 @@ const PROMPT_HISTORY = 50;
98
98
  // Commands the composer handles itself; the CLI's own ones are sent through as text.
99
99
  const LOCAL_COMMANDS = ['model', 'compact', 'clear'];
100
100
 
101
- /* Files and paths dropped on the card become attachments and chips there. CodeMirror would paste a
102
- file's text, or the drag's own text, at the drop point on top of that. */
101
+ /* The card turns dropped files and paths into attachments and chips; CodeMirror would also paste their text at the drop point. */
103
102
  const LEAVE_DROPS_TO_THE_CARD = EditorView.domEventHandlers({
104
103
  drop: (event) => event.dataTransfer !== null && (event.dataTransfer.types.includes('Files') || carriesMentions(event.dataTransfer.types))
105
104
  });
@@ -158,6 +157,20 @@ function hintedPlaceholder(lead: string, joiner: string, hints: ReadonlyArray<{
158
157
  return root;
159
158
  }
160
159
 
160
+ function sendLabelKey(busy: boolean, canSteer: boolean, delivery: 'queue' | 'steer'): string {
161
+ if (!busy) {
162
+ return 'composer.send';
163
+ }
164
+ if (!canSteer) {
165
+ return 'composer.queueButton';
166
+ }
167
+ return delivery === 'steer' ? 'composer.steerButton' : 'composer.queueWithSteerButton';
168
+ }
169
+
170
+ function sameQuery(left: MentionQuery | null, right: MentionQuery | null): boolean {
171
+ return left?.start === right?.start && left?.query === right?.query;
172
+ }
173
+
161
174
  function splitPath(path: string): { name: string; dir: string } {
162
175
  const slash = path.lastIndexOf('/');
163
176
  return slash < 0 ? { name: path, dir: '' } : { name: path.slice(slash + 1), dir: path.slice(0, slash) };
@@ -196,7 +209,6 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
196
209
  const providers = useProviders((s) => s.providers);
197
210
  const { approvals, questions } = usePendingRequests(chatId);
198
211
  const order = useChatRow(chatId, (row) => row?.order);
199
- // The structure, which a delta leaves alone. The prompts and the requests are items of their own.
200
212
  const items = useChatRow(chatId, (row) => row?.structure);
201
213
  const chats = chatHost().useReferableChats();
202
214
  const placeholderLead = chatHost().useComposerPlaceholder(scope.id, chatId);
@@ -208,7 +220,7 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
208
220
  const capabilities = provider?.capabilities;
209
221
  const compaction = capabilities?.compaction;
210
222
  const dismissedTurnId = useResumeCompactionDismissal(chatId);
211
- /* The cheap half of the offer, so a chat that can never make one keeps no timer. */
223
+ // The cheap half of the offer, so a chat that can never make one keeps no timer.
212
224
  const couldOfferCompaction = compaction !== undefined && compaction !== 'none' && info.usage.contextTokens >= RESUME_COMPACTION_TOKENS;
213
225
  const now = useNow(60_000, couldOfferCompaction);
214
226
  const compactionOffer = !couldOfferCompaction
@@ -228,13 +240,9 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
228
240
  const quote = draft.quote;
229
241
  // The CLI announces its session on the first message, so anything before that is still a blank chat.
230
242
  const started = info.agentSessionId !== null || info.usage.turns > 0;
231
- /*
232
- * A chat that has started keeps its provider (the CLI holds the thread), and one opened for a
233
- * named CLI was never going to change it. Either way the models stay switchable. The CLI takes
234
- * the new one on the restart the next send does anyway. With one provider in the list the
235
- * picker drops its group headers on its own, so it reads as that CLI's own catalog.
236
- */
237
243
  const accountChoice = useAccountChoice(info.provider, info.account);
244
+ // A chat that has started keeps its provider (the CLI holds the thread), and so does one opened for a
245
+ // named CLI; its models stay switchable, since the CLI takes a new one on the restart the next send does.
238
246
  const pickable =
239
247
  started || providerFixed
240
248
  ? providers.filter((entry) => entry.kind === info.provider)
@@ -284,11 +292,11 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
284
292
  }
285
293
  let stale = false;
286
294
  const search = (): void => {
287
- const search = chatHost().searchFiles;
288
- if (search === null) {
295
+ const searchFiles = chatHost().searchFiles;
296
+ if (searchFiles === null) {
289
297
  return;
290
298
  }
291
- search(scope.id, info.cwd, mention.query, MENTION_RESULTS)
299
+ searchFiles(scope.id, info.cwd, mention.query, MENTION_RESULTS)
292
300
  .then((files) => {
293
301
  if (!stale) {
294
302
  setSearched({ query: mention.query, files });
@@ -394,15 +402,28 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
394
402
  inputRef.current?.focus();
395
403
  };
396
404
 
397
- const configure = (patch: { selection?: ModelSelection; runtimeMode?: RuntimeMode; account?: string }): void => {
405
+ const showError = (error: unknown): void => {
406
+ setNotice(error instanceof Error ? error.message : String(error));
407
+ };
408
+
409
+ /* One configuration at a time; a second pick while one is on its way is dropped. */
410
+ const runConfiguration = (work: () => Promise<void>): void => {
398
411
  if (configuring.current) {
399
412
  return;
400
413
  }
401
414
  configuring.current = true;
402
415
  setConfigurationPending(true);
403
- void actions
404
- .configure(chatId, patch)
405
- .then(() => {
416
+ void work()
417
+ .catch(showError)
418
+ .finally(() => {
419
+ configuring.current = false;
420
+ setConfigurationPending(false);
421
+ });
422
+ };
423
+
424
+ const configure = (patch: { selection?: ModelSelection; runtimeMode?: RuntimeMode; account?: string }): void => {
425
+ runConfiguration(() =>
426
+ actions.configure(chatId, patch).then(() => {
406
427
  if (patch.selection) {
407
428
  rememberChatSelection(info.provider, patch.selection);
408
429
  }
@@ -413,11 +434,7 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
413
434
  rememberChatAccount(scope.id, info.provider, patch.account);
414
435
  }
415
436
  })
416
- .catch((error: unknown) => setNotice(error instanceof Error ? error.message : String(error)))
417
- .finally(() => {
418
- configuring.current = false;
419
- setConfigurationPending(false);
420
- });
437
+ );
421
438
  };
422
439
 
423
440
  const chooseAccount = (account: string): void => configure({ account });
@@ -426,18 +443,7 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
426
443
  const chooseModel = (provider: AgentKind, slug: string): void => {
427
444
  const selection: ModelSelection = { model: slug, options: {} };
428
445
  if (provider !== info.provider) {
429
- if (configuring.current) {
430
- return;
431
- }
432
- configuring.current = true;
433
- setConfigurationPending(true);
434
- void onRetarget(provider, selection)
435
- .then(() => rememberChatSelection(provider, selection))
436
- .catch((error: unknown) => setNotice(error instanceof Error ? error.message : String(error)))
437
- .finally(() => {
438
- configuring.current = false;
439
- setConfigurationPending(false);
440
- });
446
+ runConfiguration(() => onRetarget(provider, selection).then(() => rememberChatSelection(provider, selection)));
441
447
  return;
442
448
  }
443
449
  configure({ selection });
@@ -445,14 +451,10 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
445
451
 
446
452
  const stop = (shiftKey: boolean): void => {
447
453
  if (composerStopOf(shiftKey) === 'turn') {
448
- void actions.stopTurn(chatId, false).catch((error: unknown) => setNotice(error instanceof Error ? error.message : String(error)));
454
+ void actions.stopTurn(chatId, false).catch(showError);
449
455
  return;
450
456
  }
451
- chatHost().confirm.stopSubagents(
452
- scope.id,
453
- chatId,
454
- () => void actions.stopTurn(chatId, true).catch((error: unknown) => setNotice(error instanceof Error ? error.message : String(error)))
455
- );
457
+ chatHost().confirm.stopSubagents(scope.id, chatId, () => void actions.stopTurn(chatId, true).catch(showError));
456
458
  };
457
459
 
458
460
  /* The host decides whether a turn is in the way; only its refusal asks the person first. */
@@ -493,18 +495,16 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
493
495
  }
494
496
  };
495
497
 
496
- const same = (a: MentionQuery | null, b: MentionQuery | null): boolean => a?.start === b?.start && a?.query === b?.query;
497
-
498
498
  /* Which picker the caret opens: `@` for a file, `$` for a skill, or neither. In code a sigil is just text. */
499
499
  const trackTriggers = (value: string, selection: TextRange, state: EditorState): void => {
500
500
  const empty = selection.from === selection.to;
501
501
  const tree = empty ? (ensureSyntaxTree(state, selection.from, 50) ?? syntaxTree(state)) : null;
502
502
  const caret = tree !== null && !inCode(tree, value, selection.from) ? selection.from : null;
503
503
  const skill = caret === null ? null : findSkillQuery(value, caret);
504
- setSkillQuery((current) => (same(current, skill) ? current : skill));
504
+ setSkillQuery((current) => (sameQuery(current, skill) ? current : skill));
505
505
  // A CLI that does not expand `@path` gets the text as it is, so the picker stays out of the way.
506
506
  const next = caret === null || capabilities?.mentions === false ? null : findMentionQuery(value, caret);
507
- setMention((current) => (same(current, next) ? current : next));
507
+ setMention((current) => (sameQuery(current, next) ? current : next));
508
508
  };
509
509
 
510
510
  // The inserted text reaches the editor with the render it causes, so the caret waits a frame for it.
@@ -558,8 +558,7 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
558
558
  moveCaret(result.caret);
559
559
  };
560
560
 
561
- /* Paths dragged in from a list of files. There is no mention query to insert into, so they land
562
- at the end of the prompt and join the draft's mentions, which is what `chat.send` carries. */
561
+ /* Paths dragged in from a list of files. With no mention query to insert into, they land at the end of the prompt. */
563
562
  const addMentions = (paths: string[]): void => {
564
563
  const fresh = paths.filter((path) => path !== '');
565
564
  if (fresh.length === 0 || capabilities?.mentions === false) {
@@ -752,6 +751,19 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
752
751
  return true;
753
752
  };
754
753
 
754
+ /* The arrows walk an open picker round; true when the key was one of them. */
755
+ const moveInMenu = (key: string, count: number): boolean => {
756
+ if (key === 'ArrowDown') {
757
+ setMenuIndex((i) => (i + 1) % count);
758
+ return true;
759
+ }
760
+ if (key === 'ArrowUp') {
761
+ setMenuIndex((i) => (i - 1 + count) % count);
762
+ return true;
763
+ }
764
+ return false;
765
+ };
766
+
755
767
  /* Runs before the editor's own keymap; true means the key is handled and its default prevented. */
756
768
  const onKeyDown = (e: KeyboardEvent, view: EditorView): boolean => {
757
769
  // Shift with the paste shortcut pastes a large text inline after all; any other key forgets it.
@@ -766,8 +778,7 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
766
778
  setText('', [], []);
767
779
  return true;
768
780
  }
769
- // The composer keeps the keyboard while you type, but the shortcuts that move between views,
770
- // panels and the palette stay the app's, untouched: Mod+Shift+Enter would otherwise send as well.
781
+ // The app's own shortcuts stay the app's while you type: Mod+Shift+Enter would otherwise send as well.
771
782
  if (chatHost().isAppShortcut(e)) {
772
783
  return false;
773
784
  }
@@ -785,12 +796,7 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
785
796
  return true;
786
797
  }
787
798
  if (commandMenuOpen) {
788
- if (e.key === 'ArrowDown') {
789
- setMenuIndex((i) => (i + 1) % commands.length);
790
- return true;
791
- }
792
- if (e.key === 'ArrowUp') {
793
- setMenuIndex((i) => (i - 1 + commands.length) % commands.length);
799
+ if (moveInMenu(e.key, commands.length)) {
794
800
  return true;
795
801
  }
796
802
  if (e.key === 'Tab') {
@@ -804,12 +810,7 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
804
810
  }
805
811
  }
806
812
  if (skillMenuOpen) {
807
- if (e.key === 'ArrowDown') {
808
- setMenuIndex((i) => (i + 1) % skillMatches.length);
809
- return true;
810
- }
811
- if (e.key === 'ArrowUp') {
812
- setMenuIndex((i) => (i - 1 + skillMatches.length) % skillMatches.length);
813
+ if (moveInMenu(e.key, skillMatches.length)) {
813
814
  return true;
814
815
  }
815
816
  if (e.key === 'Tab' || e.key === 'Enter') {
@@ -818,12 +819,7 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
818
819
  }
819
820
  }
820
821
  if (mentionMenuOpen && mentionCount > 0) {
821
- if (e.key === 'ArrowDown') {
822
- setMenuIndex((i) => (i + 1) % mentionCount);
823
- return true;
824
- }
825
- if (e.key === 'ArrowUp') {
826
- setMenuIndex((i) => (i - 1 + mentionCount) % mentionCount);
822
+ if (moveInMenu(e.key, mentionCount)) {
827
823
  return true;
828
824
  }
829
825
  if (e.key === 'Tab' || e.key === 'Enter') {
@@ -946,9 +942,8 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
946
942
 
947
943
  return (
948
944
  <div className="chat-composer-content pointer-events-none relative z-10 w-full">
949
- {/* Only while there is something below the fold. It sits over the composer rather than
950
- in the thread, because the composer is the one thing whose height it always clears. */}
951
- {/* A read-only composer sits under something that stands in the thread's place, whose end is not this one. */}
945
+ {/* Over the composer rather than in the thread, since the composer's height is the one it always clears.
946
+ A read-only composer sits under something that stands in the thread's place, whose end is not this one. */}
952
947
  {!atEnd && !readOnly && (
953
948
  <div className="absolute inset-x-0 bottom-full mb-2 flex justify-center">
954
949
  <Tooltip label={t('composer.jumpToEnd')} name>
@@ -969,8 +964,7 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
969
964
  ? 'border-accent bg-[color-mix(in_srgb,var(--accent-soft)_60%,var(--surface-raised))]'
970
965
  : 'border-border bg-[color-mix(in_srgb,var(--surface-raised)_92%,transparent)]'
971
966
  )}
972
- /* The whole card takes a dropped file as a mention, so a layout around it that takes drops
973
- of its own leaves it alone. */
967
+ /* The whole card takes a dropped file, so a layout around it that takes drops of its own leaves it alone. */
974
968
  data-takes-drop="all"
975
969
  onDragOver={(e) => {
976
970
  if (writable && (e.dataTransfer.types.includes('Files') || carriesMentions(e.dataTransfer.types))) {
@@ -1300,21 +1294,7 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
1300
1294
  </Tooltip>
1301
1295
  )}
1302
1296
  {(!busy || !isEmptyDraft(draft)) && (
1303
- <Tooltip
1304
- label={
1305
- busy
1306
- ? t(
1307
- canSteer
1308
- ? sendDelivery === 'steer'
1309
- ? 'composer.steerButton'
1310
- : 'composer.queueWithSteerButton'
1311
- : 'composer.queueButton'
1312
- )
1313
- : t('composer.send')
1314
- }
1315
- kbd={KEY_SHORTCUTS.modEnter}
1316
- name
1317
- >
1297
+ <Tooltip label={t(sendLabelKey(busy, canSteer, sendDelivery))} kbd={KEY_SHORTCUTS.modEnter} name>
1318
1298
  <button
1319
1299
  className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-accent text-accent-text hover:brightness-90 disabled:opacity-50 disabled:hover:brightness-100"
1320
1300
  disabled={isEmptyDraft(draft) || !writable || guard.tooLong}
@@ -67,17 +67,14 @@ function languageOf(className: string | undefined): string {
67
67
  const INLINE_CODE = 'rounded-sm bg-surface-sunken px-1 py-px font-mono text-code';
68
68
 
69
69
  /*
70
- * A path an agent wrote down, opened in the preview panel. It keeps the shape of the code chip it
71
- * would otherwise be, so a sentence full of them does not start dancing; only the color says it can
72
- * be clicked. `data-file-path` is what the thread's own right-click menu reads, so a link answers to
73
- * both ways of opening it.
70
+ * A path an agent wrote down. It keeps the shape of the code chip it would otherwise be, so a sentence
71
+ * full of them does not shift; `data-file-path` is what the thread's right-click menu reads.
74
72
  */
75
73
  function FileLink({ target, className, children }: { target: FileRef; className?: string; children: ReactNode }) {
76
74
  const cwd = useFileLinkCwd();
77
75
  const scopeId = useFileLinkScopeId();
78
76
  return (
79
- // `select-text` because a thread is copied as often as it is clicked, and a button is not
80
- // selectable on its own.
77
+ // A button is not selectable on its own, and a thread is copied as often as it is clicked.
81
78
  <button
82
79
  type="button"
83
80
  className={clsx('cursor-pointer select-text', className)}
@@ -108,8 +105,10 @@ function InlineCode({ text }: { text: string }) {
108
105
  );
109
106
  }
110
107
 
111
- /* The path a link target spells, with the one scheme that still means a file on the host's machine
112
- taken off. A href nobody encoded is left as it is, since decoding throws on a stray percent. */
108
+ /*
109
+ * The path a link target spells, without the one scheme that still means a file on the host's machine.
110
+ * A href nobody encoded is left as it is, since decoding throws on a stray percent.
111
+ */
113
112
  function hrefPath(href: string): string {
114
113
  const bare = href.replace(/^file:\/\//, '');
115
114
  try {
@@ -129,6 +128,10 @@ function MarkdownLink({ href, children }: { href?: string; children?: ReactNode
129
128
  </FileLink>
130
129
  );
131
130
  }
131
+ return <ExternalLink href={href}>{children}</ExternalLink>;
132
+ }
133
+
134
+ function ExternalLink({ href, children }: { href?: string; children?: ReactNode }) {
132
135
  return (
133
136
  <a href={href} target="_blank" rel="noreferrer">
134
137
  {children}
@@ -173,13 +176,7 @@ const plainComponents = {
173
176
  </MarkdownCode>
174
177
  );
175
178
  },
176
- a({ href, children }: { href?: string; children?: ReactNode }) {
177
- return (
178
- <a href={href} target="_blank" rel="noreferrer">
179
- {children}
180
- </a>
181
- );
182
- }
179
+ a: ExternalLink
183
180
  };
184
181
 
185
182
  // A reply sits under the heading of its message, an `h3`, so its own headings start one level below
@@ -271,8 +268,7 @@ const MESSAGE_PLUGINS = [remarkGfm, remarkHtmlAsText, remarkBreaks];
271
268
  const FADE_PLUGINS = [rehypeFadeWords];
272
269
  const NO_PLUGINS: typeof FADE_PLUGINS = [];
273
270
 
274
- /* One block of a reply. It renders no element of its own, so prose still sees the paragraphs as
275
- direct children and keeps its first and last margins. */
271
+ /* One block of a reply. It renders no element of its own, so prose keeps its first and last margins. */
276
272
  const ReplyBlock = memo(function ReplyBlock({ text, fade, open, reply }: { text: string; fade: boolean; open: boolean; reply?: ShellCodeBlockReply }) {
277
273
  return (
278
274
  // A context rather than a second set of components. A component that changed would mount the
@@ -43,11 +43,9 @@ interface ModelPickerProps {
43
43
  }
44
44
 
45
45
  /*
46
- * Which model answers: the provider's mark plus the model's short name
47
- * as the trigger, a search field over the popup, one group per provider and the legacy models
48
- * behind an expander. Before the first message the list carries every installed CLI, so picking
49
- * another provider's model is also how a chat picks its provider. A chat bound to one CLI passes
50
- * only that CLI, which drops the group headers and leaves its own catalog to choose from.
46
+ * Which model answers: a search field over one group per provider, with the legacy models behind an
47
+ * expander. Before the first message the list carries every installed CLI, so picking another
48
+ * provider's model is also how a chat picks its provider; with one CLI the group headers drop.
51
49
  */
52
50
  export function ModelPicker({
53
51
  providers,
@@ -224,10 +222,8 @@ export function ModelPicker({
224
222
  }
225
223
 
226
224
  /*
227
- * Prompts put aside with Cmd+S. The shelf is the whole app's, not this chat's. On a canvas a
228
- * stashed prompt usually moves to another node, which is the reason to put it away in the first
229
- * place. Restoring drops the text, the mentions and the skills into this composer; the files a
230
- * draft held are named on the row but not kept, because their bytes never go to storage.
225
+ * Prompts put aside with Cmd+S, on a shelf the whole app shares, since a stashed prompt usually moves
226
+ * to another chat. A draft's files are named on the row but not kept: their bytes never go to storage.
231
227
  */
232
228
  export function StashPicker({ onRestore }: { onRestore(prompt: StashedPrompt): void }) {
233
229
  const { t } = useTranslation('agent-chat');
@@ -21,9 +21,8 @@ function lastLineOf(range: Range): DOMRect | null {
21
21
  }
22
22
 
23
23
  /*
24
- * A button under a selection in an answer that hands it to the composer. Quoting on the selection
25
- * alone grew the composer while the person was still selecting, which moved the thread under the
26
- * pointer. It only shows once the pointer is up, so a drag never has it jump along.
24
+ * A button under a selection in an answer that hands it to the composer. It shows once the pointer is
25
+ * up: quoting on the selection alone grew the composer mid-drag and moved the thread under the pointer.
27
26
  */
28
27
  export function QuoteButton({
29
28
  thread,
@@ -103,6 +103,13 @@ function modelValue(provider: AgentKind, slug: string): string {
103
103
  return `${provider}/${slug}`;
104
104
  }
105
105
 
106
+ /* Each provider's current or legacy models; a provider with none of them is left out. */
107
+ function modelGroupsOf(providers: ProviderInfo[], legacy: boolean): Array<{ entry: ProviderInfo; models: ModelInfo[] }> {
108
+ return providers
109
+ .map((entry) => ({ entry, models: entry.models.filter((row) => (row.legacy === true) === legacy) }))
110
+ .filter((group) => group.models.length > 0);
111
+ }
112
+
106
113
  /*
107
114
  * Everything that decides how the next turn runs, behind one pill: the model, its own knobs and the
108
115
  * permission mode. Model and effective permissions stay visible as the composer narrows.
@@ -142,8 +149,8 @@ export function RunSettings({
142
149
  summary.push({ id: CONTEXT_OPTION, label: formatTokens(usage.contextWindow ?? 0), ring: true });
143
150
  }
144
151
  const grouped = providers.length > 1;
145
- const live = providers.map((entry) => ({ entry, models: entry.models.filter((row) => !row.legacy) })).filter((group) => group.models.length > 0);
146
- const legacy = providers.map((entry) => ({ entry, models: entry.models.filter((row) => row.legacy) })).filter((group) => group.models.length > 0);
152
+ const live = modelGroupsOf(providers, false);
153
+ const legacy = modelGroupsOf(providers, true);
147
154
  const chosenLegacy = owner?.models.find((row) => row.legacy && row.slug === selection.model);
148
155
  const chosenModel = modelValue(provider, selection.model);
149
156
  const shownMode = effectiveRuntimeMode ?? runtimeMode;
@@ -70,9 +70,8 @@ const NOTHING_FOUND: readonly boolean[] = [];
70
70
 
71
71
  /*
72
72
  * The strip at the left edge of a chat view: a tick per message of the person, per wake by tasks and
73
- * per other message with a bookmark, the ones on screen bright and the rest dimmed, a bookmark in the accent, growing only under the pointer. A single hover card follows the pointer along the strip, anchored at the tick
74
- * it points at, so a thousand ticks are a thousand spans and not a thousand popups. While a find is
75
- * open, a short mark at the strip's right edge stands beside every tick with a hit under it.
73
+ * per other bookmarked message, the ones on screen bright. One hover card follows the pointer, so a
74
+ * thousand ticks are a thousand spans and not a thousand popups. An open find marks every tick it hits.
76
75
  */
77
76
  export const Scrubber = memo(function Scrubber({
78
77
  ticks,
@@ -8,9 +8,7 @@ import { Icon, IconButton } from '@adecore/ui';
8
8
 
9
9
  const CRUMB_LINK = 'truncate text-text-muted hover:text-text';
10
10
 
11
- /*
12
- * The chat's own title where the bar draws it. While a sub-agent stands in the chat's place it is the first crumb of the way down, so pressing it goes back to the main agent.
13
- */
11
+ /* The chat's own title in the bar; while a sub-agent stands in the chat's place, pressing it goes back to the main agent. */
14
12
  export function SubagentTitleCrumb({ chatId, className, children }: { chatId: string; className?: string; children: ReactNode }) {
15
13
  const { trail, show } = useSubagentTrail(chatId);
16
14
  if (trail.length === 0) {
@@ -136,8 +136,7 @@ export function SubagentTimeline({ chatId, toolUseId }: { chatId: string; toolUs
136
136
  loadEarlier();
137
137
  }}
138
138
  >
139
- {/* The same menu the chat's own thread has; this transcript is read-only, so the chat
140
- it belongs to is not passed on and the fork item stays out. */}
139
+ {/* The chat's own menu; this transcript is read-only, so no chat is passed and the fork item stays out. */}
141
140
  <ContextMenu.Root>
142
141
  <ContextMenu.Trigger
143
142
  ref={threadRef}
@@ -1,6 +1,6 @@
1
- import { type CSSProperties, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from 'react';
1
+ import { type CSSProperties, type RefObject, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from 'react';
2
2
  import { useTranslation } from 'react-i18next';
3
- import { useVirtualizer } from '@tanstack/react-virtual';
3
+ import { useVirtualizer, type Virtualizer } from '@tanstack/react-virtual';
4
4
  import clsx from 'clsx';
5
5
  import { LoaderCircle } from 'lucide-react';
6
6
  import type { ChatBookmark } from '@adecore/agent-contracts';
@@ -67,6 +67,12 @@ import { ContextMenu, EmptyState, ErrorBoundary, Icon, SectionLabel } from '@ade
67
67
 
68
68
  const ESTIMATED_ROW_PX = 56;
69
69
 
70
+ /* A jump puts its row at the top and stops following the end, so the reader stays where they landed. */
71
+ function showRowAtTop(virtualizer: Virtualizer<HTMLDivElement, Element>, followRef: RefObject<boolean>, index: number): void {
72
+ followRef.current = false;
73
+ virtualizer.scrollToIndex(index, { align: 'start' });
74
+ }
75
+
70
76
  /*
71
77
  * A thread before its first message: which model answers, what the lines into this node let it read,
72
78
  * and the folder it works in, so a first question can lean on all three.
@@ -288,7 +294,7 @@ export function Timeline({ chatId, composer, overlay }: { chatId: string; compos
288
294
  [chatId]
289
295
  );
290
296
 
291
- // The client does not run the React Compiler, so its memoization rule has nothing to break here.
297
+ // Nothing here builds with the React Compiler, so its memoization rule has nothing to break.
292
298
  // oxlint-disable-next-line react/incompatible-library
293
299
  const virtualizer = useVirtualizer({
294
300
  count: rows.length,
@@ -307,11 +313,9 @@ export function Timeline({ chatId, composer, overlay }: { chatId: string; compos
307
313
  });
308
314
  const tail = lastReplyLength ?? rows.length;
309
315
  /*
310
- * The rows are measured as they render, so the end of the list moves while it is drawn: a diff
311
- * that highlights, an image that loads, a tool row that grows a line of output. `scrollToIndex`
312
- * aims at where the row was when it was asked, which is what left the thread short of the end.
313
- * The scroller's own bottom is a fact rather than an estimate, and the total size changing is
314
- * what says a row was measured again, so this runs for every one of those.
316
+ * Rows are measured as they render, so the end moves while it is drawn (a diff that highlights, an
317
+ * image that loads). `scrollToIndex` aims at where a row was when asked and fell short; the
318
+ * scroller's own bottom is a fact, and a new total size says a row was measured again.
315
319
  */
316
320
  const totalSize = virtualizer.getTotalSize();
317
321
  useLayoutEffect(() => {
@@ -361,8 +365,7 @@ export function Timeline({ chatId, composer, overlay }: { chatId: string; compos
361
365
 
362
366
  /*
363
367
  * Where each message starts, from the virtualizer's measurements (an estimate for a row it never
364
- * drew), so reading the scroll position costs no layout. The virtualizer renders this component on
365
- * every scroll already.
368
+ * drew), so reading the scroll position costs no layout.
366
369
  */
367
370
  const measurements = virtualizer.measurementsCache;
368
371
  const starts = ticks.map((tick) => measurements[tick.rowIndex]?.start ?? tick.rowIndex * ESTIMATED_ROW_PX);
@@ -418,8 +421,7 @@ export function Timeline({ chatId, composer, overlay }: { chatId: string; compos
418
421
  setSeeking({ itemId: tick.bookmark.itemId, key: keyOf(chatId) });
419
422
  return;
420
423
  }
421
- followRef.current = false;
422
- virtualizer.scrollToIndex(tick.rowIndex, { align: 'start' });
424
+ showRowAtTop(virtualizer, followRef, tick.rowIndex);
423
425
  };
424
426
 
425
427
  useLayoutEffect(() => {
@@ -439,8 +441,7 @@ export function Timeline({ chatId, composer, overlay }: { chatId: string; compos
439
441
  } else {
440
442
  setSeeking(null);
441
443
  if (jump.kind === 'row') {
442
- followRef.current = false;
443
- virtualizer.scrollToIndex(jump.index, { align: 'start' });
444
+ showRowAtTop(virtualizer, followRef, jump.index);
444
445
  setLanding({ target: seeking.block, key: seeking.key });
445
446
  }
446
447
  }
@@ -449,8 +450,7 @@ export function Timeline({ chatId, composer, overlay }: { chatId: string; compos
449
450
  const index = rows.findIndex((row) => row.id === seeking.itemId);
450
451
  if (index !== -1) {
451
452
  setSeeking(null);
452
- followRef.current = false;
453
- virtualizer.scrollToIndex(index, { align: 'start' });
453
+ showRowAtTop(virtualizer, followRef, index);
454
454
  return;
455
455
  }
456
456
  const item = items?.[seeking.itemId];
@@ -505,8 +505,7 @@ export function Timeline({ chatId, composer, overlay }: { chatId: string; compos
505
505
  const found = changes !== -1 ? changes : rows.findIndex((row) => row.id === opening?.id || row.id === `start-${turnId}` || row.id === `fold-${turnId}`);
506
506
  if (found !== -1) {
507
507
  setRevealing(null);
508
- followRef.current = false;
509
- virtualizer.scrollToIndex(found, { align: 'start' });
508
+ showRowAtTop(virtualizer, followRef, found);
510
509
  useTimelineFlash.getState().flash(keyOf(chatId), rows[found]!.id, changes !== -1);
511
510
  return;
512
511
  }
@@ -564,8 +563,7 @@ export function Timeline({ chatId, composer, overlay }: { chatId: string; compos
564
563
  return;
565
564
  }
566
565
  subagents.add(found.branch.id);
567
- followRef.current = false;
568
- virtualizer.scrollToIndex(found.index, { align: 'start' });
566
+ showRowAtTop(virtualizer, followRef, found.index);
569
567
  };
570
568
 
571
569
  return (
@@ -11,11 +11,7 @@ import { useChatRow } from '../../state/chats';
11
11
  import { openFileLink, useFileLinkCwd, useFileLinkScopeId } from './file-links';
12
12
  import { ContextMenu, copyText, DisabledReason, EDIT_SHORTCUTS, Icon, Kbd, selectAllWithin } from '@adecore/ui';
13
13
 
14
- /*
15
- * The menu behind a right-click in a thread. Copy is the reason it exists. Everything in a thread
16
- * is text a person may want out of it, and a whole message, a code block or the markdown an answer
17
- * was written in are each a different amount of that.
18
- */
14
+ /* The menu behind a right-click in a thread, mostly for copying: a whole message, a code block or an answer's markdown. */
19
15
  export function TimelineMenuPopup({
20
16
  target,
21
17
  thread,
@@ -88,10 +84,8 @@ export function TimelineMenuPopup({
88
84
  }
89
85
 
90
86
  /*
91
- * Place a bookmark on a message, or rename and remove the one it has: the same rows from the thread
92
- * and from the strip beside it. `onName` runs when a row opens the name field, which sits on the
93
- * bookmark's line over the message and so has to be on screen; the strip scrolls there, the thread
94
- * was clicked on it.
87
+ * Place a bookmark on a message, or rename and remove the one it has, from the thread and from the strip.
88
+ * `onName` runs when a row opens the name field, which sits over the message and so has to be on screen.
95
89
  */
96
90
  export function BookmarkMenuItems({ chatId, itemId, onName }: { chatId: string; itemId: string; onName?: () => void }) {
97
91
  const { t } = useTranslation('agent-chat');
@@ -7,9 +7,8 @@ import { useDiffTheme } from './diff-theme';
7
7
  import { fullFileDiff, type DiffContents } from './full-diff';
8
8
 
9
9
  /*
10
- * A patch the CLI reported itself (Codex writes unified diffs). The renderer needs a file header
11
- * to know what it is looking at, and a CLI that only sends hunks gets one from the path we know.
12
- * A diff without hunks at all is shown as its own lines, so nothing is ever swallowed.
10
+ * A patch the CLI reported itself. The renderer needs a file header, which a CLI that only sends hunks
11
+ * gets from the path we know; a diff without hunks is shown as its own lines, so nothing is swallowed.
13
12
  */
14
13
  function asPatch(change: ChatFileChange): string | null {
15
14
  if (!/^@@/m.test(change.diff)) {
@@ -23,8 +22,7 @@ function asPatch(change: ChatFileChange): string | null {
23
22
  return `--- ${from}\n+++ ${to}\n${change.diff}`;
24
23
  }
25
24
 
26
- /* Lets a diff with a view of its own run to the bottom of it, so the scrollbar of a short one sits
27
- there instead of under its last line; the lines themselves keep their height. */
25
+ /* Lets a diff with a view of its own run to its bottom, so a short one's scrollbar sits there and not under its last line. */
28
26
  const FILL_CSS = `
29
27
  :host { display: flex; flex-direction: column; flex-grow: 1; }
30
28
  pre { flex-grow: 1; align-content: start; }