@adecore/agents-react 0.21.1 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (145) hide show
  1. package/dist/agents/model-requirement.d.ts +6 -0
  2. package/dist/agents/model-requirement.js +26 -0
  3. package/dist/agents/status-look.js +2 -3
  4. package/dist/chat/chat-client.d.ts +3 -2
  5. package/dist/chat/chat-client.js +4 -2
  6. package/dist/chat/drafts.d.ts +2 -1
  7. package/dist/chat/drafts.js +21 -17
  8. package/dist/chat/logic/ansi.js +5 -1
  9. package/dist/chat/logic/fork.js +8 -9
  10. package/dist/chat/logic/limit.js +17 -14
  11. package/dist/chat/logic/picker-entries.d.ts +10 -0
  12. package/dist/chat/logic/picker-entries.js +19 -0
  13. package/dist/chat/logic/resume-compaction.js +2 -11
  14. package/dist/chat/logic/scrubber.js +9 -18
  15. package/dist/chat/logic/timeline.d.ts +5 -0
  16. package/dist/chat/logic/timeline.js +25 -11
  17. package/dist/chat/logic/tool-catalog.js +3 -4
  18. package/dist/chat/mentions.js +5 -6
  19. package/dist/chat/prompt-hold.d.ts +8 -0
  20. package/dist/chat/prompt-hold.js +16 -0
  21. package/dist/chat/quote.d.ts +4 -0
  22. package/dist/chat/quote.js +19 -0
  23. package/dist/chat/runtime-modes.js +2 -4
  24. package/dist/chat/stash.d.ts +8 -2
  25. package/dist/chat/stash.js +22 -8
  26. package/dist/chat/subagent-list.d.ts +7 -1
  27. package/dist/chat/subagent-list.js +13 -1
  28. package/dist/chat/thumbnails.js +3 -4
  29. package/dist/chat/timeline-scroll.d.ts +6 -2
  30. package/dist/chat/timeline-scroll.js +45 -46
  31. package/dist/chat/ui/BookmarkSubmenu.js +1 -5
  32. package/dist/chat/ui/ChatActivity.js +22 -8
  33. package/dist/chat/ui/CodeBlock.js +2 -3
  34. package/dist/chat/ui/Composer.js +160 -88
  35. package/dist/chat/ui/ComposerInput.d.ts +3 -1
  36. package/dist/chat/ui/ComposerInput.js +13 -4
  37. package/dist/chat/ui/Markdown.js +11 -11
  38. package/dist/chat/ui/Pickers.js +31 -27
  39. package/dist/chat/ui/PromptComposer.d.ts +8 -1
  40. package/dist/chat/ui/PromptComposer.js +18 -6
  41. package/dist/chat/ui/QuoteButton.js +4 -5
  42. package/dist/chat/ui/RunSettings.js +8 -2
  43. package/dist/chat/ui/Scrubber.js +2 -3
  44. package/dist/chat/ui/SubagentControls.d.ts +4 -2
  45. package/dist/chat/ui/SubagentControls.js +13 -11
  46. package/dist/chat/ui/SubagentInfo.d.ts +1 -1
  47. package/dist/chat/ui/SubagentInfo.js +9 -15
  48. package/dist/chat/ui/SubagentStopButton.js +2 -1
  49. package/dist/chat/ui/Timeline.js +39 -21
  50. package/dist/chat/ui/TimelineMenu.js +3 -9
  51. package/dist/chat/ui/UnifiedDiff.js +3 -5
  52. package/dist/chat/ui/VisualFrame.js +3 -4
  53. package/dist/chat/ui/composer/keys.js +19 -11
  54. package/dist/chat/ui/composer/menu-aria.d.ts +10 -0
  55. package/dist/chat/ui/composer/menu-aria.js +18 -0
  56. package/dist/chat/ui/intelligent-ui/render-context.d.ts +1 -1
  57. package/dist/chat/ui/quote-selection.d.ts +5 -2
  58. package/dist/chat/ui/quote-selection.js +3 -2
  59. package/dist/chat/ui/rows/MessageRows.js +26 -8
  60. package/dist/chat/ui/rows/Rows.js +1 -1
  61. package/dist/chat/ui/rows/WorkRows.d.ts +7 -4
  62. package/dist/chat/ui/rows/WorkRows.js +22 -17
  63. package/dist/chat/visual-viewport.d.ts +1 -1
  64. package/dist/chat/visual-viewport.js +9 -8
  65. package/dist/host.d.ts +1 -0
  66. package/dist/host.js +1 -0
  67. package/dist/locales/en/agent-chat.json +30 -7
  68. package/dist/locales/nl/agent-chat.json +30 -7
  69. package/dist/port-transport.js +4 -8
  70. package/dist/state/chats.d.ts +2 -1
  71. package/dist/state/chats.js +29 -15
  72. package/dist/state/usage.js +44 -49
  73. package/dist/storage.js +7 -7
  74. package/dist/theme.css +6 -14
  75. package/dist/usage/LimitsList.js +1 -2
  76. package/dist/usage/UsageBreakdown.js +1 -3
  77. package/dist/usage/UsageDialog.js +2 -4
  78. package/dist/usage/format.js +6 -6
  79. package/dist/usage/money.js +1 -5
  80. package/dist/usage/summary.js +2 -4
  81. package/package.json +24 -4
  82. package/src/agents/model-requirement.ts +36 -0
  83. package/src/agents/status-look.ts +2 -3
  84. package/src/chat/chat-client.ts +7 -3
  85. package/src/chat/drafts.ts +33 -21
  86. package/src/chat/logic/ansi.ts +5 -1
  87. package/src/chat/logic/fork.ts +8 -9
  88. package/src/chat/logic/limit.ts +17 -16
  89. package/src/chat/logic/picker-entries.ts +25 -0
  90. package/src/chat/logic/resume-compaction.ts +2 -11
  91. package/src/chat/logic/scrubber.ts +13 -17
  92. package/src/chat/logic/timeline.ts +39 -13
  93. package/src/chat/logic/tool-catalog.ts +7 -6
  94. package/src/chat/mentions.ts +5 -6
  95. package/src/chat/prompt-hold.ts +27 -0
  96. package/src/chat/quote.ts +20 -0
  97. package/src/chat/runtime-modes.ts +2 -4
  98. package/src/chat/stash.ts +32 -8
  99. package/src/chat/subagent-list.ts +19 -5
  100. package/src/chat/thumbnails.ts +3 -4
  101. package/src/chat/timeline-scroll.ts +58 -53
  102. package/src/chat/ui/BookmarkSubmenu.tsx +1 -5
  103. package/src/chat/ui/ChatActivity.tsx +35 -9
  104. package/src/chat/ui/CodeBlock.tsx +2 -3
  105. package/src/chat/ui/Composer.tsx +263 -138
  106. package/src/chat/ui/ComposerInput.tsx +18 -3
  107. package/src/chat/ui/Markdown.tsx +13 -17
  108. package/src/chat/ui/Pickers.tsx +56 -39
  109. package/src/chat/ui/PromptComposer.tsx +37 -8
  110. package/src/chat/ui/QuoteButton.tsx +5 -5
  111. package/src/chat/ui/RunSettings.tsx +9 -2
  112. package/src/chat/ui/Scrubber.tsx +2 -3
  113. package/src/chat/ui/SubagentControls.tsx +23 -22
  114. package/src/chat/ui/SubagentInfo.tsx +24 -42
  115. package/src/chat/ui/SubagentStopButton.tsx +2 -1
  116. package/src/chat/ui/SubagentTimeline.tsx +1 -2
  117. package/src/chat/ui/Timeline.tsx +44 -24
  118. package/src/chat/ui/TimelineMenu.tsx +3 -9
  119. package/src/chat/ui/UnifiedDiff.tsx +3 -5
  120. package/src/chat/ui/VisualFrame.tsx +3 -4
  121. package/src/chat/ui/composer/keys.ts +21 -11
  122. package/src/chat/ui/composer/menu-aria.ts +29 -0
  123. package/src/chat/ui/intelligent-ui/render-context.ts +2 -2
  124. package/src/chat/ui/quote-selection.ts +6 -4
  125. package/src/chat/ui/rehype-chips.ts +1 -1
  126. package/src/chat/ui/rehype-fade.ts +1 -1
  127. package/src/chat/ui/remark-html-as-text.ts +1 -1
  128. package/src/chat/ui/rows/MessageRows.tsx +47 -9
  129. package/src/chat/ui/rows/Rows.tsx +3 -1
  130. package/src/chat/ui/rows/WorkRows.tsx +101 -70
  131. package/src/chat/visual-viewport.ts +10 -8
  132. package/src/host.ts +4 -3
  133. package/src/locales/en/agent-chat.json +30 -7
  134. package/src/locales/nl/agent-chat.json +30 -7
  135. package/src/port-transport.ts +4 -8
  136. package/src/state/chats.ts +42 -16
  137. package/src/state/usage.ts +49 -60
  138. package/src/storage.ts +7 -7
  139. package/src/theme.css +6 -14
  140. package/src/usage/LimitsList.tsx +2 -4
  141. package/src/usage/UsageBreakdown.tsx +2 -5
  142. package/src/usage/UsageDialog.tsx +2 -4
  143. package/src/usage/format.ts +6 -6
  144. package/src/usage/money.ts +1 -5
  145. package/src/usage/summary.ts +2 -4
@@ -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
  });
@@ -1,23 +1,23 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useEffect, useMemo, useRef, useState } from 'react';
2
+ import { useContext, useEffect, useMemo, useRef, useState } from 'react';
3
3
  import { useTranslation } from 'react-i18next';
4
4
  import { Bookmark, ChevronDown, ChevronRight, Search, Trash2 } from 'lucide-react';
5
5
  import { AgentIcon } from '../../agents/AgentIcon.js';
6
6
  import { ProviderLogo } from '../../agents/ProviderLogo.js';
7
7
  import { agentChipOf, modelName } from '../../agents/model-name.js';
8
+ import { modelRequirementOf } from '../../agents/model-requirement.js';
9
+ import { chatHost } from '../../host.js';
10
+ import { ChatScopeContext } from '../../scope.js';
11
+ import { useReportedModels } from '../../state/chats.js';
12
+ import { pickerEntriesOf } from '../logic/picker-entries.js';
8
13
  import { forgetStashed, STASH_SHORTCUT, useStash } from '../stash.js';
9
14
  import { Icon, IconButton, Menu, Popover, Tooltip } from '@adecore/ui';
10
15
  const triggerClass = 'flex h-7 shrink-0 items-center gap-1 whitespace-nowrap rounded-md px-2 text-xs text-text-muted hover:bg-surface-hover hover:text-text data-[popup-open]:bg-surface-active data-[popup-open]:text-text';
11
16
  const chipClass = 'flex h-6 min-w-0 shrink items-center gap-1.5 whitespace-nowrap rounded-md bg-surface-hover px-2 text-xs text-text hover:bg-surface-active data-[popup-open]:bg-surface-active';
12
- function matches(model, query) {
13
- return model.name.toLowerCase().includes(query) || model.slug.toLowerCase().includes(query);
14
- }
15
17
  /*
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.
18
+ * Which model answers: a search field over one group per provider, with the legacy models behind an
19
+ * expander. Before the first message the list carries every installed CLI, so picking another
20
+ * provider's model is also how a chat picks its provider; with one CLI the group headers drop.
21
21
  */
22
22
  export function ModelPicker({ providers, provider, selection, open, onOpenChange, onChange, kbd = '/model', side = 'top', trigger = 'plain', finalFocus, footer }) {
23
23
  const { t } = useTranslation('agent-chat');
@@ -31,18 +31,13 @@ export function ModelPicker({ providers, provider, selection, open, onOpenChange
31
31
  const chip = agentChipOf(providers, provider, selection.model);
32
32
  const grouped = providers.length > 1;
33
33
  const trimmed = query.trim().toLowerCase();
34
- const entries = useMemo(() => {
35
- const rows = providers.flatMap((entry) => entry.models.map((model) => ({ kind: 'model', provider: entry, model })));
36
- if (trimmed !== '') {
37
- return rows.filter((row) => matches(row.model, trimmed));
38
- }
39
- const live = rows.filter((row) => !row.model.legacy);
40
- const legacy = rows.filter((row) => row.model.legacy);
41
- if (legacy.length === 0) {
42
- return live;
43
- }
44
- return legacyOpen ? [...live, { kind: 'legacy', count: legacy.length }, ...legacy] : [...live, { kind: 'legacy', count: legacy.length }];
45
- }, [providers, trimmed, legacyOpen]);
34
+ const entries = useMemo(() => pickerEntriesOf(providers, trimmed, legacyOpen), [providers, trimmed, legacyOpen]);
35
+ const scope = useContext(ChatScopeContext);
36
+ const machine = chatHost().useMachineLabel(scope?.id ?? '');
37
+ const reported = useReportedModels(provider);
38
+ const requirementOf = (entry) => modelRequirementOf(entry.model, entry.provider, reported);
39
+ // A CLI that is too old blocks all its models; one way to its settings under the group says it once.
40
+ const blockedKinds = new Set(entries.flatMap((entry) => (entry.kind === 'model' && requirementOf(entry) !== null ? [entry.provider.kind] : [])));
46
41
  /* Closing forgets the search, so the next open (a click or `/model`) starts on the whole list. */
47
42
  const setOpen = (next) => {
48
43
  if (!next) {
@@ -65,6 +60,9 @@ export function ModelPicker({ providers, provider, selection, open, onOpenChange
65
60
  inputRef.current?.focus();
66
61
  return;
67
62
  }
63
+ if (requirementOf(entry) !== null) {
64
+ return;
65
+ }
68
66
  onChange(entry.provider.kind, entry.model.slug);
69
67
  setOpen(false);
70
68
  };
@@ -91,15 +89,21 @@ export function ModelPicker({ providers, provider, selection, open, onOpenChange
91
89
  }
92
90
  const previous = entries[i - 1];
93
91
  const first = grouped && (previous === undefined || previous.kind !== 'model' || previous.provider.kind !== entry.provider.kind);
92
+ const requirement = requirementOf(entry);
93
+ const next = entries[i + 1];
94
+ const lastOfGroup = next === undefined || next.kind !== 'model' || next.provider.kind !== entry.provider.kind;
94
95
  const chosen = entry.provider.kind === provider && entry.model.slug === selection.model;
95
- return (_jsxs("div", { children: [first && (_jsxs(Menu.Label, { className: "flex items-center gap-1.5", children: [_jsx(AgentIcon, { kind: entry.provider.kind, size: 12 }), entry.provider.name] })), _jsxs("button", { role: "option", "aria-selected": chosen, "data-active": i === index, className: "cursor-row flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-left text-sm text-text-muted", onMouseEnter: () => setIndex(i), onClick: () => choose(entry), children: [_jsx(Menu.Check, { kind: "radio", checked: chosen }), _jsx("span", { className: "min-w-0 truncate", children: entry.model.name }), entry.model.badge && (_jsx("span", { className: "rounded bg-accent-soft px-1 text-[11px] leading-5 font-medium uppercase text-accent", children: entry.model.badge })), _jsx("span", { className: "grow" }), entry.model.legacy && _jsx("span", { className: "text-xs text-text-faint", children: t('pickers.model.legacy') })] })] }, `${entry.provider.kind}/${entry.model.slug}`));
96
+ return (_jsxs("div", { children: [first && (_jsxs(Menu.Label, { className: "flex items-center gap-1.5", children: [_jsx(AgentIcon, { kind: entry.provider.kind, size: 12 }), entry.provider.name] })), _jsxs("button", { role: "option", "aria-selected": chosen, "aria-disabled": requirement !== null || undefined, "data-active": i === index, className: `cursor-row flex w-full flex-col items-stretch gap-0.5 rounded-md px-2.5 py-1.5 text-left text-sm ${requirement === null ? 'text-text-muted' : 'text-text-faint'}`, onMouseEnter: () => setIndex(i), onClick: () => choose(entry), children: [_jsxs("span", { className: "flex w-full items-center gap-2", children: [_jsx(Menu.Check, { kind: "radio", checked: chosen }), _jsx("span", { className: "min-w-0 truncate", children: entry.model.name }), entry.model.badge && (_jsx("span", { className: "rounded bg-accent-soft px-1 text-[11px] leading-5 font-medium uppercase text-accent", children: entry.model.badge })), _jsx("span", { className: "grow" }), entry.model.legacy && _jsx("span", { className: "text-xs text-text-faint", children: t('pickers.model.legacy') })] }), requirement !== null && (_jsx("span", { className: "pl-6 text-xs text-text-faint", children: machine === null
97
+ ? t('pickers.model.requiresHere', { cli: requirement.cli, version: requirement.minVersion })
98
+ : t('pickers.model.requires', { cli: requirement.cli, version: requirement.minVersion, machine }) }))] }), lastOfGroup && blockedKinds.has(entry.provider.kind) && (_jsx("button", { type: "button", className: "cursor-row flex w-full items-center rounded-md px-2.5 py-1.5 text-left text-xs text-text-muted", onClick: () => {
99
+ setOpen(false);
100
+ chatHost().openSettings('providers');
101
+ }, children: t('pickers.model.providerSettings') }))] }, `${entry.provider.kind}/${entry.model.slug}`));
96
102
  })] }), footer !== undefined && _jsx("div", { className: "border-t border-border-soft px-3 py-2 text-xs text-text-faint", children: footer })] })] }));
97
103
  }
98
104
  /*
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.
105
+ * Prompts put aside with Cmd+S, on a shelf the whole app shares, since a stashed prompt usually moves
106
+ * to another chat. A draft's files are named on the row but not kept: their bytes never go to storage.
103
107
  */
104
108
  export function StashPicker({ onRestore }) {
105
109
  const { t } = useTranslation('agent-chat');
@@ -107,5 +111,5 @@ export function StashPicker({ onRestore }) {
107
111
  if (prompts.length === 0) {
108
112
  return null;
109
113
  }
110
- return (_jsxs(Popover.Root, { children: [_jsx(Tooltip, { label: t('pickers.stash.title'), kbd: STASH_SHORTCUT, children: _jsxs(Popover.Trigger, { className: triggerClass, children: [_jsx(Icon, { icon: Bookmark, size: 12 }), _jsx("span", { className: "tabular-nums", children: prompts.length })] }) }), _jsx(Popover.Popup, { variant: "picker", side: "top", className: "w-80", children: _jsx("div", { className: "max-h-72 overflow-auto p-1", children: prompts.map((prompt) => (_jsxs("div", { className: "group/stash flex items-start gap-1", children: [_jsxs(Popover.Close, { className: "flex min-w-0 grow flex-col gap-0.5 rounded-md px-2 py-1.5 text-left text-xs text-text-muted hover:bg-surface-hover hover:text-text", onClick: () => onRestore(prompt), children: [_jsx("span", { className: "line-clamp-2 whitespace-pre-wrap", children: prompt.text || t('pickers.stash.noText') }), prompt.attachments.length > 0 && (_jsx("span", { className: "text-text-faint", children: t('pickers.stash.files', { count: prompt.attachments.length }) }))] }), _jsx(IconButton, { icon: Trash2, size: "xs", label: t('common.action.delete'), className: "mt-1 opacity-0 group-hover/stash:opacity-100 focus-visible:opacity-100", onClick: () => forgetStashed(prompt.id) })] }, prompt.id))) }) })] }));
114
+ return (_jsxs(Popover.Root, { children: [_jsx(Tooltip, { label: t('pickers.stash.title'), kbd: STASH_SHORTCUT, children: _jsxs(Popover.Trigger, { className: triggerClass, children: [_jsx(Icon, { icon: Bookmark, size: 12 }), _jsx("span", { className: "tabular-nums", children: prompts.length })] }) }), _jsx(Popover.Popup, { variant: "picker", side: "top", className: "w-80", children: _jsx("div", { className: "max-h-72 overflow-auto p-1", children: prompts.map((prompt) => (_jsxs("div", { className: "group/stash flex items-start gap-1", children: [_jsxs(Popover.Close, { className: "flex min-w-0 grow flex-col gap-0.5 rounded-md px-2 py-1.5 text-left text-xs text-text-muted hover:bg-surface-hover hover:text-text", onClick: () => onRestore(prompt), children: [_jsx("span", { className: "line-clamp-2 whitespace-pre-wrap", children: prompt.text || prompt.quote?.text || t('pickers.stash.noText') }), prompt.attachments.length > 0 && (_jsx("span", { className: "text-text-faint", children: t('pickers.stash.files', { count: prompt.attachments.length }) }))] }), _jsx(IconButton, { icon: Trash2, size: "xs", label: t('common.action.delete'), className: "mt-1 opacity-0 group-hover/stash:opacity-100 focus-visible:opacity-100", onClick: () => forgetStashed(prompt.id) })] }, prompt.id))) }) })] }));
111
115
  }
@@ -1,6 +1,12 @@
1
1
  import { type ReactNode } from 'react';
2
+ import type { HoldRelease } from '../prompt-hold.js';
2
3
  import { type PendingPrompt } from '../../prompts/logic/prompts.js';
3
- export declare function PromptComposer({ chatId, pending, focused, onShowElsewhere, disabled, hasDraft, denyReason, onAllAnswered, children }: {
4
+ export interface PromptHold {
5
+ held: boolean;
6
+ releasedBy: HoldRelease | null;
7
+ release(reason: HoldRelease): void;
8
+ }
9
+ export declare function PromptComposer({ chatId, pending, focused, onShowElsewhere, disabled, hasDraft, denyReason, onAllAnswered, hold, children }: {
4
10
  chatId: string;
5
11
  pending: PendingPrompt[];
6
12
  focused: boolean;
@@ -9,5 +15,6 @@ export declare function PromptComposer({ chatId, pending, focused, onShowElsewhe
9
15
  hasDraft: boolean;
10
16
  denyReason: boolean;
11
17
  onAllAnswered?: () => void;
18
+ hold?: PromptHold;
12
19
  children: ReactNode;
13
20
  }): import("react").JSX.Element;
@@ -11,12 +11,12 @@ import { PromptView } from '../../prompts/ui/PromptView.js';
11
11
  import { chatHost } from '../../host.js';
12
12
  import { useChatScope } from '../../scope.js';
13
13
  import { useChatActions } from '../actions.js';
14
- import { Icon } from '@adecore/ui';
14
+ import { Button, Icon } from '@adecore/ui';
15
15
  /* The one being read stays in front; otherwise blocking before optional, oldest first, as everywhere else. */
16
16
  function pickSubject(waiting, activeId) {
17
17
  return waiting.find((subject) => promptIdOf(subject) === activeId) ?? orderPrompts(waiting, isBlockingSubject, promptCreatedAt)[0] ?? null;
18
18
  }
19
- export function PromptComposer({ chatId, pending, focused, onShowElsewhere, disabled, hasDraft, denyReason, onAllAnswered, children }) {
19
+ export function PromptComposer({ chatId, pending, focused, onShowElsewhere, disabled, hasDraft, denyReason, onAllAnswered, hold, children }) {
20
20
  const { t } = useTranslation('agent-chat');
21
21
  const { id } = useChatScope();
22
22
  const actions = useChatActions();
@@ -30,9 +30,21 @@ export function PromptComposer({ chatId, pending, focused, onShowElsewhere, disa
30
30
  const session = usePromptSession({ prompts: subjects, idOf: promptIdOf, pick: pickSubject, disabled });
31
31
  const ref = useRef(null);
32
32
  const { active, activeId: activeKey } = session;
33
- const expanded = !!active;
33
+ const asksApproval = active?.kind === 'chat' ? active.item.kind === 'approval' : active?.prompt.asks === 'approval';
34
+ const held = !!active && (hold?.held ?? false);
35
+ const expanded = !!active && !held;
36
+ // The card that waited behind the draft, so a blur that released it is not mistaken for one released long before.
37
+ const heldKey = useRef(null);
34
38
  const refocus = useFocusAfterAnswer(activeKey, ref, () => onAllAnswered?.());
35
39
  useEffect(() => {
40
+ if (held) {
41
+ heldKey.current = activeKey;
42
+ }
43
+ }, [held, activeKey]);
44
+ useEffect(() => {
45
+ if (hold?.releasedBy === 'blur' && heldKey.current === activeKey) {
46
+ return;
47
+ }
36
48
  if (focused && expanded && !elsewhere && !ref.current?.querySelector('.prompt-card')?.contains(document.activeElement)) {
37
49
  // Keys meant for the draft may still be coming, and on a button the next Space would answer the card.
38
50
  if (hasDraft) {
@@ -42,8 +54,8 @@ export function PromptComposer({ chatId, pending, focused, onShowElsewhere, disa
42
54
  focusPromptStart(ref.current);
43
55
  }
44
56
  }
45
- }, [activeKey, expanded, focused, elsewhere, hasDraft]);
46
- return (_jsxs("div", { ref: ref, onPointerDownCapture: session.onPointerDownCapture, onClickCapture: session.onClickCapture, children: [active && elsewhere && (_jsxs("button", { type: "button", className: "flex w-full items-center gap-2 px-3.5 py-3 text-left text-sm text-text-muted hover:text-text", onClick: onShowElsewhere, children: [_jsx(Icon, { icon: (active.kind === 'chat' ? active.item.kind === 'approval' : active.prompt.asks === 'approval') ? Hand : MessageCircleQuestionMark, size: 16, className: "shrink-0 text-status-needs-you" }), _jsx("span", { className: "grow", children: t('composer.waitingBelow') }), _jsx(Icon, { icon: ArrowDown, size: 16, className: "shrink-0" })] })), active && !elsewhere && (_jsx("div", { hidden: !expanded, children: _jsx(PromptView, { subject: active, draft: session.draftOf(promptIdOf(active)), onDraft: (draft) => session.setDraft(promptIdOf(active), draft), onAction: (action) => {
57
+ }, [activeKey, expanded, focused, elsewhere, hasDraft, hold?.releasedBy]);
58
+ return (_jsxs("div", { ref: ref, onPointerDownCapture: session.onPointerDownCapture, onClickCapture: session.onClickCapture, children: [active && elsewhere && (_jsxs("button", { type: "button", className: "flex w-full items-center gap-2 px-3.5 py-3 text-left text-sm text-text-muted hover:text-text", onClick: onShowElsewhere, children: [_jsx(Icon, { icon: asksApproval ? Hand : MessageCircleQuestionMark, size: 16, className: "shrink-0 text-status-needs-you" }), _jsx("span", { className: "grow", children: t('composer.waitingBelow') }), _jsx(Icon, { icon: ArrowDown, size: 16, className: "shrink-0" })] })), active && !elsewhere && (_jsx("div", { hidden: !expanded, children: _jsx(PromptView, { subject: active, draft: session.draftOf(promptIdOf(active)), onDraft: (draft) => session.setDraft(promptIdOf(active), draft), onAction: (action) => {
47
59
  refocus.hold();
48
60
  void session
49
61
  .act(active, () => answerPrompt(active, action, actions))
@@ -52,5 +64,5 @@ export function PromptComposer({ chatId, pending, focused, onShowElsewhere, disa
52
64
  refocus.release();
53
65
  }
54
66
  });
55
- }, more: session.waiting.length - 1, hasDraft: hasDraft, denyReason: denyReason, disabled: disabled, sending: session.sending, error: session.errorOf(promptIdOf(active)) }, activeKey) })), _jsx("div", { hidden: expanded, children: children })] }));
67
+ }, more: session.waiting.length - 1, hasDraft: hasDraft, denyReason: denyReason, disabled: disabled, sending: session.sending, error: session.errorOf(promptIdOf(active)) }, activeKey) })), held && active && (_jsxs("div", { role: "status", className: "flex items-center gap-2 border-b border-border px-3.5 py-2.5", children: [_jsx(Icon, { icon: asksApproval ? Hand : MessageCircleQuestionMark, size: 16, className: "shrink-0 text-status-needs-you" }), _jsx("span", { className: "grow text-sm text-text-muted", children: t(asksApproval ? 'composer.held.approval' : 'composer.held.question') }), _jsx(Button, { size: "sm", onMouseDown: (e) => e.preventDefault(), onClick: () => hold?.release('show'), children: t('composer.held.show') })] })), _jsx("div", { hidden: expanded, children: children })] }));
56
68
  }
@@ -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');
@@ -36,7 +35,7 @@ export function QuoteButton({ thread, frame, taker }) {
36
35
  const zoom = box.offsetWidth > 0 ? bounds.width / box.offsetWidth : 1;
37
36
  const x = Math.min(Math.max((line.right - bounds.left) / zoom, EDGE_PX), box.offsetWidth - EDGE_PX);
38
37
  const y = (line.bottom - bounds.top) / zoom + GAP_PX;
39
- setOffer({ quote, x: Math.round(x), y: Math.round(y) });
38
+ setOffer({ quote: quote.text, itemId: quote.itemId, x: Math.round(x), y: Math.round(y) });
40
39
  };
41
40
  const press = () => {
42
41
  pressed = true;
@@ -68,7 +67,7 @@ export function QuoteButton({ thread, frame, taker }) {
68
67
  // Pressing a button moves the selection, and the selection is what it quotes.
69
68
  onMouseDown: (e) => e.preventDefault(), onClick: () => {
70
69
  document.getSelection()?.removeAllRanges();
71
- taker.current?.(offer.quote);
70
+ taker.current?.(offer.quote, offer.itemId);
72
71
  setOffer(null);
73
72
  }, children: [_jsx(Icon, { icon: Quote, size: 12 }), t('composer.quote.add')] }));
74
73
  }
@@ -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');
@@ -4,8 +4,10 @@ export declare function SubagentTitleCrumb({ chatId, className, children }: {
4
4
  className?: string;
5
5
  children: ReactNode;
6
6
  }): import("react").JSX.Element;
7
- export declare function SubagentBreadcrumb({ chatId, title, className }: {
7
+ export declare function SubagentBreadcrumb({ chatId, className }: {
8
8
  chatId: string;
9
- title?: string;
10
9
  className?: string;
11
10
  }): import("react").JSX.Element | null;
11
+ export declare function SubagentActions({ chatId }: {
12
+ chatId: string;
13
+ }): import("react").JSX.Element | null;
@@ -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) {
@@ -17,20 +15,24 @@ export function SubagentTitleCrumb({ chatId, className, children }) {
17
15
  }
18
16
  return (_jsx("button", { type: "button", className: clsx('flex min-w-0 items-center', className), onClick: () => show(MAIN_AGENT), children: children }));
19
17
  }
20
- /*
21
- * The way down after the chat's title, each step a way back up, and a close that goes straight back
22
- * to the main agent. A bar that does not draw the title itself passes it, and it opens the way.
23
- * Nothing while the main agent itself is on screen.
24
- */
25
- export function SubagentBreadcrumb({ chatId, title, className }) {
18
+ /* The way down to the sub-agent on screen, each step but the last a way back up. Nothing while the main agent is on screen. */
19
+ export function SubagentBreadcrumb({ chatId, className }) {
20
+ const { t } = useTranslation('agent-chat');
21
+ const { trail, show } = useSubagentTrail(chatId);
22
+ if (trail.length === 0) {
23
+ return null;
24
+ }
25
+ return (_jsx("nav", { "aria-label": t('subagents.title'), className: clsx('flex min-w-0 items-center gap-1', className), children: breadcrumbOf(trail).map((step) => (_jsxs(Fragment, { children: [step.depth > 1 && _jsx(Icon, { icon: ChevronRight, size: 12, className: "shrink-0 text-text-faint" }), step.current ? (_jsx("span", { "aria-current": "page", className: "min-w-0 truncate text-text", children: step.label })) : (_jsx("button", { type: "button", className: clsx(CRUMB_LINK, 'min-w-0 shrink'), onClick: () => show(trailTo(trail, step.depth)), children: step.label }))] }, step.depth))) }));
26
+ }
27
+ /* Stops the sub-agent on screen where that is on offer, and closes it straight back to the main agent. */
28
+ export function SubagentActions({ chatId }) {
26
29
  const { t } = useTranslation('agent-chat');
27
30
  const { trail, show } = useSubagentTrail(chatId);
28
31
  const subagents = useOpenableSubagents(chatId);
29
32
  if (trail.length === 0) {
30
33
  return null;
31
34
  }
32
- const steps = breadcrumbOf(trail);
33
35
  // Only a row of the chat's own thread can be stopped; a grandchild belongs to the sub-agent that opened it.
34
36
  const shown = trail.length === 1 ? subagents.find((item) => item.toolUseId === trail[0].toolUseId) : undefined;
35
- return (_jsxs("nav", { "aria-label": t('subagents.title'), className: clsx('flex min-w-0 items-center gap-1 text-xs', className), children: [title !== undefined && (_jsx("button", { type: "button", className: clsx(CRUMB_LINK, 'min-w-0 shrink'), onClick: () => show(MAIN_AGENT), children: title })), steps.map((step) => (_jsxs(Fragment, { children: [_jsx(Icon, { icon: ChevronRight, size: 12, className: "shrink-0 text-text-faint" }), step.current ? (_jsx("span", { "aria-current": "page", className: "min-w-0 truncate text-text", children: step.label })) : (_jsx("button", { type: "button", className: clsx(CRUMB_LINK, 'min-w-0 shrink'), onClick: () => show(trailTo(trail, step.depth)), children: step.label }))] }, step.depth))), shown !== undefined && _jsx(SubagentStopButton, { chatId: chatId, item: shown, size: "sm" }), _jsx(IconButton, { icon: X, size: "sm", label: t('subagents.backToMain'), onClick: () => show(MAIN_AGENT) })] }));
37
+ return (_jsxs("div", { className: "flex shrink-0 items-center gap-1", children: [shown !== undefined && _jsx(SubagentStopButton, { chatId: chatId, item: shown, size: "sm" }), _jsx(IconButton, { icon: X, size: "sm", label: t('subagents.backToMain'), onClick: () => show(MAIN_AGENT) })] }));
36
38
  }
@@ -1,4 +1,4 @@
1
1
  export declare function SubagentInfo({ chatId, toolUseId }: {
2
2
  chatId: string;
3
3
  toolUseId: string;
4
- }): import("react").JSX.Element | null;
4
+ }): import("react").JSX.Element;
@@ -1,20 +1,18 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Fragment, useState } from 'react';
3
- import clsx from 'clsx';
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Fragment } from 'react';
4
3
  import { useTranslation } from 'react-i18next';
5
- import { ChevronDown } from 'lucide-react';
6
4
  import { entryTimeOf, statusWordOf, subagentFacts, taskIdOf } from '../subagent-list.js';
7
5
  import { useSubagentItem } from '../subagent-view.js';
8
6
  import { StatusIcon } from './ChatActivity.js';
9
- import { Markdown } from './Markdown.js';
10
- import { Icon, useNow } from '@adecore/ui';
7
+ import { SubagentActions, SubagentBreadcrumb } from './SubagentControls.js';
8
+ import { useNow } from '@adecore/ui';
11
9
  import { useModelName } from '../../agents/model-name.js';
12
10
  import { useChatRow } from '../../state/chats.js';
13
11
  import { chatHost } from '../../host.js';
14
12
  import { useChatScope } from '../../scope.js';
15
13
  /*
16
- * What a sub-agent is, over its conversation: its state and time, the model it runs on and what it
17
- * spent, and the task it was given behind a fold. Nothing for a conversation whose row the thread lost.
14
+ * What a sub-agent is, over its conversation: the way down to it, its state and time, the model it runs on
15
+ * and what it spent, and the way out. Only the way down and out for a conversation whose row the thread lost.
18
16
  */
19
17
  export function SubagentInfo({ chatId, toolUseId }) {
20
18
  const { t } = useTranslation('agent-chat');
@@ -24,11 +22,7 @@ export function SubagentInfo({ chatId, toolUseId }) {
24
22
  const task = chatHost().tasks.useTask(id, item === null ? null : taskIdOf(item));
25
23
  const modelName = useModelName(provider, item?.model ?? '');
26
24
  const now = useNow(1000, item?.status === 'running');
27
- const [open, setOpen] = useState(false);
28
- if (item === null) {
29
- return null;
30
- }
31
- const word = statusWordOf(item, task);
32
- const facts = subagentFacts(item, item.model === undefined ? null : modelName);
33
- return (_jsx("section", { "aria-label": t('subagents.info.label'), className: "shrink-0 border-b border-border px-4 py-2", children: _jsxs("div", { className: "chat-column-content flex flex-col gap-2", children: [_jsxs("div", { className: "flex min-w-0 items-center gap-2 text-xs text-text-faint", children: [_jsx(StatusIcon, { word: word }), _jsx("span", { className: "sr-only", children: t(`common.status.${word}`) }), _jsx("span", { className: "shrink-0 text-text-muted tabular-nums", children: entryTimeOf(item, task, now) }), facts.map((fact) => (_jsxs(Fragment, { children: [_jsx("span", { "aria-hidden": "true", children: "\u00B7" }), _jsx("span", { className: "min-w-0 truncate", children: fact })] }, fact))), _jsx("span", { className: "grow" }), item.prompt && (_jsxs("button", { type: "button", className: "flex shrink-0 items-center gap-1.5 text-text-muted hover:text-text", "aria-expanded": open, onClick: () => setOpen((current) => !current), children: [_jsx(Icon, { icon: ChevronDown, size: 12, className: clsx('transition-transform', open && 'rotate-180') }), open ? t('subagents.info.hideTask') : t('subagents.info.showTask')] }))] }), open && item.prompt && (_jsx("div", { className: "max-h-60 overflow-auto rounded-md border border-border bg-surface-raised px-3 py-2 text-xs select-text", children: _jsx(Markdown, { text: item.prompt }) }))] }) }));
25
+ const word = item === null ? null : statusWordOf(item, task);
26
+ const facts = item === null ? [] : subagentFacts(item, item.model === undefined ? null : modelName);
27
+ return (_jsx("section", { "aria-label": t('subagents.info.label'), className: "shrink-0 border-b border-border px-4 py-1.5", children: _jsxs("div", { className: "flex min-w-0 items-center gap-2 text-xs text-text-faint", children: [_jsx(SubagentBreadcrumb, { chatId: chatId, className: "max-w-1/3 shrink-0 font-medium" }), item !== null && word !== null && (_jsxs(_Fragment, { children: [_jsx(StatusIcon, { word: word }), _jsx("span", { className: "sr-only", children: t(`common.status.${word}`) }), _jsx("span", { className: "shrink-0 text-text-muted tabular-nums", children: entryTimeOf(item, task, now) })] })), facts.map((fact) => (_jsxs(Fragment, { children: [_jsx("span", { "aria-hidden": "true", children: "\u00B7" }), _jsx("span", { className: "min-w-0 truncate", children: fact })] }, fact))), _jsx("span", { className: "grow" }), _jsx(SubagentActions, { chatId: chatId })] }) }));
34
28
  }
@@ -19,7 +19,8 @@ export function SubagentStopButton({ chatId, item, size = 'xs', className }) {
19
19
  }
20
20
  const send = () => {
21
21
  void actions.stopSubagent(chatId, item.toolUseId).catch((e) => {
22
- const description = e instanceof Error ? e.message : t('subagents.noAnswer');
22
+ const stale = e instanceof Error && 'code' in e && e.code === 'subagent-stale';
23
+ const description = stale ? t('subagents.stale') : e instanceof Error ? e.message : t('subagents.noAnswer');
23
24
  chatHost().notify({ kind: 'error', title: t('subagents.stopFailed'), description });
24
25
  });
25
26
  };
@@ -37,6 +37,13 @@ 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
+ // How long a jump waits for a thread that is still loading before it calls the item missing.
41
+ const JUMP_THREAD_WAIT_MS = 8000;
42
+ /* A jump puts its row at the top and stops following the end, so the reader stays where they landed. */
43
+ function showRowAtTop(virtualizer, followRef, index) {
44
+ followRef.current = false;
45
+ virtualizer.scrollToIndex(index, { align: 'start' });
46
+ }
40
47
  /*
41
48
  * A thread before its first message: which model answers, what the lines into this node let it read,
42
49
  * and the folder it works in, so a first question can lean on all three.
@@ -191,7 +198,7 @@ export function Timeline({ chatId, composer, overlay }) {
191
198
  useEffect(() => registerTimeline(chatId, scrollRef.current, (enabled) => {
192
199
  followRef.current = enabled;
193
200
  }, () => coveredHeightRef.current), [chatId]);
194
- // The client does not run the React Compiler, so its memoization rule has nothing to break here.
201
+ // Nothing here builds with the React Compiler, so its memoization rule has nothing to break.
195
202
  // oxlint-disable-next-line react/incompatible-library
196
203
  const virtualizer = useVirtualizer({
197
204
  count: rows.length,
@@ -209,11 +216,9 @@ export function Timeline({ chatId, composer, overlay }) {
209
216
  });
210
217
  const tail = lastReplyLength ?? rows.length;
211
218
  /*
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.
219
+ * Rows are measured as they render, so the end moves while it is drawn (a diff that highlights, an
220
+ * image that loads). `scrollToIndex` aims at where a row was when asked and fell short; the
221
+ * scroller's own bottom is a fact, and a new total size says a row was measured again.
217
222
  */
218
223
  const totalSize = virtualizer.getTotalSize();
219
224
  useLayoutEffect(() => {
@@ -259,8 +264,7 @@ export function Timeline({ chatId, composer, overlay }) {
259
264
  const paddingLeft = threadPaddingLeft(frame.width, showsScrubber);
260
265
  /*
261
266
  * 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.
267
+ * drew), so reading the scroll position costs no layout.
264
268
  */
265
269
  const measurements = virtualizer.measurementsCache;
266
270
  const starts = ticks.map((tick) => measurements[tick.rowIndex]?.start ?? tick.rowIndex * ESTIMATED_ROW_PX);
@@ -314,8 +318,7 @@ export function Timeline({ chatId, composer, overlay }) {
314
318
  setSeeking({ itemId: tick.bookmark.itemId, key: keyOf(chatId) });
315
319
  return;
316
320
  }
317
- followRef.current = false;
318
- virtualizer.scrollToIndex(tick.rowIndex, { align: 'start' });
321
+ showRowAtTop(virtualizer, followRef, tick.rowIndex);
319
322
  };
320
323
  useLayoutEffect(() => {
321
324
  if (seeking === null) {
@@ -336,8 +339,7 @@ export function Timeline({ chatId, composer, overlay }) {
336
339
  else {
337
340
  setSeeking(null);
338
341
  if (jump.kind === 'row') {
339
- followRef.current = false;
340
- virtualizer.scrollToIndex(jump.index, { align: 'start' });
342
+ showRowAtTop(virtualizer, followRef, jump.index);
341
343
  setLanding({ target: seeking.block, key: seeking.key });
342
344
  }
343
345
  }
@@ -346,11 +348,17 @@ export function Timeline({ chatId, composer, overlay }) {
346
348
  const index = rows.findIndex((row) => row.id === seeking.itemId);
347
349
  if (index !== -1) {
348
350
  setSeeking(null);
349
- followRef.current = false;
350
- virtualizer.scrollToIndex(index, { align: 'start' });
351
+ showRowAtTop(virtualizer, followRef, index);
352
+ if (seeking.options?.flash) {
353
+ useTimelineFlash.getState().flash(seeking.key, seeking.itemId, false);
354
+ }
355
+ return;
356
+ }
357
+ // The thread has not loaded yet, so nothing says the item is missing; the effect below bounds the wait.
358
+ if (items === undefined) {
351
359
  return;
352
360
  }
353
- const item = items?.[seeking.itemId];
361
+ const item = items[seeking.itemId];
354
362
  if (item === undefined && cursor !== null) {
355
363
  loadEarlier();
356
364
  return;
@@ -361,10 +369,22 @@ export function Timeline({ chatId, composer, overlay }) {
361
369
  return;
362
370
  }
363
371
  setSeeking(null);
372
+ seeking.options?.onMissing?.();
364
373
  }, [seeking, rows, items, turns, virtualizer, cursor, loadEarlier, keyOf, chatId]);
365
- useEffect(() => registerItemJumper(keyOf(chatId), (itemId) => {
374
+ const waitingForThread = seeking !== null && items === undefined;
375
+ useEffect(() => {
376
+ if (!waitingForThread) {
377
+ return;
378
+ }
379
+ const timer = window.setTimeout(() => {
380
+ setSeeking(null);
381
+ seeking?.options?.onMissing?.();
382
+ }, JUMP_THREAD_WAIT_MS);
383
+ return () => window.clearTimeout(timer);
384
+ }, [waitingForThread, seeking]);
385
+ useEffect(() => registerItemJumper(keyOf(chatId), (itemId, options) => {
366
386
  setLanding(null);
367
- setSeeking({ itemId, key: keyOf(chatId) });
387
+ setSeeking({ itemId, key: keyOf(chatId), options });
368
388
  }), [keyOf, chatId]);
369
389
  useLayoutEffect(() => {
370
390
  if (!landing || landing.key !== keyOf(chatId)) {
@@ -399,8 +419,7 @@ export function Timeline({ chatId, composer, overlay }) {
399
419
  const found = changes !== -1 ? changes : rows.findIndex((row) => row.id === opening?.id || row.id === `start-${turnId}` || row.id === `fold-${turnId}`);
400
420
  if (found !== -1) {
401
421
  setRevealing(null);
402
- followRef.current = false;
403
- virtualizer.scrollToIndex(found, { align: 'start' });
422
+ showRowAtTop(virtualizer, followRef, found);
404
423
  useTimelineFlash.getState().flash(keyOf(chatId), rows[found].id, changes !== -1);
405
424
  return;
406
425
  }
@@ -452,8 +471,7 @@ export function Timeline({ chatId, composer, overlay }) {
452
471
  return;
453
472
  }
454
473
  subagents.add(found.branch.id);
455
- followRef.current = false;
456
- virtualizer.scrollToIndex(found.index, { align: 'start' });
474
+ showRowAtTop(virtualizer, followRef, found.index);
457
475
  };
458
476
  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
477
  // 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');