@adecore/agents-react 0.24.0 → 0.24.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.
@@ -39,6 +39,7 @@ import { useNotice } from './composer/use-notice.js';
39
39
  import { usePendingRequests } from './composer/use-pending-requests.js';
40
40
  import { usePromptHistory } from './composer/use-prompt-history.js';
41
41
  import { usePromptHold } from './composer/use-prompt-hold.js';
42
+ import { surfacePartOf } from './composer/surface.js';
42
43
  import { useTakeBack } from './composer/use-take-back.js';
43
44
  function sendLabelKey(busy, steerable, delivery) {
44
45
  if (!busy) {
@@ -121,29 +122,40 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
121
122
  setText: (next) => box.setText(next),
122
123
  submit
123
124
  });
125
+ const pointSurface = (event) => {
126
+ event.currentTarget.toggleAttribute('data-text-cursor', surfacePartOf(event.target, event.currentTarget) !== 'action');
127
+ };
128
+ const pressSurface = (event) => {
129
+ if (!writable || event.button !== 0 || event.defaultPrevented || surfacePartOf(event.target, event.currentTarget) !== 'surface') {
130
+ return;
131
+ }
132
+ // Nothing around the field selects, so the default would only blur it on the way, closing its picker and lifting a held prompt card.
133
+ event.preventDefault();
134
+ inputRef.current?.setCaret(Infinity);
135
+ };
124
136
  return (_jsxs("div", { className: "chat-composer-content pointer-events-none relative z-10 w-full", children: [!atEnd && !readOnly && (_jsx("div", { className: "absolute inset-x-0 bottom-full mb-2 flex justify-center", children: _jsx(Tooltip, { label: t('composer.jumpToEnd'), name: true, children: _jsx(Surface, { render: _jsx("button", { onClick: () => scrollTimelineToEnd(chatId) }), className: "pointer-events-auto grid h-8 w-8 place-items-center rounded-full text-text-muted hover:text-text", children: _jsx(Icon, { icon: ChevronDown, size: 16 }) }) }) })), _jsx(ChatActivity, { chatId: chatId }), _jsx("div", { className: clsx('@container/composer pointer-events-auto flex flex-col overflow-hidden rounded-2xl border shadow-float backdrop-blur-[14px] focus-ring-within', files.dragging
125
137
  ? 'border-accent bg-[color-mix(in_srgb,var(--accent-soft)_60%,var(--surface-raised))]'
126
- : 'border-border bg-[color-mix(in_srgb,var(--surface-raised)_92%,transparent)]'), ...files.dropTarget, children: _jsxs(PromptComposer, { chatId: chatId, pending: pending, focused: focused, onShowElsewhere: answerPromptsElsewhere, disabled: disabled, hasDraft: !empty, denyReason: capabilities?.denyReason === true, onAllAnswered: () => inputRef.current?.focus(), hold: hold, children: [_jsx(LimitDock, { chatId: chatId, info: info }), _jsx(GoalDock, { chatId: chatId, info: info, goals: capabilities?.goals }), compactionOffer && (_jsx(ResumeCompactionDock, { tokens: compactionOffer.tokens, onCompact: controls.compact, onDismiss: () => dismissResumeCompaction(scope, chatId, compactionOffer.turnId) })), _jsx(ComposerMenu, { menus: menus }), queue.length > 0 && (_jsx(QueuedMessages, { chatId: chatId, queue: queue, paused: info.queuePaused === true, takingBack: takingBack !== null, onEdit: (message) => void takeBack(message) })), draft.quote !== '' && (_jsx(DraftQuote, { chatId: chatId, quote: draft.quote, source: draft.quoteSource, onRemove: box.removeQuote, onSourceGone: () => notice.setNotice(t('rows.user.quoteGone')) })), draft.chats.length > 0 && _jsx(DraftChats, { chats: draft.chats, onRemove: box.removeChat }), draft.attachments.length > 0 && _jsx(DraftAttachments, { attachments: draft.attachments, onRemove: box.removeAttachment }), _jsx(ComposerInput, { ref: inputRef, dictationToolbar: dictationToolbar, className: "composer-input select-text text-sm leading-normal text-text", value: draft.text, placeholder: placeholder, disabled: !writable, tabbable: focused, extensions: editorExtensions, aria: menus.inputAria, onChange: (value, selection, state) => {
127
- hold.noteInput();
128
- box.setText(value);
129
- menus.setMenuIndex(0);
130
- menus.trackTriggers(value, selection, state);
131
- history.edited(value);
132
- }, onSelectionChange: menus.trackTriggers, onBlur: () => {
133
- menus.close();
134
- hold.release('blur');
135
- }, onComposing: hold.setComposing, onKeyDown: onKeyDown, onPaste: files.onPaste }), notice.notice && (_jsx("div", { className: "px-5 pb-1 text-xs text-status-error @max-md/composer:px-3.5", role: "alert", children: notice.notice })), guard.visible && (_jsxs("div", { className: clsx('px-5 pb-1 text-right text-xs tabular-nums @max-md/composer:px-3.5', guard.tooLong ? 'text-status-error' : 'text-text-faint'), children: [formatNumber(guard.count), " / ", formatNumber(PROMPT_MAX_CHARS), guard.tooLong && ` ${t('composer.tooLong')}`] })), _jsx(ComposerControls, { chatId: chatId, info: info, providerFixed: providerFixed, writable: writable, attachable: capabilities?.attachments !== false, controls: controls, send: {
136
- visible: !busy || !empty,
137
- disabled: empty || !writable || guard.tooLong,
138
- label: t(sendLabelKey(busy, steerable, sendDelivery)),
139
- kbd: sendShortcut(sendKey, draft.text.includes('\n')),
140
- onSend: (alternate) => submit(alternate ? 'alternate' : 'default')
141
- }, dictationToolbar: setDictationToolbar, onFiles: (picked) => {
142
- files.addFiles(picked);
143
- inputRef.current?.focus();
144
- }, onRestore: restoreStashed, insert: (added) => {
145
- if (writable) {
146
- inputRef.current?.insert(added);
147
- }
148
- } })] }) }), _jsx(PromptDialog, { open: controls.confirmClear, title: t('composer.clear.title'), description: t('composer.clear.description'), confirmLabel: t('composer.clear.confirm'), danger: true, fallbackMessage: t('composer.notice.clearFailed'), onConfirm: controls.forceClearThread, onOpenChange: controls.cancelClear })] }));
138
+ : 'border-border bg-[color-mix(in_srgb,var(--surface-raised)_92%,transparent)]'), ...files.dropTarget, children: _jsx(PromptComposer, { chatId: chatId, pending: pending, focused: focused, onShowElsewhere: answerPromptsElsewhere, disabled: disabled, hasDraft: !empty, denyReason: capabilities?.denyReason === true, onAllAnswered: () => inputRef.current?.focus(), hold: hold, children: _jsxs("div", { className: clsx(writable && 'data-[text-cursor]:cursor-text'), onPointerOver: pointSurface, onMouseDown: pressSurface, children: [_jsx(LimitDock, { chatId: chatId, info: info }), _jsx(GoalDock, { chatId: chatId, info: info, goals: capabilities?.goals }), compactionOffer && (_jsx(ResumeCompactionDock, { tokens: compactionOffer.tokens, onCompact: controls.compact, onDismiss: () => dismissResumeCompaction(scope, chatId, compactionOffer.turnId) })), _jsx(ComposerMenu, { menus: menus }), queue.length > 0 && (_jsx(QueuedMessages, { chatId: chatId, queue: queue, paused: info.queuePaused === true, takingBack: takingBack !== null, onEdit: (message) => void takeBack(message) })), draft.quote !== '' && (_jsx(DraftQuote, { chatId: chatId, quote: draft.quote, source: draft.quoteSource, onRemove: box.removeQuote, onSourceGone: () => notice.setNotice(t('rows.user.quoteGone')) })), draft.chats.length > 0 && _jsx(DraftChats, { chats: draft.chats, onRemove: box.removeChat }), draft.attachments.length > 0 && _jsx(DraftAttachments, { attachments: draft.attachments, onRemove: box.removeAttachment }), _jsx(ComposerInput, { ref: inputRef, dictationToolbar: dictationToolbar, className: "composer-input select-text text-sm leading-normal text-text", value: draft.text, placeholder: placeholder, disabled: !writable, tabbable: focused, extensions: editorExtensions, aria: menus.inputAria, onChange: (value, selection, state) => {
139
+ hold.noteInput();
140
+ box.setText(value);
141
+ menus.setMenuIndex(0);
142
+ menus.trackTriggers(value, selection, state);
143
+ history.edited(value);
144
+ }, onSelectionChange: menus.trackTriggers, onBlur: () => {
145
+ menus.close();
146
+ hold.release('blur');
147
+ }, onComposing: hold.setComposing, onKeyDown: onKeyDown, onPaste: files.onPaste }), notice.notice && (_jsx("div", { className: "px-5 pb-1 text-xs text-status-error @max-md/composer:px-3.5", role: "alert", children: notice.notice })), guard.visible && (_jsxs("div", { className: clsx('px-5 pb-1 text-right text-xs tabular-nums @max-md/composer:px-3.5', guard.tooLong ? 'text-status-error' : 'text-text-faint'), children: [formatNumber(guard.count), " / ", formatNumber(PROMPT_MAX_CHARS), guard.tooLong && ` ${t('composer.tooLong')}`] })), _jsx(ComposerControls, { chatId: chatId, info: info, providerFixed: providerFixed, writable: writable, attachable: capabilities?.attachments !== false, controls: controls, send: {
148
+ visible: !busy || !empty,
149
+ disabled: empty || !writable || guard.tooLong,
150
+ label: t(sendLabelKey(busy, steerable, sendDelivery)),
151
+ kbd: sendShortcut(sendKey, draft.text.includes('\n')),
152
+ onSend: (alternate) => submit(alternate ? 'alternate' : 'default')
153
+ }, dictationToolbar: setDictationToolbar, onFiles: (picked) => {
154
+ files.addFiles(picked);
155
+ inputRef.current?.focus();
156
+ }, onRestore: restoreStashed, insert: (added) => {
157
+ if (writable) {
158
+ inputRef.current?.insert(added);
159
+ }
160
+ } })] }) }) }), _jsx(PromptDialog, { open: controls.confirmClear, title: t('composer.clear.title'), description: t('composer.clear.description'), confirmLabel: t('composer.clear.confirm'), danger: true, fallbackMessage: t('composer.notice.clearFailed'), onConfirm: controls.forceClearThread, onOpenChange: controls.cancelClear })] }));
149
161
  }
@@ -1,5 +1,6 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
- import { Suspense, useMemo } from 'react';
2
+ import { useMemo } from 'react';
3
+ import { LazyBoundary } from '@adecore/ui';
3
4
  import { useTranslation } from 'react-i18next';
4
5
  import { lazyNamed } from '../../lazy.js';
5
6
  import { diffSourceBudget, legacyDiffBudget } from './diff/diff-source-budget.js';
@@ -17,7 +18,7 @@ export default function EditDiff({ change, allowEditor = true, maxHeight }) {
17
18
  const legacyMode = !allowEditor || loadEngine === undefined;
18
19
  const legacyAllowed = useMemo(() => legacyMode && legacyDiffBudget(change.before, change.after), [legacyMode, change.before, change.after]);
19
20
  const omitted = _jsx(DiffOmitted, { path: change.path });
20
- const fallback = legacyAllowed ? (_jsx(DiffFallbackBoundary, { fallback: omitted, resetKeys: [change.path, change.before, change.after], children: _jsx(Suspense, { fallback: _jsx("div", { className: "px-3 py-2 text-xs text-text-faint", children: t('work.loadingDiff') }), children: _jsx(LegacyEditDiff, { change: change, maxHeight: maxHeight }) }) })) : (omitted);
21
+ const fallback = legacyAllowed ? (_jsx(DiffFallbackBoundary, { fallback: omitted, resetKeys: [change.path, change.before, change.after], children: _jsx(LazyBoundary, { fallback: _jsx("div", { className: "px-3 py-2 text-xs text-text-faint", children: t('work.loadingDiff') }), children: _jsx(LegacyEditDiff, { change: change, maxHeight: maxHeight }) }) })) : (omitted);
21
22
  if (!sourceAllowed) {
22
23
  return omitted;
23
24
  }
@@ -25,5 +26,5 @@ export default function EditDiff({ change, allowEditor = true, maxHeight }) {
25
26
  return fallback;
26
27
  }
27
28
  const unavailable = _jsx(DiffUnavailable, { path: change.path });
28
- return (_jsx(DiffFallbackBoundary, { fallback: unavailable, resetKeys: [change.path, change.before, change.after, loadEngine], children: _jsx(Suspense, { fallback: _jsx("div", { className: "px-3 py-2 text-xs text-text-faint", children: t('work.loadingDiff') }), children: _jsx(EditorEditDiff, { change: change, loadEngine: loadEngine, fallback: unavailable, maxHeight: maxHeight, sourceChecked: true }) }) }));
29
+ return (_jsx(DiffFallbackBoundary, { fallback: unavailable, resetKeys: [change.path, change.before, change.after, loadEngine], children: _jsx(LazyBoundary, { fallback: _jsx("div", { className: "px-3 py-2 text-xs text-text-faint", children: t('work.loadingDiff') }), children: _jsx(EditorEditDiff, { change: change, loadEngine: loadEngine, fallback: unavailable, maxHeight: maxHeight, sourceChecked: true }) }) }));
29
30
  }
@@ -1,5 +1,6 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
- import { Suspense, useMemo } from 'react';
2
+ import { useMemo } from 'react';
3
+ import { LazyBoundary } from '@adecore/ui';
3
4
  import { useTranslation } from 'react-i18next';
4
5
  import { lazyNamed } from '../../lazy.js';
5
6
  import { diffSourceBudget, legacyDiffBudget, patchSourceBudget } from './diff/diff-source-budget.js';
@@ -23,7 +24,7 @@ export default function UnifiedDiff(props) {
23
24
  const legacyAllowed = useMemo(() => legacyMode && legacyDiffBudget(oldText, newText, change.diff), [legacyMode, oldText, newText, change.diff]);
24
25
  const rawAllowed = useMemo(() => hasHunks || legacyDiffBudget('', '', change.diff), [hasHunks, change.diff]);
25
26
  const omitted = _jsx(DiffOmitted, { path: change.path });
26
- const fallback = legacyAllowed ? (_jsx(DiffFallbackBoundary, { fallback: omitted, resetKeys: [change.path, change.diff, oldText, newText, diffStyle], children: _jsx(Suspense, { fallback: _jsx("div", { className: "px-3 py-2 text-xs text-text-faint", children: t('work.loadingDiff') }), children: _jsx(LegacyUnifiedDiff, { ...props }) }) })) : (omitted);
27
+ const fallback = legacyAllowed ? (_jsx(DiffFallbackBoundary, { fallback: omitted, resetKeys: [change.path, change.diff, oldText, newText, diffStyle], children: _jsx(LazyBoundary, { fallback: _jsx("div", { className: "px-3 py-2 text-xs text-text-faint", children: t('work.loadingDiff') }), children: _jsx(LegacyUnifiedDiff, { ...props }) }) })) : (omitted);
27
28
  if (!sourceAllowed) {
28
29
  return omitted;
29
30
  }
@@ -34,5 +35,5 @@ export default function UnifiedDiff(props) {
34
35
  return fallback;
35
36
  }
36
37
  const unavailable = _jsx(DiffUnavailable, { path: change.path });
37
- return (_jsx(DiffFallbackBoundary, { fallback: unavailable, resetKeys: [change.path, change.diff, oldText, newText, ignoreWhitespace, diffStyle, loadEngine], children: _jsx(Suspense, { fallback: _jsx("div", { className: "px-3 py-2 text-xs text-text-faint", children: t('work.loadingDiff') }), children: _jsx(EditorPatchDiff, { change: change, sourceChecked: true, contents: contents, ignoreWhitespace: ignoreWhitespace, wrap: overflow === 'wrap', fill: fill, maxHeight: maxHeight, layout: diffStyle, loadEngine: loadEngine, fallback: unavailable }) }) }));
38
+ return (_jsx(DiffFallbackBoundary, { fallback: unavailable, resetKeys: [change.path, change.diff, oldText, newText, ignoreWhitespace, diffStyle, loadEngine], children: _jsx(LazyBoundary, { fallback: _jsx("div", { className: "px-3 py-2 text-xs text-text-faint", children: t('work.loadingDiff') }), children: _jsx(EditorPatchDiff, { change: change, sourceChecked: true, contents: contents, ignoreWhitespace: ignoreWhitespace, wrap: overflow === 'wrap', fill: fill, maxHeight: maxHeight, layout: diffStyle, loadEngine: loadEngine, fallback: unavailable }) }) }));
38
39
  }
@@ -0,0 +1 @@
1
+ export declare function surfacePartOf(target: EventTarget | null, surface: Element): 'input' | 'action' | 'surface';
@@ -0,0 +1,44 @@
1
+ /* Anything with an action of its own; `data-composer-action` marks one a role cannot name, such as a control of the host's. */
2
+ const ACTIONS = [
3
+ 'a',
4
+ 'button',
5
+ 'input',
6
+ 'select',
7
+ 'textarea',
8
+ 'label',
9
+ 'summary',
10
+ '[contenteditable]:not([contenteditable="false"])',
11
+ '[tabindex]:not([tabindex="-1"])',
12
+ '[role="button"]',
13
+ '[role="link"]',
14
+ '[role="menuitem"]',
15
+ '[role="menuitemcheckbox"]',
16
+ '[role="menuitemradio"]',
17
+ '[role="option"]',
18
+ '[role="listbox"]',
19
+ '[role="checkbox"]',
20
+ '[role="radio"]',
21
+ '[role="switch"]',
22
+ '[role="tab"]',
23
+ '[role="slider"]',
24
+ '[role="combobox"]',
25
+ '[data-composer-action]'
26
+ ].join(', ');
27
+ /*
28
+ * Where a pointer is on the composer: in the text field, on an action, or on the space around them,
29
+ * which stands in for the text field. A target outside the surface is an action, as an event from a
30
+ * popup portaled out of it still reaches the surface through React.
31
+ */
32
+ export function surfacePartOf(target, surface) {
33
+ // Not `instanceof Element`, which fails for an element of another window.
34
+ const element = target;
35
+ if (typeof element?.closest !== 'function' || !surface.contains(element)) {
36
+ return 'action';
37
+ }
38
+ const input = element.closest('.cm-editor');
39
+ if (input !== null && surface.contains(input)) {
40
+ return 'input';
41
+ }
42
+ const action = element.closest(ACTIONS);
43
+ return action !== null && surface.contains(action) ? 'action' : 'surface';
44
+ }
@@ -1,10 +1,10 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
- import { Suspense } from 'react';
2
+ import { LazyBoundary } from '@adecore/ui';
3
3
  import { useTranslation } from 'react-i18next';
4
4
  import { lazyNamed } from '../../../lazy.js';
5
5
  const EditDiff = lazyNamed(() => import('../EditDiff.js'), 'default');
6
6
  const UnifiedDiff = lazyNamed(() => import('../UnifiedDiff.js'), 'default');
7
7
  export function ReviewDiff({ entry, maxHeight = 480 }) {
8
8
  const { t } = useTranslation('agent-chat');
9
- return (_jsx("div", { "data-review-entry": entry.key, children: _jsx(Suspense, { fallback: _jsx("p", { className: "px-3 py-2 text-xs text-text-faint", children: t('work.loadingDiff') }), children: entry.change.kind === 'edit' ? (_jsx(EditDiff, { change: entry.change.edit, maxHeight: maxHeight })) : (_jsx(UnifiedDiff, { change: entry.change.patch, contents: entry.change.contents, maxHeight: maxHeight })) }) }));
9
+ return (_jsx("div", { "data-review-entry": entry.key, children: _jsx(LazyBoundary, { fallback: _jsx("p", { className: "px-3 py-2 text-xs text-text-faint", children: t('work.loadingDiff') }), children: entry.change.kind === 'edit' ? (_jsx(EditDiff, { change: entry.change.edit, maxHeight: maxHeight })) : (_jsx(UnifiedDiff, { change: entry.change.patch, contents: entry.change.contents, maxHeight: maxHeight })) }) }));
10
10
  }
@@ -1,5 +1,5 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Fragment, Suspense, useEffect, useState } from 'react';
2
+ import { Fragment, useEffect, useState } from 'react';
3
3
  import clsx from 'clsx';
4
4
  import i18next from 'i18next';
5
5
  import { useTranslation } from 'react-i18next';
@@ -13,7 +13,7 @@ import { useChatScope } from '../../../scope.js';
13
13
  import { useCurrentItem } from '../../../state/chats.js';
14
14
  import { useTimelineFlash } from '../../timeline-flash.js';
15
15
  import { formatClockDuration, formatElapsedShort } from '@adecore/ui/format';
16
- import { Icon, MiddleTruncate, Spinner, useTickingText } from '@adecore/ui';
16
+ import { Icon, LazyBoundary, MiddleTruncate, Spinner, useTickingText } from '@adecore/ui';
17
17
  import { ROW_GUTTER, toolIcon } from '../icons.js';
18
18
  import { AnsiOutput } from '../AnsiOutput.js';
19
19
  import { RowTime } from '../RowTime.js';
@@ -48,7 +48,7 @@ export function ToggleLine({ icon, label, detail, command, path, open = false, o
48
48
  /* The diff renderers load lazily, so a diff waits on them under a line that says so. */
49
49
  function DiffSuspense({ children }) {
50
50
  const { t } = useTranslation('agent-chat');
51
- return _jsx(Suspense, { fallback: _jsx("div", { className: "px-3 py-2 text-xs text-text-faint", children: t('work.loadingDiff') }), children: children });
51
+ return _jsx(LazyBoundary, { fallback: _jsx("div", { className: "px-3 py-2 text-xs text-text-faint", children: t('work.loadingDiff') }), children: children });
52
52
  }
53
53
  /* A file in a changed files card, which opens its diff under it. */
54
54
  function ChangedFileLine({ path, open, onToggle, trailing, children }) {
package/dist/lazy.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { lazy } from 'react';
3
+ import { LoadedComponent } from '@adecore/ui';
3
4
  const loaders = [];
4
5
  let register = null;
5
6
  const openErrorListeners = new Set();
@@ -29,12 +30,9 @@ export function onLazyOpenError(listener) {
29
30
  * module the prefetcher already fetched.
30
31
  */
31
32
  export function lazyNamed(load, name, options) {
32
- let loaded = null;
33
- const open = async () => {
34
- const component = (await load())[name];
35
- loaded ??= component;
36
- return component;
37
- };
33
+ // Through `LoadedComponent`, so a `LazyBoundary` around it draws the moment the module lands.
34
+ const loaded = new LoadedComponent(async () => (await load())[name]);
35
+ const open = () => loaded.load();
38
36
  if (options?.prefetch !== false) {
39
37
  loaders.push(open);
40
38
  register?.(open);
@@ -52,7 +50,7 @@ export function lazyNamed(load, name, options) {
52
50
  }
53
51
  });
54
52
  const LazyNamed = (props) => {
55
- const Component = loaded ?? Lazy;
53
+ const Component = loaded.current ?? Lazy;
56
54
  return _jsx(Component, { ...props });
57
55
  };
58
56
  return LazyNamed;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adecore/agents-react",
3
- "version": "0.24.0",
3
+ "version": "0.24.2",
4
4
  "description": "React agent chat threads, composers, approvals, provider accounts and usage views.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -67,11 +67,11 @@
67
67
  "test:visuals": "bun --config=integration.bunfig.toml --conditions=source test ./src/chat/visual-viewport.integration.test.ts"
68
68
  },
69
69
  "dependencies": {
70
- "@adecore/agent-contracts": "0.24.0",
71
- "@adecore/editor": "0.24.0",
72
- "@adecore/editor-react": "0.24.0",
73
- "@adecore/intelligent-ui": "0.24.0",
74
- "@adecore/merge": "0.24.0",
70
+ "@adecore/agent-contracts": "0.24.2",
71
+ "@adecore/editor": "0.24.2",
72
+ "@adecore/editor-react": "0.24.2",
73
+ "@adecore/intelligent-ui": "0.24.2",
74
+ "@adecore/merge": "0.24.2",
75
75
  "@codemirror/commands": "^6.11.1",
76
76
  "@codemirror/language": "^6.12.4",
77
77
  "@codemirror/state": "^6.7.6",
@@ -93,14 +93,14 @@
93
93
  "zustand": "^5.0.15"
94
94
  },
95
95
  "peerDependencies": {
96
- "@adecore/ui": "^0.24.0",
96
+ "@adecore/ui": "^0.24.2",
97
97
  "i18next": "^26.4.2",
98
98
  "react": "^19.3.0",
99
99
  "react-dom": "^19.3.0",
100
100
  "react-i18next": "^17.0.15"
101
101
  },
102
102
  "devDependencies": {
103
- "@adecore/ui": "0.24.0",
103
+ "@adecore/ui": "0.24.2",
104
104
  "@types/bun": "^1.4.2",
105
105
  "@types/react": "^19.3.0",
106
106
  "@types/react-dom": "^19.3.0",
@@ -1,4 +1,4 @@
1
- import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
1
+ import { useEffect, useMemo, useRef, useState, useSyncExternalStore, type MouseEvent, type PointerEvent } from 'react';
2
2
  import { useTranslation } from 'react-i18next';
3
3
  import clsx from 'clsx';
4
4
  import { ChevronDown } from 'lucide-react';
@@ -40,6 +40,7 @@ import { useNotice } from './composer/use-notice';
40
40
  import { usePendingRequests } from './composer/use-pending-requests';
41
41
  import { usePromptHistory } from './composer/use-prompt-history';
42
42
  import { usePromptHold } from './composer/use-prompt-hold';
43
+ import { surfacePartOf } from './composer/surface';
43
44
  import { useTakeBack } from './composer/use-take-back';
44
45
 
45
46
  interface ComposerProps {
@@ -154,6 +155,19 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
154
155
  submit
155
156
  });
156
157
 
158
+ const pointSurface = (event: PointerEvent<HTMLDivElement>) => {
159
+ event.currentTarget.toggleAttribute('data-text-cursor', surfacePartOf(event.target, event.currentTarget) !== 'action');
160
+ };
161
+
162
+ const pressSurface = (event: MouseEvent<HTMLDivElement>) => {
163
+ if (!writable || event.button !== 0 || event.defaultPrevented || surfacePartOf(event.target, event.currentTarget) !== 'surface') {
164
+ return;
165
+ }
166
+ // Nothing around the field selects, so the default would only blur it on the way, closing its picker and lifting a held prompt card.
167
+ event.preventDefault();
168
+ inputRef.current?.setCaret(Infinity);
169
+ };
170
+
157
171
  return (
158
172
  <div className="chat-composer-content pointer-events-none relative z-10 w-full">
159
173
  {/* Over the composer rather than in the thread, since the composer's height is the one it always clears.
@@ -191,104 +205,106 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
191
205
  onAllAnswered={() => inputRef.current?.focus()}
192
206
  hold={hold}
193
207
  >
194
- <LimitDock chatId={chatId} info={info} />
195
- <GoalDock chatId={chatId} info={info} goals={capabilities?.goals} />
196
- {compactionOffer && (
197
- <ResumeCompactionDock
198
- tokens={compactionOffer.tokens}
199
- onCompact={controls.compact}
200
- onDismiss={() => dismissResumeCompaction(scope, chatId, compactionOffer.turnId)}
208
+ <div className={clsx(writable && 'data-[text-cursor]:cursor-text')} onPointerOver={pointSurface} onMouseDown={pressSurface}>
209
+ <LimitDock chatId={chatId} info={info} />
210
+ <GoalDock chatId={chatId} info={info} goals={capabilities?.goals} />
211
+ {compactionOffer && (
212
+ <ResumeCompactionDock
213
+ tokens={compactionOffer.tokens}
214
+ onCompact={controls.compact}
215
+ onDismiss={() => dismissResumeCompaction(scope, chatId, compactionOffer.turnId)}
216
+ />
217
+ )}
218
+ <ComposerMenu menus={menus} />
219
+ {queue.length > 0 && (
220
+ <QueuedMessages
221
+ chatId={chatId}
222
+ queue={queue}
223
+ paused={info.queuePaused === true}
224
+ takingBack={takingBack !== null}
225
+ onEdit={(message) => void takeBack(message)}
226
+ />
227
+ )}
228
+ {draft.quote !== '' && (
229
+ <DraftQuote
230
+ chatId={chatId}
231
+ quote={draft.quote}
232
+ source={draft.quoteSource}
233
+ onRemove={box.removeQuote}
234
+ onSourceGone={() => notice.setNotice(t('rows.user.quoteGone'))}
235
+ />
236
+ )}
237
+ {draft.chats.length > 0 && <DraftChats chats={draft.chats} onRemove={box.removeChat} />}
238
+ {draft.attachments.length > 0 && <DraftAttachments attachments={draft.attachments} onRemove={box.removeAttachment} />}
239
+ <ComposerInput
240
+ ref={inputRef}
241
+ dictationToolbar={dictationToolbar}
242
+ className="composer-input select-text text-sm leading-normal text-text"
243
+ value={draft.text}
244
+ placeholder={placeholder}
245
+ disabled={!writable}
246
+ tabbable={focused}
247
+ extensions={editorExtensions}
248
+ aria={menus.inputAria}
249
+ onChange={(value, selection, state) => {
250
+ hold.noteInput();
251
+ box.setText(value);
252
+ menus.setMenuIndex(0);
253
+ menus.trackTriggers(value, selection, state);
254
+ history.edited(value);
255
+ }}
256
+ onSelectionChange={menus.trackTriggers}
257
+ onBlur={() => {
258
+ menus.close();
259
+ hold.release('blur');
260
+ }}
261
+ onComposing={hold.setComposing}
262
+ onKeyDown={onKeyDown}
263
+ onPaste={files.onPaste}
201
264
  />
202
- )}
203
- <ComposerMenu menus={menus} />
204
- {queue.length > 0 && (
205
- <QueuedMessages
265
+ {notice.notice && (
266
+ <div className="px-5 pb-1 text-xs text-status-error @max-md/composer:px-3.5" role="alert">
267
+ {notice.notice}
268
+ </div>
269
+ )}
270
+ {guard.visible && (
271
+ <div
272
+ className={clsx(
273
+ 'px-5 pb-1 text-right text-xs tabular-nums @max-md/composer:px-3.5',
274
+ guard.tooLong ? 'text-status-error' : 'text-text-faint'
275
+ )}
276
+ >
277
+ {formatNumber(guard.count)} / {formatNumber(PROMPT_MAX_CHARS)}
278
+ {guard.tooLong && ` ${t('composer.tooLong')}`}
279
+ </div>
280
+ )}
281
+ <ComposerControls
206
282
  chatId={chatId}
207
- queue={queue}
208
- paused={info.queuePaused === true}
209
- takingBack={takingBack !== null}
210
- onEdit={(message) => void takeBack(message)}
283
+ info={info}
284
+ providerFixed={providerFixed}
285
+ writable={writable}
286
+ attachable={capabilities?.attachments !== false}
287
+ controls={controls}
288
+ send={{
289
+ visible: !busy || !empty,
290
+ disabled: empty || !writable || guard.tooLong,
291
+ label: t(sendLabelKey(busy, steerable, sendDelivery)),
292
+ kbd: sendShortcut(sendKey, draft.text.includes('\n')),
293
+ onSend: (alternate) => submit(alternate ? 'alternate' : 'default')
294
+ }}
295
+ dictationToolbar={setDictationToolbar}
296
+ onFiles={(picked) => {
297
+ files.addFiles(picked);
298
+ inputRef.current?.focus();
299
+ }}
300
+ onRestore={restoreStashed}
301
+ insert={(added) => {
302
+ if (writable) {
303
+ inputRef.current?.insert(added);
304
+ }
305
+ }}
211
306
  />
212
- )}
213
- {draft.quote !== '' && (
214
- <DraftQuote
215
- chatId={chatId}
216
- quote={draft.quote}
217
- source={draft.quoteSource}
218
- onRemove={box.removeQuote}
219
- onSourceGone={() => notice.setNotice(t('rows.user.quoteGone'))}
220
- />
221
- )}
222
- {draft.chats.length > 0 && <DraftChats chats={draft.chats} onRemove={box.removeChat} />}
223
- {draft.attachments.length > 0 && <DraftAttachments attachments={draft.attachments} onRemove={box.removeAttachment} />}
224
- <ComposerInput
225
- ref={inputRef}
226
- dictationToolbar={dictationToolbar}
227
- className="composer-input select-text text-sm leading-normal text-text"
228
- value={draft.text}
229
- placeholder={placeholder}
230
- disabled={!writable}
231
- tabbable={focused}
232
- extensions={editorExtensions}
233
- aria={menus.inputAria}
234
- onChange={(value, selection, state) => {
235
- hold.noteInput();
236
- box.setText(value);
237
- menus.setMenuIndex(0);
238
- menus.trackTriggers(value, selection, state);
239
- history.edited(value);
240
- }}
241
- onSelectionChange={menus.trackTriggers}
242
- onBlur={() => {
243
- menus.close();
244
- hold.release('blur');
245
- }}
246
- onComposing={hold.setComposing}
247
- onKeyDown={onKeyDown}
248
- onPaste={files.onPaste}
249
- />
250
- {notice.notice && (
251
- <div className="px-5 pb-1 text-xs text-status-error @max-md/composer:px-3.5" role="alert">
252
- {notice.notice}
253
- </div>
254
- )}
255
- {guard.visible && (
256
- <div
257
- className={clsx(
258
- 'px-5 pb-1 text-right text-xs tabular-nums @max-md/composer:px-3.5',
259
- guard.tooLong ? 'text-status-error' : 'text-text-faint'
260
- )}
261
- >
262
- {formatNumber(guard.count)} / {formatNumber(PROMPT_MAX_CHARS)}
263
- {guard.tooLong && ` ${t('composer.tooLong')}`}
264
- </div>
265
- )}
266
- <ComposerControls
267
- chatId={chatId}
268
- info={info}
269
- providerFixed={providerFixed}
270
- writable={writable}
271
- attachable={capabilities?.attachments !== false}
272
- controls={controls}
273
- send={{
274
- visible: !busy || !empty,
275
- disabled: empty || !writable || guard.tooLong,
276
- label: t(sendLabelKey(busy, steerable, sendDelivery)),
277
- kbd: sendShortcut(sendKey, draft.text.includes('\n')),
278
- onSend: (alternate) => submit(alternate ? 'alternate' : 'default')
279
- }}
280
- dictationToolbar={setDictationToolbar}
281
- onFiles={(picked) => {
282
- files.addFiles(picked);
283
- inputRef.current?.focus();
284
- }}
285
- onRestore={restoreStashed}
286
- insert={(added) => {
287
- if (writable) {
288
- inputRef.current?.insert(added);
289
- }
290
- }}
291
- />
307
+ </div>
292
308
  </PromptComposer>
293
309
  </div>
294
310
  <PromptDialog
@@ -1,4 +1,5 @@
1
- import { Suspense, useMemo } from 'react';
1
+ import { useMemo } from 'react';
2
+ import { LazyBoundary } from '@adecore/ui';
2
3
  import { useTranslation } from 'react-i18next';
3
4
  import { lazyNamed } from '../../lazy';
4
5
  import { diffSourceBudget, legacyDiffBudget } from './diff/diff-source-budget';
@@ -22,9 +23,9 @@ export default function EditDiff({ change, allowEditor = true, maxHeight }: { ch
22
23
  const omitted = <DiffOmitted path={change.path} />;
23
24
  const fallback = legacyAllowed ? (
24
25
  <DiffFallbackBoundary fallback={omitted} resetKeys={[change.path, change.before, change.after]}>
25
- <Suspense fallback={<div className="px-3 py-2 text-xs text-text-faint">{t('work.loadingDiff')}</div>}>
26
+ <LazyBoundary fallback={<div className="px-3 py-2 text-xs text-text-faint">{t('work.loadingDiff')}</div>}>
26
27
  <LegacyEditDiff change={change} maxHeight={maxHeight} />
27
- </Suspense>
28
+ </LazyBoundary>
28
29
  </DiffFallbackBoundary>
29
30
  ) : (
30
31
  omitted
@@ -38,9 +39,9 @@ export default function EditDiff({ change, allowEditor = true, maxHeight }: { ch
38
39
  const unavailable = <DiffUnavailable path={change.path} />;
39
40
  return (
40
41
  <DiffFallbackBoundary fallback={unavailable} resetKeys={[change.path, change.before, change.after, loadEngine]}>
41
- <Suspense fallback={<div className="px-3 py-2 text-xs text-text-faint">{t('work.loadingDiff')}</div>}>
42
+ <LazyBoundary fallback={<div className="px-3 py-2 text-xs text-text-faint">{t('work.loadingDiff')}</div>}>
42
43
  <EditorEditDiff change={change} loadEngine={loadEngine!} fallback={unavailable} maxHeight={maxHeight} sourceChecked />
43
- </Suspense>
44
+ </LazyBoundary>
44
45
  </DiffFallbackBoundary>
45
46
  );
46
47
  }
@@ -1,4 +1,5 @@
1
- import { Suspense, useMemo } from 'react';
1
+ import { useMemo } from 'react';
2
+ import { LazyBoundary } from '@adecore/ui';
2
3
  import { useTranslation } from 'react-i18next';
3
4
  import { lazyNamed } from '../../lazy';
4
5
  import { diffSourceBudget, legacyDiffBudget, patchSourceBudget } from './diff/diff-source-budget';
@@ -27,9 +28,9 @@ export default function UnifiedDiff(props: UnifiedDiffProps) {
27
28
  const omitted = <DiffOmitted path={change.path} />;
28
29
  const fallback = legacyAllowed ? (
29
30
  <DiffFallbackBoundary fallback={omitted} resetKeys={[change.path, change.diff, oldText, newText, diffStyle]}>
30
- <Suspense fallback={<div className="px-3 py-2 text-xs text-text-faint">{t('work.loadingDiff')}</div>}>
31
+ <LazyBoundary fallback={<div className="px-3 py-2 text-xs text-text-faint">{t('work.loadingDiff')}</div>}>
31
32
  <LegacyUnifiedDiff {...props} />
32
- </Suspense>
33
+ </LazyBoundary>
33
34
  </DiffFallbackBoundary>
34
35
  ) : (
35
36
  omitted
@@ -46,7 +47,7 @@ export default function UnifiedDiff(props: UnifiedDiffProps) {
46
47
  const unavailable = <DiffUnavailable path={change.path} />;
47
48
  return (
48
49
  <DiffFallbackBoundary fallback={unavailable} resetKeys={[change.path, change.diff, oldText, newText, ignoreWhitespace, diffStyle, loadEngine]}>
49
- <Suspense fallback={<div className="px-3 py-2 text-xs text-text-faint">{t('work.loadingDiff')}</div>}>
50
+ <LazyBoundary fallback={<div className="px-3 py-2 text-xs text-text-faint">{t('work.loadingDiff')}</div>}>
50
51
  <EditorPatchDiff
51
52
  change={change}
52
53
  sourceChecked
@@ -59,7 +60,7 @@ export default function UnifiedDiff(props: UnifiedDiffProps) {
59
60
  loadEngine={loadEngine!}
60
61
  fallback={unavailable}
61
62
  />
62
- </Suspense>
63
+ </LazyBoundary>
63
64
  </DiffFallbackBoundary>
64
65
  );
65
66
  }
@@ -0,0 +1,45 @@
1
+ /* Anything with an action of its own; `data-composer-action` marks one a role cannot name, such as a control of the host's. */
2
+ const ACTIONS = [
3
+ 'a',
4
+ 'button',
5
+ 'input',
6
+ 'select',
7
+ 'textarea',
8
+ 'label',
9
+ 'summary',
10
+ '[contenteditable]:not([contenteditable="false"])',
11
+ '[tabindex]:not([tabindex="-1"])',
12
+ '[role="button"]',
13
+ '[role="link"]',
14
+ '[role="menuitem"]',
15
+ '[role="menuitemcheckbox"]',
16
+ '[role="menuitemradio"]',
17
+ '[role="option"]',
18
+ '[role="listbox"]',
19
+ '[role="checkbox"]',
20
+ '[role="radio"]',
21
+ '[role="switch"]',
22
+ '[role="tab"]',
23
+ '[role="slider"]',
24
+ '[role="combobox"]',
25
+ '[data-composer-action]'
26
+ ].join(', ');
27
+
28
+ /*
29
+ * Where a pointer is on the composer: in the text field, on an action, or on the space around them,
30
+ * which stands in for the text field. A target outside the surface is an action, as an event from a
31
+ * popup portaled out of it still reaches the surface through React.
32
+ */
33
+ export function surfacePartOf(target: EventTarget | null, surface: Element): 'input' | 'action' | 'surface' {
34
+ // Not `instanceof Element`, which fails for an element of another window.
35
+ const element = target as Element | null;
36
+ if (typeof element?.closest !== 'function' || !surface.contains(element)) {
37
+ return 'action';
38
+ }
39
+ const input = element.closest('.cm-editor');
40
+ if (input !== null && surface.contains(input)) {
41
+ return 'input';
42
+ }
43
+ const action = element.closest(ACTIONS);
44
+ return action !== null && surface.contains(action) ? 'action' : 'surface';
45
+ }
@@ -1,4 +1,4 @@
1
- import { Suspense } from 'react';
1
+ import { LazyBoundary } from '@adecore/ui';
2
2
  import { useTranslation } from 'react-i18next';
3
3
  import { lazyNamed } from '../../../lazy';
4
4
  import type { ReviewEntry } from './review-plan';
@@ -10,13 +10,13 @@ export function ReviewDiff({ entry, maxHeight = 480 }: { entry: ReviewEntry; max
10
10
  const { t } = useTranslation('agent-chat');
11
11
  return (
12
12
  <div data-review-entry={entry.key}>
13
- <Suspense fallback={<p className="px-3 py-2 text-xs text-text-faint">{t('work.loadingDiff')}</p>}>
13
+ <LazyBoundary fallback={<p className="px-3 py-2 text-xs text-text-faint">{t('work.loadingDiff')}</p>}>
14
14
  {entry.change.kind === 'edit' ? (
15
15
  <EditDiff change={entry.change.edit} maxHeight={maxHeight} />
16
16
  ) : (
17
17
  <UnifiedDiff change={entry.change.patch} contents={entry.change.contents} maxHeight={maxHeight} />
18
18
  )}
19
- </Suspense>
19
+ </LazyBoundary>
20
20
  </div>
21
21
  );
22
22
  }
@@ -1,4 +1,4 @@
1
- import { Fragment, Suspense, useEffect, useState, type Dispatch, type ReactNode, type SetStateAction } from 'react';
1
+ import { Fragment, useEffect, useState, type Dispatch, type ReactNode, type SetStateAction } from 'react';
2
2
  import clsx from 'clsx';
3
3
  import i18next from 'i18next';
4
4
  import { useTranslation } from 'react-i18next';
@@ -28,7 +28,7 @@ import { useChatScope } from '../../../scope';
28
28
  import { useCurrentItem } from '../../../state/chats';
29
29
  import { useTimelineFlash } from '../../timeline-flash';
30
30
  import { formatClockDuration, formatElapsedShort } from '@adecore/ui/format';
31
- import { Icon, MiddleTruncate, Spinner, useTickingText } from '@adecore/ui';
31
+ import { Icon, LazyBoundary, MiddleTruncate, Spinner, useTickingText } from '@adecore/ui';
32
32
  import { ROW_GUTTER, toolIcon } from '../icons';
33
33
  import { AnsiOutput } from '../AnsiOutput';
34
34
  import { RowTime } from '../RowTime';
@@ -122,7 +122,7 @@ export function ToggleLine({
122
122
  /* The diff renderers load lazily, so a diff waits on them under a line that says so. */
123
123
  function DiffSuspense({ children }: { children: ReactNode }) {
124
124
  const { t } = useTranslation('agent-chat');
125
- return <Suspense fallback={<div className="px-3 py-2 text-xs text-text-faint">{t('work.loadingDiff')}</div>}>{children}</Suspense>;
125
+ return <LazyBoundary fallback={<div className="px-3 py-2 text-xs text-text-faint">{t('work.loadingDiff')}</div>}>{children}</LazyBoundary>;
126
126
  }
127
127
 
128
128
  /* A file in a changed files card, which opens its diff under it. */
package/src/lazy.tsx CHANGED
@@ -1,4 +1,5 @@
1
1
  import { lazy, type ComponentProps, type ComponentType } from 'react';
2
+ import { LoadedComponent } from '@adecore/ui';
2
3
 
3
4
  type Loader = () => Promise<unknown>;
4
5
 
@@ -38,12 +39,9 @@ export function lazyNamed<Module extends Record<Name, ComponentType<any>>, Name
38
39
  name: Name,
39
40
  options?: { readonly prefetch?: boolean }
40
41
  ): ComponentType<ComponentProps<Module[Name]>> {
41
- let loaded: ComponentType<any> | null = null;
42
- const open = async (): Promise<ComponentType<any>> => {
43
- const component = (await load())[name];
44
- loaded ??= component;
45
- return component;
46
- };
42
+ // Through `LoadedComponent`, so a `LazyBoundary` around it draws the moment the module lands.
43
+ const loaded = new LoadedComponent<ComponentType<any>>(async () => (await load())[name]);
44
+ const open = (): Promise<ComponentType<any>> => loaded.load();
47
45
  if (options?.prefetch !== false) {
48
46
  loaders.push(open);
49
47
  register?.(open);
@@ -60,7 +58,7 @@ export function lazyNamed<Module extends Record<Name, ComponentType<any>>, Name
60
58
  }
61
59
  });
62
60
  const LazyNamed = (props: ComponentProps<Module[Name]>) => {
63
- const Component: ComponentType<any> = loaded ?? Lazy;
61
+ const Component: ComponentType<any> = loaded.current ?? Lazy;
64
62
  return <Component {...props} />;
65
63
  };
66
64
  return LazyNamed;