@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
@@ -29,9 +29,13 @@ interface ComposerInputProps {
29
29
  className?: string;
30
30
  /* Keep the reference stable. A new one reconfigures the editor. */
31
31
  extensions?: Extension;
32
+ /* Attributes for the editable element, such as its label and the option a menu points at; keep the reference stable. */
33
+ aria?: Readonly<Record<string, string>>;
32
34
  onChange(text: string, selection: InputSelection, state: EditorState): void;
33
35
  onSelectionChange?(text: string, selection: InputSelection, state: EditorState): void;
34
36
  onBlur?(): void;
37
+ /* An IME composition starts or ends; the text it builds is not in the document until it ends. */
38
+ onComposing?(composing: boolean): void;
35
39
  /* Runs before the editor's own keymap; true means the key is handled and its default prevented. */
36
40
  onKeyDown?(event: KeyboardEvent, view: EditorView): boolean;
37
41
  /* Runs before the editor pastes the plain text itself; true means the paste is handled. */
@@ -40,6 +44,8 @@ interface ComposerInputProps {
40
44
 
41
45
  const NO_EXTENSIONS: Extension = [];
42
46
 
47
+ const NO_ARIA: Readonly<Record<string, string>> = {};
48
+
43
49
  /*
44
50
  * A multi-line text field on CodeMirror, controlled by a plain string. The editor draws the text
45
51
  * itself, so a decoration may change a font or add padding without walking the caret off its
@@ -54,6 +60,7 @@ export function ComposerInput({
54
60
  className,
55
61
  dictationToolbar,
56
62
  extensions = NO_EXTENSIONS,
63
+ aria = NO_ARIA,
57
64
  ...handlers
58
65
  }: ComposerInputProps) {
59
66
  const hostRef = useRef<HTMLDivElement>(null);
@@ -94,7 +101,15 @@ export function ComposerInput({
94
101
  Prec.highest(
95
102
  EditorView.domEventHandlers({
96
103
  keydown: (event, target) => latest.current.handlers.onKeyDown?.(event, target) ?? false,
97
- paste: (event, target) => latest.current.handlers.onPaste?.(event, target) ?? false
104
+ paste: (event, target) => latest.current.handlers.onPaste?.(event, target) ?? false,
105
+ compositionstart: () => {
106
+ latest.current.handlers.onComposing?.(true);
107
+ return false;
108
+ },
109
+ compositionend: () => {
110
+ latest.current.handlers.onComposing?.(false);
111
+ return false;
112
+ }
98
113
  })
99
114
  ),
100
115
  EditorView.updateListener.of((update) => {
@@ -128,13 +143,13 @@ export function ComposerInput({
128
143
  viewRef.current?.dispatch({
129
144
  effects: [
130
145
  // Spellcheck stays on, as it was on the textarea this editor replaced.
131
- compartments.attributes.reconfigure(EditorView.contentAttributes.of({ spellcheck: 'true', tabindex: tabbable ? '0' : '-1' })),
146
+ compartments.attributes.reconfigure(EditorView.contentAttributes.of({ ...aria, spellcheck: 'true', tabindex: tabbable ? '0' : '-1' })),
132
147
  compartments.editable.reconfigure([EditorView.editable.of(!disabled), EditorState.readOnly.of(disabled)]),
133
148
  compartments.placeholder.reconfigure(placeholderText(placeholder)),
134
149
  compartments.extensions.reconfigure(extensions)
135
150
  ]
136
151
  });
137
- }, [compartments, tabbable, disabled, placeholder, extensions]);
152
+ }, [compartments, tabbable, disabled, placeholder, extensions, aria]);
138
153
 
139
154
  useLayoutEffect(() => {
140
155
  const view = viewRef.current;
@@ -67,17 +67,14 @@ function languageOf(className: string | undefined): string {
67
67
  const INLINE_CODE = 'rounded-sm bg-surface-sunken px-1 py-px font-mono text-code';
68
68
 
69
69
  /*
70
- * A path an agent wrote down, opened in the preview panel. It keeps the shape of the code chip it
71
- * would otherwise be, so a sentence full of them does not start dancing; only the color says it can
72
- * be clicked. `data-file-path` is what the thread's own right-click menu reads, so a link answers to
73
- * both ways of opening it.
70
+ * A path an agent wrote down. It keeps the shape of the code chip it would otherwise be, so a sentence
71
+ * full of them does not shift; `data-file-path` is what the thread's right-click menu reads.
74
72
  */
75
73
  function FileLink({ target, className, children }: { target: FileRef; className?: string; children: ReactNode }) {
76
74
  const cwd = useFileLinkCwd();
77
75
  const scopeId = useFileLinkScopeId();
78
76
  return (
79
- // `select-text` because a thread is copied as often as it is clicked, and a button is not
80
- // selectable on its own.
77
+ // A button is not selectable on its own, and a thread is copied as often as it is clicked.
81
78
  <button
82
79
  type="button"
83
80
  className={clsx('cursor-pointer select-text', className)}
@@ -108,8 +105,10 @@ function InlineCode({ text }: { text: string }) {
108
105
  );
109
106
  }
110
107
 
111
- /* The path a link target spells, with the one scheme that still means a file on the host's machine
112
- taken off. A href nobody encoded is left as it is, since decoding throws on a stray percent. */
108
+ /*
109
+ * The path a link target spells, without the one scheme that still means a file on the host's machine.
110
+ * A href nobody encoded is left as it is, since decoding throws on a stray percent.
111
+ */
113
112
  function hrefPath(href: string): string {
114
113
  const bare = href.replace(/^file:\/\//, '');
115
114
  try {
@@ -129,6 +128,10 @@ function MarkdownLink({ href, children }: { href?: string; children?: ReactNode
129
128
  </FileLink>
130
129
  );
131
130
  }
131
+ return <ExternalLink href={href}>{children}</ExternalLink>;
132
+ }
133
+
134
+ function ExternalLink({ href, children }: { href?: string; children?: ReactNode }) {
132
135
  return (
133
136
  <a href={href} target="_blank" rel="noreferrer">
134
137
  {children}
@@ -173,13 +176,7 @@ const plainComponents = {
173
176
  </MarkdownCode>
174
177
  );
175
178
  },
176
- a({ href, children }: { href?: string; children?: ReactNode }) {
177
- return (
178
- <a href={href} target="_blank" rel="noreferrer">
179
- {children}
180
- </a>
181
- );
182
- }
179
+ a: ExternalLink
183
180
  };
184
181
 
185
182
  // A reply sits under the heading of its message, an `h3`, so its own headings start one level below
@@ -271,8 +268,7 @@ const MESSAGE_PLUGINS = [remarkGfm, remarkHtmlAsText, remarkBreaks];
271
268
  const FADE_PLUGINS = [rehypeFadeWords];
272
269
  const NO_PLUGINS: typeof FADE_PLUGINS = [];
273
270
 
274
- /* One block of a reply. It renders no element of its own, so prose still sees the paragraphs as
275
- direct children and keeps its first and last margins. */
271
+ /* One block of a reply. It renders no element of its own, so prose keeps its first and last margins. */
276
272
  const ReplyBlock = memo(function ReplyBlock({ text, fade, open, reply }: { text: string; fade: boolean; open: boolean; reply?: ShellCodeBlockReply }) {
277
273
  return (
278
274
  // A context rather than a second set of components. A component that changed would mount the
@@ -1,10 +1,15 @@
1
- import { useEffect, useMemo, useRef, useState, type ReactNode, type RefObject } from 'react';
1
+ import { useContext, useEffect, useMemo, useRef, useState, type ReactNode, type RefObject } from 'react';
2
2
  import { useTranslation } from 'react-i18next';
3
3
  import { Bookmark, ChevronDown, ChevronRight, Search, Trash2 } from 'lucide-react';
4
- import type { AgentKind, ModelInfo, ModelSelection, ProviderInfo, UsageProvider } from '@adecore/agent-contracts';
4
+ import type { AgentKind, ModelSelection, ProviderInfo, UsageProvider } from '@adecore/agent-contracts';
5
5
  import { AgentIcon } from '../../agents/AgentIcon';
6
6
  import { ProviderLogo } from '../../agents/ProviderLogo';
7
7
  import { agentChipOf, modelName } from '../../agents/model-name';
8
+ import { modelRequirementOf } from '../../agents/model-requirement';
9
+ import { chatHost } from '../../host';
10
+ import { ChatScopeContext } from '../../scope';
11
+ import { useReportedModels } from '../../state/chats';
12
+ import { pickerEntriesOf, type PickerEntry } from '../logic/picker-entries';
8
13
  import { forgetStashed, STASH_SHORTCUT, useStash, type StashedPrompt } from '../stash';
9
14
  import { Icon, IconButton, Menu, Popover, Tooltip } from '@adecore/ui';
10
15
 
@@ -14,13 +19,6 @@ const triggerClass =
14
19
  const chipClass =
15
20
  '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';
16
21
 
17
- /* A model row, or the one row that folds the legacy models open. Both take a turn in the arrow keys. */
18
- type PickerEntry = { kind: 'model'; provider: ProviderInfo; model: ModelInfo } | { kind: 'legacy'; count: number };
19
-
20
- function matches(model: ModelInfo, query: string): boolean {
21
- return model.name.toLowerCase().includes(query) || model.slug.toLowerCase().includes(query);
22
- }
23
-
24
22
  interface ModelPickerProps {
25
23
  /* Whose models may be picked: every installed chat CLI before the first message, the chat's own after it. */
26
24
  providers: ProviderInfo[];
@@ -43,11 +41,9 @@ interface ModelPickerProps {
43
41
  }
44
42
 
45
43
  /*
46
- * Which model answers: the provider's mark plus the model's short name
47
- * as the trigger, a search field over the popup, one group per provider and the legacy models
48
- * behind an expander. Before the first message the list carries every installed CLI, so picking
49
- * another provider's model is also how a chat picks its provider. A chat bound to one CLI passes
50
- * only that CLI, which drops the group headers and leaves its own catalog to choose from.
44
+ * Which model answers: a search field over one group per provider, with the legacy models behind an
45
+ * expander. Before the first message the list carries every installed CLI, so picking another
46
+ * provider's model is also how a chat picks its provider; with one CLI the group headers drop.
51
47
  */
52
48
  export function ModelPicker({
53
49
  providers,
@@ -75,18 +71,13 @@ export function ModelPicker({
75
71
  const grouped = providers.length > 1;
76
72
  const trimmed = query.trim().toLowerCase();
77
73
 
78
- const entries = useMemo((): PickerEntry[] => {
79
- const rows = providers.flatMap((entry) => entry.models.map((model) => ({ kind: 'model' as const, provider: entry, model })));
80
- if (trimmed !== '') {
81
- return rows.filter((row) => matches(row.model, trimmed));
82
- }
83
- const live = rows.filter((row) => !row.model.legacy);
84
- const legacy = rows.filter((row) => row.model.legacy);
85
- if (legacy.length === 0) {
86
- return live;
87
- }
88
- return legacyOpen ? [...live, { kind: 'legacy', count: legacy.length }, ...legacy] : [...live, { kind: 'legacy', count: legacy.length }];
89
- }, [providers, trimmed, legacyOpen]);
74
+ const entries = useMemo(() => pickerEntriesOf(providers, trimmed, legacyOpen), [providers, trimmed, legacyOpen]);
75
+ const scope = useContext(ChatScopeContext);
76
+ const machine = chatHost().useMachineLabel(scope?.id ?? '');
77
+ const reported = useReportedModels(provider);
78
+ const requirementOf = (entry: PickerEntry & { kind: 'model' }) => modelRequirementOf(entry.model, entry.provider, reported);
79
+ // A CLI that is too old blocks all its models; one way to its settings under the group says it once.
80
+ const blockedKinds = new Set(entries.flatMap((entry) => (entry.kind === 'model' && requirementOf(entry) !== null ? [entry.provider.kind] : [])));
90
81
 
91
82
  /* Closing forgets the search, so the next open (a click or `/model`) starts on the whole list. */
92
83
  const setOpen = (next: boolean): void => {
@@ -112,6 +103,9 @@ export function ModelPicker({
112
103
  inputRef.current?.focus();
113
104
  return;
114
105
  }
106
+ if (requirementOf(entry) !== null) {
107
+ return;
108
+ }
115
109
  onChange(entry.provider.kind, entry.model.slug);
116
110
  setOpen(false);
117
111
  };
@@ -186,6 +180,9 @@ export function ModelPicker({
186
180
  }
187
181
  const previous = entries[i - 1];
188
182
  const first = grouped && (previous === undefined || previous.kind !== 'model' || previous.provider.kind !== entry.provider.kind);
183
+ const requirement = requirementOf(entry);
184
+ const next = entries[i + 1];
185
+ const lastOfGroup = next === undefined || next.kind !== 'model' || next.provider.kind !== entry.provider.kind;
189
186
  const chosen = entry.provider.kind === provider && entry.model.slug === selection.model;
190
187
  return (
191
188
  <div key={`${entry.provider.kind}/${entry.model.slug}`}>
@@ -198,21 +195,43 @@ export function ModelPicker({
198
195
  <button
199
196
  role="option"
200
197
  aria-selected={chosen}
198
+ aria-disabled={requirement !== null || undefined}
201
199
  data-active={i === index}
202
- 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"
200
+ 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'}`}
203
201
  onMouseEnter={() => setIndex(i)}
204
202
  onClick={() => choose(entry)}
205
203
  >
206
- <Menu.Check kind="radio" checked={chosen} />
207
- <span className="min-w-0 truncate">{entry.model.name}</span>
208
- {entry.model.badge && (
209
- <span className="rounded bg-accent-soft px-1 text-[11px] leading-5 font-medium uppercase text-accent">
210
- {entry.model.badge}
204
+ <span className="flex w-full items-center gap-2">
205
+ <Menu.Check kind="radio" checked={chosen} />
206
+ <span className="min-w-0 truncate">{entry.model.name}</span>
207
+ {entry.model.badge && (
208
+ <span className="rounded bg-accent-soft px-1 text-[11px] leading-5 font-medium uppercase text-accent">
209
+ {entry.model.badge}
210
+ </span>
211
+ )}
212
+ <span className="grow" />
213
+ {entry.model.legacy && <span className="text-xs text-text-faint">{t('pickers.model.legacy')}</span>}
214
+ </span>
215
+ {requirement !== null && (
216
+ <span className="pl-6 text-xs text-text-faint">
217
+ {machine === null
218
+ ? t('pickers.model.requiresHere', { cli: requirement.cli, version: requirement.minVersion })
219
+ : t('pickers.model.requires', { cli: requirement.cli, version: requirement.minVersion, machine })}
211
220
  </span>
212
221
  )}
213
- <span className="grow" />
214
- {entry.model.legacy && <span className="text-xs text-text-faint">{t('pickers.model.legacy')}</span>}
215
222
  </button>
223
+ {lastOfGroup && blockedKinds.has(entry.provider.kind) && (
224
+ <button
225
+ type="button"
226
+ className="cursor-row flex w-full items-center rounded-md px-2.5 py-1.5 text-left text-xs text-text-muted"
227
+ onClick={() => {
228
+ setOpen(false);
229
+ chatHost().openSettings('providers');
230
+ }}
231
+ >
232
+ {t('pickers.model.providerSettings')}
233
+ </button>
234
+ )}
216
235
  </div>
217
236
  );
218
237
  })}
@@ -224,10 +243,8 @@ export function ModelPicker({
224
243
  }
225
244
 
226
245
  /*
227
- * Prompts put aside with Cmd+S. The shelf is the whole app's, not this chat's. On a canvas a
228
- * stashed prompt usually moves to another node, which is the reason to put it away in the first
229
- * place. Restoring drops the text, the mentions and the skills into this composer; the files a
230
- * draft held are named on the row but not kept, because their bytes never go to storage.
246
+ * Prompts put aside with Cmd+S, on a shelf the whole app shares, since a stashed prompt usually moves
247
+ * to another chat. A draft's files are named on the row but not kept: their bytes never go to storage.
231
248
  */
232
249
  export function StashPicker({ onRestore }: { onRestore(prompt: StashedPrompt): void }) {
233
250
  const { t } = useTranslation('agent-chat');
@@ -251,7 +268,7 @@ export function StashPicker({ onRestore }: { onRestore(prompt: StashedPrompt): v
251
268
  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"
252
269
  onClick={() => onRestore(prompt)}
253
270
  >
254
- <span className="line-clamp-2 whitespace-pre-wrap">{prompt.text || t('pickers.stash.noText')}</span>
271
+ <span className="line-clamp-2 whitespace-pre-wrap">{prompt.text || prompt.quote?.text || t('pickers.stash.noText')}</span>
255
272
  {prompt.attachments.length > 0 && (
256
273
  <span className="text-text-faint">{t('pickers.stash.files', { count: prompt.attachments.length })}</span>
257
274
  )}
@@ -1,6 +1,7 @@
1
1
  import { useEffect, useMemo, useRef, type ReactNode } from 'react';
2
2
  import { useTranslation } from 'react-i18next';
3
3
  import { ArrowDown, Hand, MessageCircleQuestionMark } from 'lucide-react';
4
+ import type { HoldRelease } from '../prompt-hold';
4
5
  import { focusPromptHeading, focusPromptStart } from '../../prompts/logic/focus';
5
6
  import { orderPrompts, type PendingPrompt } from '../../prompts/logic/prompts';
6
7
  import { answerPrompt, isBlockingSubject, promptCreatedAt, promptIdOf, type PromptSubject } from '../../prompts/logic/subjects';
@@ -10,7 +11,14 @@ import { PromptView } from '../../prompts/ui/PromptView';
10
11
  import { chatHost } from '../../host';
11
12
  import { useChatScope } from '../../scope';
12
13
  import { useChatActions } from '../actions';
13
- import { Icon } from '@adecore/ui';
14
+ import { Button, Icon } from '@adecore/ui';
15
+
16
+ /* Keeps a prompt card behind the draft while the person types; `released` says what lifted the hold last. */
17
+ export interface PromptHold {
18
+ held: boolean;
19
+ releasedBy: HoldRelease | null;
20
+ release(reason: HoldRelease): void;
21
+ }
14
22
 
15
23
  /* The one being read stays in front; otherwise blocking before optional, oldest first, as everywhere else. */
16
24
  function pickSubject(waiting: readonly PromptSubject[], activeId: string | null): PromptSubject | null {
@@ -26,6 +34,7 @@ export function PromptComposer({
26
34
  hasDraft,
27
35
  denyReason,
28
36
  onAllAnswered,
37
+ hold,
29
38
  children
30
39
  }: {
31
40
  chatId: string;
@@ -38,6 +47,8 @@ export function PromptComposer({
38
47
  denyReason: boolean;
39
48
  /* Takes the keyboard back to the composer once the last prompt was answered from its card. */
40
49
  onAllAnswered?: () => void;
50
+ /* Without it a card takes the composer's place at once. */
51
+ hold?: PromptHold;
41
52
  children: ReactNode;
42
53
  }) {
43
54
  const { t } = useTranslation('agent-chat');
@@ -56,10 +67,23 @@ export function PromptComposer({
56
67
  const session = usePromptSession({ prompts: subjects, idOf: promptIdOf, pick: pickSubject, disabled });
57
68
  const ref = useRef<HTMLDivElement>(null);
58
69
  const { active, activeId: activeKey } = session;
59
- const expanded = !!active;
70
+ const asksApproval = active?.kind === 'chat' ? active.item.kind === 'approval' : active?.prompt.asks === 'approval';
71
+ const held = !!active && (hold?.held ?? false);
72
+ const expanded = !!active && !held;
73
+ // The card that waited behind the draft, so a blur that released it is not mistaken for one released long before.
74
+ const heldKey = useRef<string | null>(null);
60
75
  const refocus = useFocusAfterAnswer(activeKey, ref, () => onAllAnswered?.());
61
76
 
62
77
  useEffect(() => {
78
+ if (held) {
79
+ heldKey.current = activeKey;
80
+ }
81
+ }, [held, activeKey]);
82
+
83
+ useEffect(() => {
84
+ if (hold?.releasedBy === 'blur' && heldKey.current === activeKey) {
85
+ return;
86
+ }
63
87
  if (focused && expanded && !elsewhere && !ref.current?.querySelector('.prompt-card')?.contains(document.activeElement)) {
64
88
  // Keys meant for the draft may still be coming, and on a button the next Space would answer the card.
65
89
  if (hasDraft) {
@@ -68,7 +92,7 @@ export function PromptComposer({
68
92
  focusPromptStart(ref.current);
69
93
  }
70
94
  }
71
- }, [activeKey, expanded, focused, elsewhere, hasDraft]);
95
+ }, [activeKey, expanded, focused, elsewhere, hasDraft, hold?.releasedBy]);
72
96
 
73
97
  return (
74
98
  <div ref={ref} onPointerDownCapture={session.onPointerDownCapture} onClickCapture={session.onClickCapture}>
@@ -78,11 +102,7 @@ export function PromptComposer({
78
102
  className="flex w-full items-center gap-2 px-3.5 py-3 text-left text-sm text-text-muted hover:text-text"
79
103
  onClick={onShowElsewhere}
80
104
  >
81
- <Icon
82
- icon={(active.kind === 'chat' ? active.item.kind === 'approval' : active.prompt.asks === 'approval') ? Hand : MessageCircleQuestionMark}
83
- size={16}
84
- className="shrink-0 text-status-needs-you"
85
- />
105
+ <Icon icon={asksApproval ? Hand : MessageCircleQuestionMark} size={16} className="shrink-0 text-status-needs-you" />
86
106
  <span className="grow">{t('composer.waitingBelow')}</span>
87
107
  <Icon icon={ArrowDown} size={16} className="shrink-0" />
88
108
  </button>
@@ -113,6 +133,15 @@ export function PromptComposer({
113
133
  />
114
134
  </div>
115
135
  )}
136
+ {held && active && (
137
+ <div role="status" className="flex items-center gap-2 border-b border-border px-3.5 py-2.5">
138
+ <Icon icon={asksApproval ? Hand : MessageCircleQuestionMark} size={16} className="shrink-0 text-status-needs-you" />
139
+ <span className="grow text-sm text-text-muted">{t(asksApproval ? 'composer.held.approval' : 'composer.held.question')}</span>
140
+ <Button size="sm" onMouseDown={(e) => e.preventDefault()} onClick={() => hold?.release('show')}>
141
+ {t('composer.held.show')}
142
+ </Button>
143
+ </div>
144
+ )}
116
145
  <div hidden={expanded}>{children}</div>
117
146
  </div>
118
147
  );
@@ -10,6 +10,7 @@ const EDGE_PX = 40;
10
10
 
11
11
  interface Offer {
12
12
  quote: string;
13
+ itemId: string | null;
13
14
  x: number;
14
15
  y: number;
15
16
  }
@@ -21,9 +22,8 @@ function lastLineOf(range: Range): DOMRect | null {
21
22
  }
22
23
 
23
24
  /*
24
- * A button under a selection in an answer that hands it to the composer. Quoting on the selection
25
- * alone grew the composer while the person was still selecting, which moved the thread under the
26
- * pointer. It only shows once the pointer is up, so a drag never has it jump along.
25
+ * A button under a selection in an answer that hands it to the composer. It shows once the pointer is
26
+ * up: quoting on the selection alone grew the composer mid-drag and moved the thread under the pointer.
27
27
  */
28
28
  export function QuoteButton({
29
29
  thread,
@@ -53,7 +53,7 @@ export function QuoteButton({
53
53
  const zoom = box.offsetWidth > 0 ? bounds.width / box.offsetWidth : 1;
54
54
  const x = Math.min(Math.max((line.right - bounds.left) / zoom, EDGE_PX), box.offsetWidth - EDGE_PX);
55
55
  const y = (line.bottom - bounds.top) / zoom + GAP_PX;
56
- setOffer({ quote, x: Math.round(x), y: Math.round(y) });
56
+ setOffer({ quote: quote.text, itemId: quote.itemId, x: Math.round(x), y: Math.round(y) });
57
57
  };
58
58
  const press = (): void => {
59
59
  pressed = true;
@@ -92,7 +92,7 @@ export function QuoteButton({
92
92
  onMouseDown={(e) => e.preventDefault()}
93
93
  onClick={() => {
94
94
  document.getSelection()?.removeAllRanges();
95
- taker.current?.(offer.quote);
95
+ taker.current?.(offer.quote, offer.itemId);
96
96
  setOffer(null);
97
97
  }}
98
98
  >
@@ -103,6 +103,13 @@ function modelValue(provider: AgentKind, slug: string): string {
103
103
  return `${provider}/${slug}`;
104
104
  }
105
105
 
106
+ /* Each provider's current or legacy models; a provider with none of them is left out. */
107
+ function modelGroupsOf(providers: ProviderInfo[], legacy: boolean): Array<{ entry: ProviderInfo; models: ModelInfo[] }> {
108
+ return providers
109
+ .map((entry) => ({ entry, models: entry.models.filter((row) => (row.legacy === true) === legacy) }))
110
+ .filter((group) => group.models.length > 0);
111
+ }
112
+
106
113
  /*
107
114
  * Everything that decides how the next turn runs, behind one pill: the model, its own knobs and the
108
115
  * permission mode. Model and effective permissions stay visible as the composer narrows.
@@ -142,8 +149,8 @@ export function RunSettings({
142
149
  summary.push({ id: CONTEXT_OPTION, label: formatTokens(usage.contextWindow ?? 0), ring: true });
143
150
  }
144
151
  const grouped = providers.length > 1;
145
- const live = providers.map((entry) => ({ entry, models: entry.models.filter((row) => !row.legacy) })).filter((group) => group.models.length > 0);
146
- const legacy = providers.map((entry) => ({ entry, models: entry.models.filter((row) => row.legacy) })).filter((group) => group.models.length > 0);
152
+ const live = modelGroupsOf(providers, false);
153
+ const legacy = modelGroupsOf(providers, true);
147
154
  const chosenLegacy = owner?.models.find((row) => row.legacy && row.slug === selection.model);
148
155
  const chosenModel = modelValue(provider, selection.model);
149
156
  const shownMode = effectiveRuntimeMode ?? runtimeMode;
@@ -70,9 +70,8 @@ const NOTHING_FOUND: readonly boolean[] = [];
70
70
 
71
71
  /*
72
72
  * The strip at the left edge of a chat view: a tick per message of the person, per wake by tasks and
73
- * per other message with a bookmark, the ones on screen bright and the rest dimmed, a bookmark in the accent, growing only under the pointer. A single hover card follows the pointer along the strip, anchored at the tick
74
- * it points at, so a thousand ticks are a thousand spans and not a thousand popups. While a find is
75
- * open, a short mark at the strip's right edge stands beside every tick with a hit under it.
73
+ * per other bookmarked message, the ones on screen bright. One hover card follows the pointer, so a
74
+ * thousand ticks are a thousand spans and not a thousand popups. An open find marks every tick it hits.
76
75
  */
77
76
  export const Scrubber = memo(function Scrubber({
78
77
  ticks,
@@ -8,9 +8,7 @@ import { Icon, IconButton } from '@adecore/ui';
8
8
 
9
9
  const CRUMB_LINK = 'truncate text-text-muted hover:text-text';
10
10
 
11
- /*
12
- * The chat's own title where the bar draws it. While a sub-agent stands in the chat's place it is the first crumb of the way down, so pressing it goes back to the main agent.
13
- */
11
+ /* The chat's own title in the bar; while a sub-agent stands in the chat's place, pressing it goes back to the main agent. */
14
12
  export function SubagentTitleCrumb({ chatId, className, children }: { chatId: string; className?: string; children: ReactNode }) {
15
13
  const { trail, show } = useSubagentTrail(chatId);
16
14
  if (trail.length === 0) {
@@ -23,31 +21,18 @@ export function SubagentTitleCrumb({ chatId, className, children }: { chatId: st
23
21
  );
24
22
  }
25
23
 
26
- /*
27
- * The way down after the chat's title, each step a way back up, and a close that goes straight back
28
- * to the main agent. A bar that does not draw the title itself passes it, and it opens the way.
29
- * Nothing while the main agent itself is on screen.
30
- */
31
- export function SubagentBreadcrumb({ chatId, title, className }: { chatId: string; title?: string; className?: string }) {
24
+ /* 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. */
25
+ export function SubagentBreadcrumb({ chatId, className }: { chatId: string; className?: string }) {
32
26
  const { t } = useTranslation('agent-chat');
33
27
  const { trail, show } = useSubagentTrail(chatId);
34
- const subagents = useOpenableSubagents(chatId);
35
28
  if (trail.length === 0) {
36
29
  return null;
37
30
  }
38
- const steps = breadcrumbOf(trail);
39
- // Only a row of the chat's own thread can be stopped; a grandchild belongs to the sub-agent that opened it.
40
- const shown = trail.length === 1 ? subagents.find((item) => item.toolUseId === trail[0]!.toolUseId) : undefined;
41
31
  return (
42
- <nav aria-label={t('subagents.title')} className={clsx('flex min-w-0 items-center gap-1 text-xs', className)}>
43
- {title !== undefined && (
44
- <button type="button" className={clsx(CRUMB_LINK, 'min-w-0 shrink')} onClick={() => show(MAIN_AGENT)}>
45
- {title}
46
- </button>
47
- )}
48
- {steps.map((step) => (
32
+ <nav aria-label={t('subagents.title')} className={clsx('flex min-w-0 items-center gap-1', className)}>
33
+ {breadcrumbOf(trail).map((step) => (
49
34
  <Fragment key={step.depth}>
50
- <Icon icon={ChevronRight} size={12} className="shrink-0 text-text-faint" />
35
+ {step.depth > 1 && <Icon icon={ChevronRight} size={12} className="shrink-0 text-text-faint" />}
51
36
  {step.current ? (
52
37
  <span aria-current="page" className="min-w-0 truncate text-text">
53
38
  {step.label}
@@ -59,8 +44,24 @@ export function SubagentBreadcrumb({ chatId, title, className }: { chatId: strin
59
44
  )}
60
45
  </Fragment>
61
46
  ))}
47
+ </nav>
48
+ );
49
+ }
50
+
51
+ /* Stops the sub-agent on screen where that is on offer, and closes it straight back to the main agent. */
52
+ export function SubagentActions({ chatId }: { chatId: string }) {
53
+ const { t } = useTranslation('agent-chat');
54
+ const { trail, show } = useSubagentTrail(chatId);
55
+ const subagents = useOpenableSubagents(chatId);
56
+ if (trail.length === 0) {
57
+ return null;
58
+ }
59
+ // Only a row of the chat's own thread can be stopped; a grandchild belongs to the sub-agent that opened it.
60
+ const shown = trail.length === 1 ? subagents.find((item) => item.toolUseId === trail[0]!.toolUseId) : undefined;
61
+ return (
62
+ <div className="flex shrink-0 items-center gap-1">
62
63
  {shown !== undefined && <SubagentStopButton chatId={chatId} item={shown} size="sm" />}
63
64
  <IconButton icon={X} size="sm" label={t('subagents.backToMain')} onClick={() => show(MAIN_AGENT)} />
64
- </nav>
65
+ </div>
65
66
  );
66
67
  }