@adecore/agents-react 0.21.0 → 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 (137) 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/UiBlockFrame.d.ts +14 -1
  32. package/dist/chat/ui/intelligent-ui/UiBlockFrame.js +47 -5
  33. package/dist/chat/ui/intelligent-ui/UiReply.d.ts +3 -1
  34. package/dist/chat/ui/intelligent-ui/UiReply.js +34 -30
  35. package/dist/chat/ui/intelligent-ui/block-local.d.ts +9 -0
  36. package/dist/chat/ui/intelligent-ui/block-local.js +30 -1
  37. package/dist/chat/ui/intelligent-ui/node-text.d.ts +6 -0
  38. package/dist/chat/ui/intelligent-ui/node-text.js +54 -0
  39. package/dist/chat/ui/intelligent-ui/registry.js +2 -1
  40. package/dist/chat/ui/intelligent-ui/render-context.d.ts +4 -1
  41. package/dist/chat/ui/intelligent-ui/renderers/chart.js +5 -7
  42. package/dist/chat/ui/intelligent-ui/renderers/choices.js +6 -2
  43. package/dist/chat/ui/intelligent-ui/renderers/content.js +6 -13
  44. package/dist/chat/ui/intelligent-ui/renderers/data.js +8 -15
  45. package/dist/chat/ui/intelligent-ui/renderers/inputs.d.ts +1 -0
  46. package/dist/chat/ui/intelligent-ui/renderers/inputs.js +16 -4
  47. package/dist/chat/ui/intelligent-ui/renderers/links.js +2 -1
  48. package/dist/chat/ui/intelligent-ui/use-ui-links.d.ts +4 -4
  49. package/dist/chat/ui/intelligent-ui/use-ui-links.js +29 -10
  50. package/dist/chat/ui/intelligent-ui/use-ui-queries.d.ts +2 -1
  51. package/dist/chat/ui/intelligent-ui/use-ui-queries.js +34 -21
  52. package/dist/chat/ui/rows/MessageRows.js +3 -4
  53. package/dist/chat/ui/rows/WorkRows.d.ts +4 -3
  54. package/dist/chat/ui/rows/WorkRows.js +19 -14
  55. package/dist/chat/visual-viewport.d.ts +1 -1
  56. package/dist/chat/visual-viewport.js +9 -8
  57. package/dist/locales/en/agent-chat.json +23 -4
  58. package/dist/locales/nl/agent-chat.json +23 -4
  59. package/dist/port-transport.js +4 -8
  60. package/dist/state/chats.js +15 -15
  61. package/dist/state/usage.js +44 -49
  62. package/dist/storage.js +7 -7
  63. package/dist/theme.css +6 -14
  64. package/dist/usage/LimitsList.js +1 -2
  65. package/dist/usage/UsageBreakdown.js +1 -3
  66. package/dist/usage/UsageDialog.js +2 -4
  67. package/dist/usage/format.js +6 -6
  68. package/dist/usage/money.js +1 -5
  69. package/dist/usage/summary.js +2 -4
  70. package/package.json +24 -29
  71. package/src/agents/status-look.ts +2 -3
  72. package/src/chat/chat-client.ts +2 -3
  73. package/src/chat/drafts.ts +6 -14
  74. package/src/chat/logic/ansi.ts +5 -1
  75. package/src/chat/logic/fork.ts +8 -9
  76. package/src/chat/logic/limit.ts +17 -16
  77. package/src/chat/logic/resume-compaction.ts +2 -11
  78. package/src/chat/logic/scrubber.ts +13 -17
  79. package/src/chat/logic/timeline.ts +22 -11
  80. package/src/chat/logic/tool-catalog.ts +7 -6
  81. package/src/chat/mentions.ts +5 -6
  82. package/src/chat/runtime-modes.ts +2 -4
  83. package/src/chat/stash.ts +1 -3
  84. package/src/chat/thumbnails.ts +3 -4
  85. package/src/chat/timeline-scroll.ts +43 -52
  86. package/src/chat/ui/BookmarkSubmenu.tsx +1 -5
  87. package/src/chat/ui/CodeBlock.tsx +2 -3
  88. package/src/chat/ui/Composer.tsx +66 -86
  89. package/src/chat/ui/Markdown.tsx +13 -17
  90. package/src/chat/ui/Pickers.tsx +5 -9
  91. package/src/chat/ui/QuoteButton.tsx +2 -3
  92. package/src/chat/ui/RunSettings.tsx +9 -2
  93. package/src/chat/ui/Scrubber.tsx +2 -3
  94. package/src/chat/ui/SubagentControls.tsx +1 -3
  95. package/src/chat/ui/SubagentTimeline.tsx +1 -2
  96. package/src/chat/ui/Timeline.tsx +18 -20
  97. package/src/chat/ui/TimelineMenu.tsx +3 -9
  98. package/src/chat/ui/UnifiedDiff.tsx +3 -5
  99. package/src/chat/ui/VisualFrame.tsx +3 -4
  100. package/src/chat/ui/composer/keys.ts +21 -11
  101. package/src/chat/ui/intelligent-ui/UiBlockFrame.tsx +135 -5
  102. package/src/chat/ui/intelligent-ui/UiReply.tsx +48 -38
  103. package/src/chat/ui/intelligent-ui/block-local.ts +36 -1
  104. package/src/chat/ui/intelligent-ui/node-text.ts +60 -0
  105. package/src/chat/ui/intelligent-ui/registry.ts +2 -1
  106. package/src/chat/ui/intelligent-ui/render-context.ts +8 -2
  107. package/src/chat/ui/intelligent-ui/renderers/chart.tsx +5 -7
  108. package/src/chat/ui/intelligent-ui/renderers/choices.tsx +6 -2
  109. package/src/chat/ui/intelligent-ui/renderers/content.tsx +9 -14
  110. package/src/chat/ui/intelligent-ui/renderers/data.tsx +13 -19
  111. package/src/chat/ui/intelligent-ui/renderers/inputs.tsx +28 -5
  112. package/src/chat/ui/intelligent-ui/renderers/links.tsx +2 -1
  113. package/src/chat/ui/intelligent-ui/use-ui-links.ts +31 -11
  114. package/src/chat/ui/intelligent-ui/use-ui-queries.ts +48 -22
  115. package/src/chat/ui/rehype-chips.ts +1 -1
  116. package/src/chat/ui/rehype-fade.ts +1 -1
  117. package/src/chat/ui/remark-html-as-text.ts +1 -1
  118. package/src/chat/ui/rows/MessageRows.tsx +4 -5
  119. package/src/chat/ui/rows/WorkRows.tsx +86 -69
  120. package/src/chat/visual-viewport.ts +10 -8
  121. package/src/host.ts +1 -3
  122. package/src/locales/en/agent-chat.json +23 -4
  123. package/src/locales/nl/agent-chat.json +23 -4
  124. package/src/port-transport.ts +4 -8
  125. package/src/state/chats.ts +16 -15
  126. package/src/state/usage.ts +49 -60
  127. package/src/storage.ts +7 -7
  128. package/src/theme.css +6 -14
  129. package/src/usage/LimitsList.tsx +2 -4
  130. package/src/usage/UsageBreakdown.tsx +2 -5
  131. package/src/usage/UsageDialog.tsx +2 -4
  132. package/src/usage/format.ts +6 -6
  133. package/src/usage/money.ts +1 -5
  134. package/src/usage/summary.ts +2 -4
  135. package/dist/chat/ui/intelligent-ui/UiBlockFooter.d.ts +0 -15
  136. package/dist/chat/ui/intelligent-ui/UiBlockFooter.js +0 -50
  137. package/src/chat/ui/intelligent-ui/UiBlockFooter.tsx +0 -139
@@ -49,8 +49,7 @@ const NOTICE_MS = 4000;
49
49
  const PROMPT_HISTORY = 50;
50
50
  // Commands the composer handles itself; the CLI's own ones are sent through as text.
51
51
  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. */
52
+ /* The card turns dropped files and paths into attachments and chips; CodeMirror would also paste their text at the drop point. */
54
53
  const LEAVE_DROPS_TO_THE_CARD = EditorView.domEventHandlers({
55
54
  drop: (event) => event.dataTransfer !== null && (event.dataTransfer.types.includes('Files') || carriesMentions(event.dataTransfer.types))
56
55
  });
@@ -88,6 +87,18 @@ function hintedPlaceholder(lead, joiner, hints) {
88
87
  root.append(lead, extra);
89
88
  return root;
90
89
  }
90
+ function sendLabelKey(busy, canSteer, delivery) {
91
+ if (!busy) {
92
+ return 'composer.send';
93
+ }
94
+ if (!canSteer) {
95
+ return 'composer.queueButton';
96
+ }
97
+ return delivery === 'steer' ? 'composer.steerButton' : 'composer.queueWithSteerButton';
98
+ }
99
+ function sameQuery(left, right) {
100
+ return left?.start === right?.start && left?.query === right?.query;
101
+ }
91
102
  function splitPath(path) {
92
103
  const slash = path.lastIndexOf('/');
93
104
  return slash < 0 ? { name: path, dir: '' } : { name: path.slice(slash + 1), dir: path.slice(0, slash) };
@@ -125,7 +136,6 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
125
136
  const providers = useProviders((s) => s.providers);
126
137
  const { approvals, questions } = usePendingRequests(chatId);
127
138
  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
139
  const items = useChatRow(chatId, (row) => row?.structure);
130
140
  const chats = chatHost().useReferableChats();
131
141
  const placeholderLead = chatHost().useComposerPlaceholder(scope.id, chatId);
@@ -136,7 +146,7 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
136
146
  const capabilities = provider?.capabilities;
137
147
  const compaction = capabilities?.compaction;
138
148
  const dismissedTurnId = useResumeCompactionDismissal(chatId);
139
- /* The cheap half of the offer, so a chat that can never make one keeps no timer. */
149
+ // The cheap half of the offer, so a chat that can never make one keeps no timer.
140
150
  const couldOfferCompaction = compaction !== undefined && compaction !== 'none' && info.usage.contextTokens >= RESUME_COMPACTION_TOKENS;
141
151
  const now = useNow(60_000, couldOfferCompaction);
142
152
  const compactionOffer = !couldOfferCompaction
@@ -156,13 +166,9 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
156
166
  const quote = draft.quote;
157
167
  // The CLI announces its session on the first message, so anything before that is still a blank chat.
158
168
  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
169
  const accountChoice = useAccountChoice(info.provider, info.account);
170
+ // A chat that has started keeps its provider (the CLI holds the thread), and so does one opened for a
171
+ // named CLI; its models stay switchable, since the CLI takes a new one on the restart the next send does.
166
172
  const pickable = started || providerFixed
167
173
  ? providers.filter((entry) => entry.kind === info.provider)
168
174
  : providers.filter((entry) => entry.installed && entry.capabilities.chat);
@@ -197,11 +203,11 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
197
203
  }
198
204
  let stale = false;
199
205
  const search = () => {
200
- const search = chatHost().searchFiles;
201
- if (search === null) {
206
+ const searchFiles = chatHost().searchFiles;
207
+ if (searchFiles === null) {
202
208
  return;
203
209
  }
204
- search(scope.id, info.cwd, mention.query, MENTION_RESULTS)
210
+ searchFiles(scope.id, info.cwd, mention.query, MENTION_RESULTS)
205
211
  .then((files) => {
206
212
  if (!stale) {
207
213
  setSearched({ query: mention.query, files });
@@ -292,15 +298,25 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
292
298
  setDraft((current) => ({ ...current, quote: '' }));
293
299
  inputRef.current?.focus();
294
300
  };
295
- const configure = (patch) => {
301
+ const showError = (error) => {
302
+ setNotice(error instanceof Error ? error.message : String(error));
303
+ };
304
+ /* One configuration at a time; a second pick while one is on its way is dropped. */
305
+ const runConfiguration = (work) => {
296
306
  if (configuring.current) {
297
307
  return;
298
308
  }
299
309
  configuring.current = true;
300
310
  setConfigurationPending(true);
301
- void actions
302
- .configure(chatId, patch)
303
- .then(() => {
311
+ void work()
312
+ .catch(showError)
313
+ .finally(() => {
314
+ configuring.current = false;
315
+ setConfigurationPending(false);
316
+ });
317
+ };
318
+ const configure = (patch) => {
319
+ runConfiguration(() => actions.configure(chatId, patch).then(() => {
304
320
  if (patch.selection) {
305
321
  rememberChatSelection(info.provider, patch.selection);
306
322
  }
@@ -310,40 +326,24 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
310
326
  if (patch.account) {
311
327
  rememberChatAccount(scope.id, info.provider, patch.account);
312
328
  }
313
- })
314
- .catch((error) => setNotice(error instanceof Error ? error.message : String(error)))
315
- .finally(() => {
316
- configuring.current = false;
317
- setConfigurationPending(false);
318
- });
329
+ }));
319
330
  };
320
331
  const chooseAccount = (account) => configure({ account });
321
332
  /* A model of another CLI re-points the whole chat; one of this CLI's own is a configure. */
322
333
  const chooseModel = (provider, slug) => {
323
334
  const selection = { model: slug, options: {} };
324
335
  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
- });
336
+ runConfiguration(() => onRetarget(provider, selection).then(() => rememberChatSelection(provider, selection)));
337
337
  return;
338
338
  }
339
339
  configure({ selection });
340
340
  };
341
341
  const stop = (shiftKey) => {
342
342
  if (composerStopOf(shiftKey) === 'turn') {
343
- void actions.stopTurn(chatId, false).catch((error) => setNotice(error instanceof Error ? error.message : String(error)));
343
+ void actions.stopTurn(chatId, false).catch(showError);
344
344
  return;
345
345
  }
346
- chatHost().confirm.stopSubagents(scope.id, chatId, () => void actions.stopTurn(chatId, true).catch((error) => setNotice(error instanceof Error ? error.message : String(error))));
346
+ chatHost().confirm.stopSubagents(scope.id, chatId, () => void actions.stopTurn(chatId, true).catch(showError));
347
347
  };
348
348
  /* The host decides whether a turn is in the way; only its refusal asks the person first. */
349
349
  const clearThread = async () => {
@@ -380,17 +380,16 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
380
380
  return false;
381
381
  }
382
382
  };
383
- const same = (a, b) => a?.start === b?.start && a?.query === b?.query;
384
383
  /* Which picker the caret opens: `@` for a file, `$` for a skill, or neither. In code a sigil is just text. */
385
384
  const trackTriggers = (value, selection, state) => {
386
385
  const empty = selection.from === selection.to;
387
386
  const tree = empty ? (ensureSyntaxTree(state, selection.from, 50) ?? syntaxTree(state)) : null;
388
387
  const caret = tree !== null && !inCode(tree, value, selection.from) ? selection.from : null;
389
388
  const skill = caret === null ? null : findSkillQuery(value, caret);
390
- setSkillQuery((current) => (same(current, skill) ? current : skill));
389
+ setSkillQuery((current) => (sameQuery(current, skill) ? current : skill));
391
390
  // A CLI that does not expand `@path` gets the text as it is, so the picker stays out of the way.
392
391
  const next = caret === null || capabilities?.mentions === false ? null : findMentionQuery(value, caret);
393
- setMention((current) => (same(current, next) ? current : next));
392
+ setMention((current) => (sameQuery(current, next) ? current : next));
394
393
  };
395
394
  // The inserted text reaches the editor with the render it causes, so the caret waits a frame for it.
396
395
  const moveCaret = (caret) => {
@@ -438,8 +437,7 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
438
437
  setSkillQuery(null);
439
438
  moveCaret(result.caret);
440
439
  };
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. */
440
+ /* Paths dragged in from a list of files. With no mention query to insert into, they land at the end of the prompt. */
443
441
  const addMentions = (paths) => {
444
442
  const fresh = paths.filter((path) => path !== '');
445
443
  if (fresh.length === 0 || capabilities?.mentions === false) {
@@ -620,6 +618,18 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
620
618
  setText(history[next].text, history[next].mentions);
621
619
  return true;
622
620
  };
621
+ /* The arrows walk an open picker round; true when the key was one of them. */
622
+ const moveInMenu = (key, count) => {
623
+ if (key === 'ArrowDown') {
624
+ setMenuIndex((i) => (i + 1) % count);
625
+ return true;
626
+ }
627
+ if (key === 'ArrowUp') {
628
+ setMenuIndex((i) => (i - 1 + count) % count);
629
+ return true;
630
+ }
631
+ return false;
632
+ };
623
633
  /* Runs before the editor's own keymap; true means the key is handled and its default prevented. */
624
634
  const onKeyDown = (e, view) => {
625
635
  // Shift with the paste shortcut pastes a large text inline after all; any other key forgets it.
@@ -634,8 +644,7 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
634
644
  setText('', [], []);
635
645
  return true;
636
646
  }
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.
647
+ // The app's own shortcuts stay the app's while you type: Mod+Shift+Enter would otherwise send as well.
639
648
  if (chatHost().isAppShortcut(e)) {
640
649
  return false;
641
650
  }
@@ -653,12 +662,7 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
653
662
  return true;
654
663
  }
655
664
  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);
665
+ if (moveInMenu(e.key, commands.length)) {
662
666
  return true;
663
667
  }
664
668
  if (e.key === 'Tab') {
@@ -673,12 +677,7 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
673
677
  }
674
678
  }
675
679
  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);
680
+ if (moveInMenu(e.key, skillMatches.length)) {
682
681
  return true;
683
682
  }
684
683
  if (e.key === 'Tab' || e.key === 'Enter') {
@@ -687,12 +686,7 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
687
686
  }
688
687
  }
689
688
  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);
689
+ if (moveInMenu(e.key, mentionCount)) {
696
690
  return true;
697
691
  }
698
692
  if (e.key === 'Tab' || e.key === 'Enter') {
@@ -856,11 +850,5 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
856
850
  addFiles(Array.from(e.target.files ?? []));
857
851
  e.target.value = '';
858
852
  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) })] }));
853
+ } }), _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
854
  }
@@ -54,10 +54,8 @@ function languageOf(className) {
54
54
  }
55
55
  const INLINE_CODE = 'rounded-sm bg-surface-sunken px-1 py-px font-mono text-code';
56
56
  /*
57
- * A path an agent wrote down, opened in the preview panel. It keeps the shape of the code chip it
58
- * would otherwise be, so a sentence full of them does not start dancing; only the color says it can
59
- * be clicked. `data-file-path` is what the thread's own right-click menu reads, so a link answers to
60
- * both ways of opening it.
57
+ * A path an agent wrote down. It keeps the shape of the code chip it would otherwise be, so a sentence
58
+ * full of them does not shift; `data-file-path` is what the thread's right-click menu reads.
61
59
  */
62
60
  function FileLink({ target, className, children }) {
63
61
  const cwd = useFileLinkCwd();
@@ -72,8 +70,10 @@ function InlineCode({ text }) {
72
70
  }
73
71
  return (_jsx(FileLink, { target: target, className: `${INLINE_CODE} text-accent hover:underline`, children: text }));
74
72
  }
75
- /* The path a link target spells, with the one scheme that still means a file on the host's machine
76
- taken off. A href nobody encoded is left as it is, since decoding throws on a stray percent. */
73
+ /*
74
+ * The path a link target spells, without the one scheme that still means a file on the host's machine.
75
+ * A href nobody encoded is left as it is, since decoding throws on a stray percent.
76
+ */
77
77
  function hrefPath(href) {
78
78
  const bare = href.replace(/^file:\/\//, '');
79
79
  try {
@@ -89,6 +89,9 @@ function MarkdownLink({ href, children }) {
89
89
  if (target !== null) {
90
90
  return (_jsx(FileLink, { target: target, className: "text-accent underline underline-offset-2", children: children }));
91
91
  }
92
+ return _jsx(ExternalLink, { href: href, children: children });
93
+ }
94
+ function ExternalLink({ href, children }) {
92
95
  return (_jsx("a", { href: href, target: "_blank", rel: "noreferrer", children: children }));
93
96
  }
94
97
  function MarkdownCode({ className, children, plain = false }) {
@@ -118,9 +121,7 @@ const plainComponents = {
118
121
  code({ className, children }) {
119
122
  return (_jsx(MarkdownCode, { className: className, plain: true, children: children }));
120
123
  },
121
- a({ href, children }) {
122
- return (_jsx("a", { href: href, target: "_blank", rel: "noreferrer", children: children }));
123
- }
124
+ a: ExternalLink
124
125
  };
125
126
  // A reply sits under the heading of its message, an `h3`, so its own headings start one level below
126
127
  // that. Only `aria-level` moves. The tag stays, since the styles and a copy of the thread read it.
@@ -186,8 +187,7 @@ const PLUGINS_WITH_BREAKS = [remarkGfm, remarkBreaks];
186
187
  const MESSAGE_PLUGINS = [remarkGfm, remarkHtmlAsText, remarkBreaks];
187
188
  const FADE_PLUGINS = [rehypeFadeWords];
188
189
  const NO_PLUGINS = [];
189
- /* One block of a reply. It renders no element of its own, so prose still sees the paragraphs as
190
- direct children and keeps its first and last margins. */
190
+ /* One block of a reply. It renders no element of its own, so prose keeps its first and last margins. */
191
191
  const ReplyBlock = memo(function ReplyBlock({ text, fade, open, reply }) {
192
192
  return (_jsx(CodeStreamingContext.Provider, { value: open, children: _jsx(ShellCodeBlockReplyContext.Provider, { value: reply ?? null, children: _jsx(ReactMarkdown, { remarkPlugins: REPLY_PLUGINS, rehypePlugins: fade ? FADE_PLUGINS : NO_PLUGINS, components: replyComponents, children: text }) }) }));
193
193
  });
@@ -13,11 +13,9 @@ function matches(model, query) {
13
13
  return model.name.toLowerCase().includes(query) || model.slug.toLowerCase().includes(query);
14
14
  }
15
15
  /*
16
- * Which model answers: the provider's mark plus the model's short name
17
- * as the trigger, a search field over the popup, one group per provider and the legacy models
18
- * behind an expander. Before the first message the list carries every installed CLI, so picking
19
- * another provider's model is also how a chat picks its provider. A chat bound to one CLI passes
20
- * only that CLI, which drops the group headers and leaves its own catalog to choose from.
16
+ * Which model answers: a search field over one group per provider, with the legacy models behind an
17
+ * expander. Before the first message the list carries every installed CLI, so picking another
18
+ * provider's model is also how a chat picks its provider; with one CLI the group headers drop.
21
19
  */
22
20
  export function ModelPicker({ providers, provider, selection, open, onOpenChange, onChange, kbd = '/model', side = 'top', trigger = 'plain', finalFocus, footer }) {
23
21
  const { t } = useTranslation('agent-chat');
@@ -96,10 +94,8 @@ export function ModelPicker({ providers, provider, selection, open, onOpenChange
96
94
  })] }), footer !== undefined && _jsx("div", { className: "border-t border-border-soft px-3 py-2 text-xs text-text-faint", children: footer })] })] }));
97
95
  }
98
96
  /*
99
- * Prompts put aside with Cmd+S. The shelf is the whole app's, not this chat's. On a canvas a
100
- * stashed prompt usually moves to another node, which is the reason to put it away in the first
101
- * place. Restoring drops the text, the mentions and the skills into this composer; the files a
102
- * draft held are named on the row but not kept, because their bytes never go to storage.
97
+ * Prompts put aside with Cmd+S, on a shelf the whole app shares, since a stashed prompt usually moves
98
+ * to another chat. A draft's files are named on the row but not kept: their bytes never go to storage.
103
99
  */
104
100
  export function StashPicker({ onRestore }) {
105
101
  const { t } = useTranslation('agent-chat');
@@ -13,9 +13,8 @@ function lastLineOf(range) {
13
13
  return rects.at(-1) ?? null;
14
14
  }
15
15
  /*
16
- * A button under a selection in an answer that hands it to the composer. Quoting on the selection
17
- * alone grew the composer while the person was still selecting, which moved the thread under the
18
- * pointer. It only shows once the pointer is up, so a drag never has it jump along.
16
+ * A button under a selection in an answer that hands it to the composer. It shows once the pointer is
17
+ * up: quoting on the selection alone grew the composer mid-drag and moved the thread under the pointer.
19
18
  */
20
19
  export function QuoteButton({ thread, frame, taker }) {
21
20
  const { t } = useTranslation('agent-chat');
@@ -47,6 +47,12 @@ function contextTone(fraction) {
47
47
  function modelValue(provider, slug) {
48
48
  return `${provider}/${slug}`;
49
49
  }
50
+ /* Each provider's current or legacy models; a provider with none of them is left out. */
51
+ function modelGroupsOf(providers, legacy) {
52
+ return providers
53
+ .map((entry) => ({ entry, models: entry.models.filter((row) => (row.legacy === true) === legacy) }))
54
+ .filter((group) => group.models.length > 0);
55
+ }
50
56
  /*
51
57
  * Everything that decides how the next turn runs, behind one pill: the model, its own knobs and the
52
58
  * permission mode. Model and effective permissions stay visible as the composer narrows.
@@ -68,8 +74,8 @@ export function RunSettings({ disabled = false, providers, provider, selection,
68
74
  summary.push({ id: CONTEXT_OPTION, label: formatTokens(usage.contextWindow ?? 0), ring: true });
69
75
  }
70
76
  const grouped = providers.length > 1;
71
- const live = providers.map((entry) => ({ entry, models: entry.models.filter((row) => !row.legacy) })).filter((group) => group.models.length > 0);
72
- const legacy = providers.map((entry) => ({ entry, models: entry.models.filter((row) => row.legacy) })).filter((group) => group.models.length > 0);
77
+ const live = modelGroupsOf(providers, false);
78
+ const legacy = modelGroupsOf(providers, true);
73
79
  const chosenLegacy = owner?.models.find((row) => row.legacy && row.slug === selection.model);
74
80
  const chosenModel = modelValue(provider, selection.model);
75
81
  const shownMode = effectiveRuntimeMode ?? runtimeMode;
@@ -34,9 +34,8 @@ const CARD_ACTIONS = {
34
34
  const NOTHING_FOUND = [];
35
35
  /*
36
36
  * The strip at the left edge of a chat view: a tick per message of the person, per wake by tasks and
37
- * 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
38
- * it points at, so a thousand ticks are a thousand spans and not a thousand popups. While a find is
39
- * open, a short mark at the strip's right edge stands beside every tick with a hit under it.
37
+ * per other bookmarked message, the ones on screen bright. One hover card follows the pointer, so a
38
+ * thousand ticks are a thousand spans and not a thousand popups. An open find marks every tick it hits.
40
39
  */
41
40
  export const Scrubber = memo(function Scrubber({ ticks, firstInView, lastInView, onPick, chat = null, found = NOTHING_FOUND, foundCurrent = null }) {
42
41
  const { t } = useTranslation('agent-chat');
@@ -7,9 +7,7 @@ import { breadcrumbOf, MAIN_AGENT, trailTo, useOpenableSubagents, useSubagentTra
7
7
  import { SubagentStopButton } from './SubagentStopButton.js';
8
8
  import { Icon, IconButton } from '@adecore/ui';
9
9
  const CRUMB_LINK = 'truncate text-text-muted hover:text-text';
10
- /*
11
- * 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.
12
- */
10
+ /* 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. */
13
11
  export function SubagentTitleCrumb({ chatId, className, children }) {
14
12
  const { trail, show } = useSubagentTrail(chatId);
15
13
  if (trail.length === 0) {
@@ -37,6 +37,11 @@ import { AgentIcon } from '../../agents/AgentIcon.js';
37
37
  import { useModelName } from '../../agents/model-name.js';
38
38
  import { ContextMenu, EmptyState, ErrorBoundary, Icon, SectionLabel } from '@adecore/ui';
39
39
  const ESTIMATED_ROW_PX = 56;
40
+ /* A jump puts its row at the top and stops following the end, so the reader stays where they landed. */
41
+ function showRowAtTop(virtualizer, followRef, index) {
42
+ followRef.current = false;
43
+ virtualizer.scrollToIndex(index, { align: 'start' });
44
+ }
40
45
  /*
41
46
  * A thread before its first message: which model answers, what the lines into this node let it read,
42
47
  * and the folder it works in, so a first question can lean on all three.
@@ -191,7 +196,7 @@ export function Timeline({ chatId, composer, overlay }) {
191
196
  useEffect(() => registerTimeline(chatId, scrollRef.current, (enabled) => {
192
197
  followRef.current = enabled;
193
198
  }, () => coveredHeightRef.current), [chatId]);
194
- // The client does not run the React Compiler, so its memoization rule has nothing to break here.
199
+ // Nothing here builds with the React Compiler, so its memoization rule has nothing to break.
195
200
  // oxlint-disable-next-line react/incompatible-library
196
201
  const virtualizer = useVirtualizer({
197
202
  count: rows.length,
@@ -209,11 +214,9 @@ export function Timeline({ chatId, composer, overlay }) {
209
214
  });
210
215
  const tail = lastReplyLength ?? rows.length;
211
216
  /*
212
- * The rows are measured as they render, so the end of the list moves while it is drawn: a diff
213
- * that highlights, an image that loads, a tool row that grows a line of output. `scrollToIndex`
214
- * aims at where the row was when it was asked, which is what left the thread short of the end.
215
- * The scroller's own bottom is a fact rather than an estimate, and the total size changing is
216
- * what says a row was measured again, so this runs for every one of those.
217
+ * Rows are measured as they render, so the end moves while it is drawn (a diff that highlights, an
218
+ * image that loads). `scrollToIndex` aims at where a row was when asked and fell short; the
219
+ * scroller's own bottom is a fact, and a new total size says a row was measured again.
217
220
  */
218
221
  const totalSize = virtualizer.getTotalSize();
219
222
  useLayoutEffect(() => {
@@ -259,8 +262,7 @@ export function Timeline({ chatId, composer, overlay }) {
259
262
  const paddingLeft = threadPaddingLeft(frame.width, showsScrubber);
260
263
  /*
261
264
  * Where each message starts, from the virtualizer's measurements (an estimate for a row it never
262
- * drew), so reading the scroll position costs no layout. The virtualizer renders this component on
263
- * every scroll already.
265
+ * drew), so reading the scroll position costs no layout.
264
266
  */
265
267
  const measurements = virtualizer.measurementsCache;
266
268
  const starts = ticks.map((tick) => measurements[tick.rowIndex]?.start ?? tick.rowIndex * ESTIMATED_ROW_PX);
@@ -314,8 +316,7 @@ export function Timeline({ chatId, composer, overlay }) {
314
316
  setSeeking({ itemId: tick.bookmark.itemId, key: keyOf(chatId) });
315
317
  return;
316
318
  }
317
- followRef.current = false;
318
- virtualizer.scrollToIndex(tick.rowIndex, { align: 'start' });
319
+ showRowAtTop(virtualizer, followRef, tick.rowIndex);
319
320
  };
320
321
  useLayoutEffect(() => {
321
322
  if (seeking === null) {
@@ -336,8 +337,7 @@ export function Timeline({ chatId, composer, overlay }) {
336
337
  else {
337
338
  setSeeking(null);
338
339
  if (jump.kind === 'row') {
339
- followRef.current = false;
340
- virtualizer.scrollToIndex(jump.index, { align: 'start' });
340
+ showRowAtTop(virtualizer, followRef, jump.index);
341
341
  setLanding({ target: seeking.block, key: seeking.key });
342
342
  }
343
343
  }
@@ -346,8 +346,7 @@ export function Timeline({ chatId, composer, overlay }) {
346
346
  const index = rows.findIndex((row) => row.id === seeking.itemId);
347
347
  if (index !== -1) {
348
348
  setSeeking(null);
349
- followRef.current = false;
350
- virtualizer.scrollToIndex(index, { align: 'start' });
349
+ showRowAtTop(virtualizer, followRef, index);
351
350
  return;
352
351
  }
353
352
  const item = items?.[seeking.itemId];
@@ -399,8 +398,7 @@ export function Timeline({ chatId, composer, overlay }) {
399
398
  const found = changes !== -1 ? changes : rows.findIndex((row) => row.id === opening?.id || row.id === `start-${turnId}` || row.id === `fold-${turnId}`);
400
399
  if (found !== -1) {
401
400
  setRevealing(null);
402
- followRef.current = false;
403
- virtualizer.scrollToIndex(found, { align: 'start' });
401
+ showRowAtTop(virtualizer, followRef, found);
404
402
  useTimelineFlash.getState().flash(keyOf(chatId), rows[found].id, changes !== -1);
405
403
  return;
406
404
  }
@@ -452,8 +450,7 @@ export function Timeline({ chatId, composer, overlay }) {
452
450
  return;
453
451
  }
454
452
  subagents.add(found.branch.id);
455
- followRef.current = false;
456
- virtualizer.scrollToIndex(found.index, { align: 'start' });
453
+ showRowAtTop(virtualizer, followRef, found.index);
457
454
  };
458
455
  return (_jsx(FileLinkContext.Provider, { value: info?.cwd ?? null, children: _jsxs("div", { ref: frameRef, className: "relative flex min-h-0 grow flex-col", "data-file-cwd": info?.cwd ?? '', "data-file-scope-id": scope.id, children: [searching && chatFind.bar, _jsxs(ContextMenu.Root, { children: [_jsx("div", { ref: scrollRef,
459
456
  // No CSS scroll-padding here: Chromium counts the sticky composer's caret as hidden behind
@@ -9,11 +9,7 @@ import { useChatScope } from '../../scope.js';
9
9
  import { useChatRow } from '../../state/chats.js';
10
10
  import { openFileLink, useFileLinkCwd, useFileLinkScopeId } from './file-links.js';
11
11
  import { ContextMenu, copyText, DisabledReason, EDIT_SHORTCUTS, Icon, Kbd, selectAllWithin } from '@adecore/ui';
12
- /*
13
- * The menu behind a right-click in a thread. Copy is the reason it exists. Everything in a thread
14
- * is text a person may want out of it, and a whole message, a code block or the markdown an answer
15
- * was written in are each a different amount of that.
16
- */
12
+ /* The menu behind a right-click in a thread, mostly for copying: a whole message, a code block or an answer's markdown. */
17
13
  export function TimelineMenuPopup({ target, thread, chatId = null }) {
18
14
  const { t } = useTranslation('agent-chat');
19
15
  const message = target.row === null ? null : messageTextOf(target.row);
@@ -36,10 +32,8 @@ export function TimelineMenuPopup({ target, thread, chatId = null }) {
36
32
  return (_jsxs(ContextMenu.Popup, { children: [_jsxs(ContextMenu.Item, { disabled: target.selection === '', onClick: () => copyText(target.selection), children: [_jsx(Icon, { icon: Copy, size: 14 }), " ", t('common.action.copy'), " ", _jsx(Kbd, { shortcut: EDIT_SHORTCUTS.copy })] }), message !== null && (_jsxs(ContextMenu.Item, { onClick: () => copyText(message), children: [_jsx(Icon, { icon: MessageSquare, size: 14 }), " ", t('timeline.menu.copyMessage')] })), target.code !== null && (_jsxs(ContextMenu.Item, { onClick: () => copyText(target.code ?? ''), children: [_jsx(Icon, { icon: Braces, size: 14 }), " ", t('timeline.menu.copyCode')] })), markdown !== null && (_jsxs(ContextMenu.Item, { onClick: () => copyText(markdown), children: [_jsx(Icon, { icon: FileText, size: 14 }), " ", t('timeline.menu.copyMarkdown')] })), chatId !== null && turnId !== null && fork !== null && (_jsx(DisabledReason, { reason: forkBlocked, children: _jsxs(ContextMenu.Item, { disabled: forkBlocked !== null, onClick: () => fork(chatId, turnId), children: [_jsx(Icon, { icon: GitFork, size: 14 }), " ", t('timeline.menu.forkFromHere')] }) })), chatId !== null && markable !== null && _jsx(BookmarkMenuItems, { chatId: chatId, itemId: markable }), _jsx(ContextMenu.Separator, {}), _jsxs(ContextMenu.Item, { onClick: () => selectAllWithin(thread.current), children: [_jsx(Icon, { icon: Scan, size: 14 }), " ", t('common.action.selectAll')] }), target.path !== null && fileLinks !== null && (_jsxs(_Fragment, { children: [_jsx(ContextMenu.Separator, {}), _jsxs(ContextMenu.Item, { onClick: openInPreview, children: [_jsx(Icon, { icon: Eye, size: 14 }), " ", t('timeline.menu.openInPreview')] })] }))] }));
37
33
  }
38
34
  /*
39
- * Place a bookmark on a message, or rename and remove the one it has: the same rows from the thread
40
- * and from the strip beside it. `onName` runs when a row opens the name field, which sits on the
41
- * bookmark's line over the message and so has to be on screen; the strip scrolls there, the thread
42
- * was clicked on it.
35
+ * Place a bookmark on a message, or rename and remove the one it has, from the thread and from the strip.
36
+ * `onName` runs when a row opens the name field, which sits over the message and so has to be on screen.
43
37
  */
44
38
  export function BookmarkMenuItems({ chatId, itemId, onName }) {
45
39
  const { t } = useTranslation('agent-chat');
@@ -6,9 +6,8 @@ import { chatHost } from '../../host.js';
6
6
  import { useDiffTheme } from './diff-theme.js';
7
7
  import { fullFileDiff } from './full-diff.js';
8
8
  /*
9
- * A patch the CLI reported itself (Codex writes unified diffs). The renderer needs a file header
10
- * to know what it is looking at, and a CLI that only sends hunks gets one from the path we know.
11
- * A diff without hunks at all is shown as its own lines, so nothing is ever swallowed.
9
+ * A patch the CLI reported itself. The renderer needs a file header, which a CLI that only sends hunks
10
+ * gets from the path we know; a diff without hunks is shown as its own lines, so nothing is swallowed.
12
11
  */
13
12
  function asPatch(change) {
14
13
  if (!/^@@/m.test(change.diff)) {
@@ -21,8 +20,7 @@ function asPatch(change) {
21
20
  const to = change.kind === 'delete' ? '/dev/null' : `b/${change.path}`;
22
21
  return `--- ${from}\n+++ ${to}\n${change.diff}`;
23
22
  }
24
- /* Lets a diff with a view of its own run to the bottom of it, so the scrollbar of a short one sits
25
- there instead of under its last line; the lines themselves keep their height. */
23
+ /* 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. */
26
24
  const FILL_CSS = `
27
25
  :host { display: flex; flex-direction: column; flex-grow: 1; }
28
26
  pre { flex-grow: 1; align-content: start; }
@@ -19,10 +19,9 @@ function personActsIn(frame) {
19
19
  return frame.ownerDocument.activeElement === frame && navigator.userActivation?.isActive !== false;
20
20
  }
21
21
  /*
22
- * One visual's page in a frame on the thread's own ground. The frame loads the app's sandbox host
23
- * page and the bridge hands it the page and the theme. The frame never gets `allow-same-origin`,
24
- * which keeps the page on an opaque origin even when the host page is served on the app's own. Its
25
- * box keeps one height while the page loads and when it fails, so nothing under it moves.
22
+ * One visual's page in a frame that loads the app's sandbox host page; the bridge hands it the page and
23
+ * the theme. The frame never gets `allow-same-origin`, which keeps the page on an opaque origin even
24
+ * when the host page is served on the app's own. Its box keeps one height while it loads or fails.
26
25
  */
27
26
  export function VisualFrame({ chatId, visual, fill = false, className }) {
28
27
  const { t } = useTranslation('agent-chat');