@adecore/agents-react 0.37.1 → 0.37.3
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.
- package/dist/chat/chat-client.d.ts +1 -0
- package/dist/chat/chat-client.js +2 -1
- package/dist/chat/chat-references.d.ts +16 -1
- package/dist/chat/chat-references.js +29 -3
- package/dist/chat/drafts.d.ts +2 -0
- package/dist/chat/drafts.js +21 -5
- package/dist/chat/index.d.ts +1 -1
- package/dist/chat/logic/picker-entries.d.ts +6 -1
- package/dist/chat/logic/picker-entries.js +10 -0
- package/dist/chat/mentions.d.ts +31 -14
- package/dist/chat/mentions.js +186 -41
- package/dist/chat/stash.d.ts +1 -0
- package/dist/chat/stash.js +4 -2
- package/dist/chat/ui/Composer.js +438 -429
- package/dist/chat/ui/ComposerInput.d.ts +1 -0
- package/dist/chat/ui/ComposerInput.js +2 -0
- package/dist/chat/ui/Markdown.d.ts +3 -1
- package/dist/chat/ui/Markdown.js +194 -120
- package/dist/chat/ui/MessageActions.js +70 -66
- package/dist/chat/ui/Pickers.js +151 -207
- package/dist/chat/ui/ReferenceChip.d.ts +15 -0
- package/dist/chat/ui/ReferenceChip.js +255 -0
- package/dist/chat/ui/RunSettings.js +25 -21
- package/dist/chat/ui/Timeline.js +52 -29
- package/dist/chat/ui/TimelineMenu.js +84 -78
- package/dist/chat/ui/chip-clipboard.d.ts +9 -0
- package/dist/chat/ui/chip-clipboard.js +105 -0
- package/dist/chat/ui/chips.d.ts +5 -0
- package/dist/chat/ui/chips.js +15 -1
- package/dist/chat/ui/composer/ComposerMenu.js +140 -79
- package/dist/chat/ui/composer/DraftParts.d.ts +0 -4
- package/dist/chat/ui/composer/DraftParts.js +0 -41
- package/dist/chat/ui/composer/chip-paste.d.ts +15 -0
- package/dist/chat/ui/composer/chip-paste.js +104 -0
- package/dist/chat/ui/composer/chips.d.ts +5 -0
- package/dist/chat/ui/composer/chips.js +59 -9
- package/dist/chat/ui/composer/key-handler.js +9 -0
- package/dist/chat/ui/composer/use-composer-draft.d.ts +0 -1
- package/dist/chat/ui/composer/use-composer-draft.js +2 -10
- package/dist/chat/ui/composer/use-composer-menus.d.ts +12 -2
- package/dist/chat/ui/composer/use-composer-menus.js +192 -149
- package/dist/chat/ui/composer/use-composer-submit.js +17 -12
- package/dist/chat/ui/composer/use-draft-files.d.ts +7 -1
- package/dist/chat/ui/composer/use-draft-files.js +90 -58
- package/dist/chat/ui/composer/use-take-back.js +2 -1
- package/dist/chat/ui/index.d.ts +0 -1
- package/dist/chat/ui/index.js +0 -1
- package/dist/chat/ui/reference-icons.d.ts +3 -0
- package/dist/chat/ui/reference-icons.js +15 -0
- package/dist/chat/ui/reference-lookup.d.ts +7 -0
- package/dist/chat/ui/reference-lookup.js +6 -0
- package/dist/chat/ui/rehype-chips.d.ts +8 -1
- package/dist/chat/ui/rehype-chips.js +35 -11
- package/dist/chat/ui/rows/MessageRows.js +218 -158
- package/dist/chat/ui/thread-references.d.ts +4 -0
- package/dist/chat/ui/thread-references.js +78 -0
- package/dist/host-hooks.d.ts +2 -0
- package/dist/host-hooks.js +7 -0
- package/dist/host.d.ts +3 -0
- package/dist/locales/en/agent-chat.d.ts +20 -3
- package/dist/locales/en/agent-chat.js +24 -7
- package/dist/locales/nl/agent-chat.d.ts +20 -3
- package/dist/locales/nl/agent-chat.js +24 -7
- package/package.json +8 -8
- package/src/chat/chat-client.ts +4 -1
- package/src/chat/chat-references.ts +44 -3
- package/src/chat/drafts.ts +24 -6
- package/src/chat/index.ts +1 -1
- package/src/chat/logic/picker-entries.ts +12 -1
- package/src/chat/mentions.ts +202 -36
- package/src/chat/stash.ts +6 -2
- package/src/chat/ui/Composer.tsx +14 -4
- package/src/chat/ui/ComposerInput.tsx +4 -0
- package/src/chat/ui/Markdown.tsx +57 -25
- package/src/chat/ui/MessageActions.tsx +5 -2
- package/src/chat/ui/Pickers.tsx +8 -2
- package/src/chat/ui/ReferenceChip.tsx +74 -0
- package/src/chat/ui/RunSettings.tsx +29 -23
- package/src/chat/ui/Timeline.tsx +24 -2
- package/src/chat/ui/TimelineMenu.tsx +7 -3
- package/src/chat/ui/chip-clipboard.ts +115 -0
- package/src/chat/ui/chips.ts +20 -1
- package/src/chat/ui/composer/ComposerMenu.tsx +53 -37
- package/src/chat/ui/composer/DraftParts.tsx +0 -12
- package/src/chat/ui/composer/chip-paste.ts +122 -0
- package/src/chat/ui/composer/chips.ts +60 -10
- package/src/chat/ui/composer/key-handler.ts +9 -0
- package/src/chat/ui/composer/use-composer-draft.ts +1 -7
- package/src/chat/ui/composer/use-composer-menus.ts +68 -29
- package/src/chat/ui/composer/use-composer-submit.ts +13 -6
- package/src/chat/ui/composer/use-draft-files.ts +39 -5
- package/src/chat/ui/composer/use-take-back.ts +2 -1
- package/src/chat/ui/index.ts +0 -1
- package/src/chat/ui/reference-icons.ts +17 -0
- package/src/chat/ui/reference-lookup.ts +13 -0
- package/src/chat/ui/rehype-chips.ts +38 -13
- package/src/chat/ui/rows/MessageRows.tsx +88 -64
- package/src/chat/ui/thread-references.ts +47 -0
- package/src/host-hooks.ts +9 -0
- package/src/host.ts +6 -1
- package/src/locales/en/agent-chat.ts +24 -7
- package/src/locales/nl/agent-chat.ts +24 -7
- package/dist/chat/ui/ChatReferenceChip.d.ts +0 -4
- package/dist/chat/ui/ChatReferenceChip.js +0 -110
- package/src/chat/ui/ChatReferenceChip.tsx +0 -26
package/src/chat/ui/Pickers.tsx
CHANGED
|
@@ -10,7 +10,7 @@ import { useChatHost } from '../../host';
|
|
|
10
10
|
import { ChatScopeContext } from '../../scope';
|
|
11
11
|
import { useReportedModels } from '../../state/chats';
|
|
12
12
|
import { ModelMarks } from './ModelMarks';
|
|
13
|
-
import { pickerEntriesOf, type PickerEntry } from '../logic/picker-entries';
|
|
13
|
+
import { inPickerOrder, pickerEntriesOf, separatesGroups, type PickerEntry } from '../logic/picker-entries';
|
|
14
14
|
import { favoriteModelsOf, toggleFavoriteModel, useChatPreferences } from '../preferences';
|
|
15
15
|
import { forgetStashed, STASH_SHORTCUT, useStash, type StashedPrompt } from '../stash';
|
|
16
16
|
import { Button, Icon, IconButton, Menu, Popover, Tooltip } from '@adecore/ui';
|
|
@@ -75,7 +75,7 @@ export function ModelPicker({
|
|
|
75
75
|
|
|
76
76
|
const favorites = useChatPreferences((preferences) => preferences.favoriteModels);
|
|
77
77
|
const starredHere = (row: PickerEntry | undefined): boolean => row?.kind === 'model' && favorites[row.provider.kind]?.includes(row.model.slug) === true;
|
|
78
|
-
const entries = pickerEntriesOf(providers, trimmed, legacyOpen, expanded, { provider, model: selection.model }, (kind) =>
|
|
78
|
+
const entries = pickerEntriesOf(inPickerOrder(providers), trimmed, legacyOpen, expanded, { provider, model: selection.model }, (kind) =>
|
|
79
79
|
favoriteModelsOf(favorites, kind)
|
|
80
80
|
);
|
|
81
81
|
const scope = useContext(ChatScopeContext);
|
|
@@ -83,6 +83,10 @@ export function ModelPicker({
|
|
|
83
83
|
const reported = useReportedModels(provider);
|
|
84
84
|
const requirementOf = (entry: PickerEntry & { kind: 'model' }) => modelRequirementOf(entry.model, entry.provider, reported);
|
|
85
85
|
// A CLI that is too old blocks all its models; one way to its settings under the group says it once.
|
|
86
|
+
const kindBefore = (i: number): AgentKind | undefined => {
|
|
87
|
+
const before = entries[i - 1];
|
|
88
|
+
return before === undefined || before.kind === 'legacy' ? undefined : before.provider.kind;
|
|
89
|
+
};
|
|
86
90
|
const blockedKinds = new Set(entries.flatMap((entry) => (entry.kind === 'model' && requirementOf(entry) !== null ? [entry.provider.kind] : [])));
|
|
87
91
|
|
|
88
92
|
/* Closing forgets the search, so the next open (a click or `/model`) starts on the whole list. */
|
|
@@ -188,6 +192,7 @@ export function ModelPicker({
|
|
|
188
192
|
const header = grouped && (before === undefined || before.kind === 'legacy' || before.provider.kind !== entry.provider.kind);
|
|
189
193
|
return (
|
|
190
194
|
<div key={`all-${entry.provider.kind}`}>
|
|
195
|
+
{separatesGroups(kindBefore(i), entry.provider.kind) && <Menu.Separator />}
|
|
191
196
|
{header && (
|
|
192
197
|
<Menu.Label className="flex items-center gap-1.5">
|
|
193
198
|
<AgentIcon kind={entry.provider.kind} size={12} />
|
|
@@ -243,6 +248,7 @@ export function ModelPicker({
|
|
|
243
248
|
const starred = starredHere(entry);
|
|
244
249
|
return (
|
|
245
250
|
<div key={`${entry.provider.kind}/${entry.model.slug}`}>
|
|
251
|
+
{separatesGroups(kindBefore(i), entry.provider.kind) && <Menu.Separator />}
|
|
246
252
|
{first && (
|
|
247
253
|
<Menu.Label className="flex items-center gap-1.5">
|
|
248
254
|
<AgentIcon kind={entry.provider.kind} size={12} />
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { useContext, type HTMLAttributes, type KeyboardEvent, type ReactNode } from 'react';
|
|
2
|
+
import clsx from 'clsx';
|
|
3
|
+
import { useTranslation } from 'react-i18next';
|
|
4
|
+
import { CircleOff } from 'lucide-react';
|
|
5
|
+
import { Icon } from '@adecore/ui';
|
|
6
|
+
import { REFERENCE_GONE_TONE, REFERENCE_TONE, TOKEN_ICON, TOKEN_IN_MESSAGE } from './chips';
|
|
7
|
+
import { REFERENCE_ICONS } from './reference-icons';
|
|
8
|
+
import { ReferenceLookupContext } from './reference-lookup';
|
|
9
|
+
|
|
10
|
+
interface TokenChipProps extends Omit<HTMLAttributes<HTMLSpanElement>, 'children'> {
|
|
11
|
+
/* `gone` for a reference that stands for nothing any more, which a copy leaves as text. */
|
|
12
|
+
kind: 'mention' | 'skill' | 'reference' | 'gone';
|
|
13
|
+
/* The path, the skill's name or the reference's id. */
|
|
14
|
+
value?: string;
|
|
15
|
+
/* The sigil the mark stands in for. Hidden, but a selection copies it with the name. */
|
|
16
|
+
sigil?: string;
|
|
17
|
+
icon: ReactNode;
|
|
18
|
+
label: string;
|
|
19
|
+
tone: string;
|
|
20
|
+
onOpen?: () => void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/*
|
|
24
|
+
* One token in the transcript. One that opens is a span with a button's role rather than a button,
|
|
25
|
+
* since a button is a box with a baseline of its own and would not sit on the line the others sit on.
|
|
26
|
+
*/
|
|
27
|
+
export function TokenChip({ kind, value, sigil, icon, label, tone, onOpen, className, ...rest }: TokenChipProps) {
|
|
28
|
+
const onKeyDown = (e: KeyboardEvent<HTMLSpanElement>): void => {
|
|
29
|
+
if (onOpen !== undefined && (e.key === 'Enter' || e.key === ' ')) {
|
|
30
|
+
e.preventDefault();
|
|
31
|
+
onOpen();
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
return (
|
|
35
|
+
<span
|
|
36
|
+
{...rest}
|
|
37
|
+
className={clsx(TOKEN_IN_MESSAGE, tone, onOpen !== undefined && 'cursor-pointer focus-visible:outline-2 focus-visible:outline-accent', className)}
|
|
38
|
+
data-chip={kind}
|
|
39
|
+
data-chip-value={value}
|
|
40
|
+
data-chip-name={kind === 'reference' ? label : undefined}
|
|
41
|
+
role={onOpen === undefined ? undefined : 'button'}
|
|
42
|
+
tabIndex={onOpen === undefined ? undefined : 0}
|
|
43
|
+
onClick={onOpen}
|
|
44
|
+
onKeyDown={onOpen === undefined ? undefined : onKeyDown}
|
|
45
|
+
>
|
|
46
|
+
{icon}
|
|
47
|
+
{sigil !== undefined && <span className="sr-only">{sigil}</span>}
|
|
48
|
+
{label}
|
|
49
|
+
</span>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/* A view, node or chat a message points at, under the name it has now; one that went says so. */
|
|
54
|
+
export function ReferenceChip({ id }: { id: string }) {
|
|
55
|
+
const { t } = useTranslation('agent-chat');
|
|
56
|
+
const { items, open } = useContext(ReferenceLookupContext);
|
|
57
|
+
const item = items.find((candidate) => candidate.id === id);
|
|
58
|
+
if (item === undefined) {
|
|
59
|
+
return (
|
|
60
|
+
<TokenChip kind="gone" icon={<Icon icon={CircleOff} size={14} className={TOKEN_ICON} />} label={t('references.gone')} tone={REFERENCE_GONE_TONE} />
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
return (
|
|
64
|
+
<TokenChip
|
|
65
|
+
kind="reference"
|
|
66
|
+
value={item.id}
|
|
67
|
+
sigil="#"
|
|
68
|
+
icon={<Icon icon={REFERENCE_ICONS[item.kind]} size={14} className={TOKEN_ICON} />}
|
|
69
|
+
label={item.name || t('references.untitled')}
|
|
70
|
+
tone={clsx(REFERENCE_TONE, open !== null && 'hover:bg-text/12')}
|
|
71
|
+
onOpen={open === null ? undefined : () => open(item.id)}
|
|
72
|
+
/>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
@@ -27,7 +27,7 @@ import type { AccountChoice } from '../account-choice';
|
|
|
27
27
|
import { modelName, shortModelName } from '../../agents/model-name';
|
|
28
28
|
import { ModelMarks } from './ModelMarks';
|
|
29
29
|
import { SessionLine } from './SessionLine';
|
|
30
|
-
import { favoritesFirst, searchModels, shortlistOf, wantsModelSearch } from '../logic/picker-entries';
|
|
30
|
+
import { favoritesFirst, inPickerOrder, searchModels, separatesGroups, shortlistOf, wantsModelSearch } from '../logic/picker-entries';
|
|
31
31
|
import { favoriteModelsOf, toggleFavoriteModel, useChatPreferences } from '../preferences';
|
|
32
32
|
import { CONTEXT_OPTION, contextFraction, contextSegments, orderOptions, type ContextPart } from '../logic/context-usage';
|
|
33
33
|
import { RUNTIME_MODES } from '../runtime-modes';
|
|
@@ -113,7 +113,7 @@ function modelValue(provider: AgentKind, slug: string): string {
|
|
|
113
113
|
|
|
114
114
|
/* Each provider's current or legacy models; a provider with none of them is left out. */
|
|
115
115
|
function modelGroupsOf(providers: ProviderInfo[], legacy: boolean): Array<{ entry: ProviderInfo; models: ModelInfo[] }> {
|
|
116
|
-
return providers
|
|
116
|
+
return inPickerOrder(providers)
|
|
117
117
|
.map((entry) => ({ entry, models: entry.models.filter((row) => (row.legacy === true) === legacy) }))
|
|
118
118
|
.filter((group) => group.models.length > 0);
|
|
119
119
|
}
|
|
@@ -177,7 +177,7 @@ export function RunSettings({
|
|
|
177
177
|
const searchable = wantsModelSearch(providers);
|
|
178
178
|
const favorites = useChatPreferences((preferences) => preferences.favoriteModels);
|
|
179
179
|
const favoritesOf = (kind: AgentKind): ReadonlySet<string> => favoriteModelsOf(favorites, kind);
|
|
180
|
-
const found = query.trim() === '' ? null : searchModels(providers, query, favoritesOf);
|
|
180
|
+
const found = query.trim() === '' ? null : searchModels(inPickerOrder(providers), query, favoritesOf);
|
|
181
181
|
|
|
182
182
|
// The menu moves the focus to its popup as it opens, so the search field takes it a frame later.
|
|
183
183
|
useEffect(() => {
|
|
@@ -292,16 +292,19 @@ export function RunSettings({
|
|
|
292
292
|
};
|
|
293
293
|
|
|
294
294
|
const modelGroups = (groups: typeof live) =>
|
|
295
|
-
groups.map(({ entry, models }) => (
|
|
296
|
-
<
|
|
297
|
-
{
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
295
|
+
groups.map(({ entry, models }, i) => (
|
|
296
|
+
<Fragment key={entry.kind}>
|
|
297
|
+
{separatesGroups(groups[i - 1]?.entry.kind, entry.kind) && <Menu.Separator />}
|
|
298
|
+
<Menu.Group>
|
|
299
|
+
{grouped && (
|
|
300
|
+
<Menu.GroupLabel className="flex items-center gap-1.5">
|
|
301
|
+
<AgentIcon kind={entry.kind} size={12} />
|
|
302
|
+
{entry.name}
|
|
303
|
+
</Menu.GroupLabel>
|
|
304
|
+
)}
|
|
305
|
+
{providerModels(entry, models)}
|
|
306
|
+
</Menu.Group>
|
|
307
|
+
</Fragment>
|
|
305
308
|
));
|
|
306
309
|
|
|
307
310
|
return (
|
|
@@ -370,16 +373,19 @@ export function RunSettings({
|
|
|
370
373
|
{found !== null && found.length === 0 && <div className="px-2.5 py-3 text-xs text-text-faint">{t('pickers.model.noMatch')}</div>}
|
|
371
374
|
{found !== null && found.length > 0 && (
|
|
372
375
|
<Menu.RadioGroup value={chosenModel} onValueChange={chooseModel}>
|
|
373
|
-
{found.map(({ entry, models }) => (
|
|
374
|
-
<
|
|
375
|
-
{
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
376
|
+
{found.map(({ entry, models }, i) => (
|
|
377
|
+
<Fragment key={entry.kind}>
|
|
378
|
+
{separatesGroups(found[i - 1]?.entry.kind, entry.kind) && <Menu.Separator />}
|
|
379
|
+
<Menu.Group>
|
|
380
|
+
{grouped && (
|
|
381
|
+
<Menu.GroupLabel className="flex items-center gap-1.5">
|
|
382
|
+
<AgentIcon kind={entry.kind} size={12} />
|
|
383
|
+
{entry.name}
|
|
384
|
+
</Menu.GroupLabel>
|
|
385
|
+
)}
|
|
386
|
+
{byMaker(entry, models)}
|
|
387
|
+
</Menu.Group>
|
|
388
|
+
</Fragment>
|
|
383
389
|
))}
|
|
384
390
|
</Menu.RadioGroup>
|
|
385
391
|
)}
|
package/src/chat/ui/Timeline.tsx
CHANGED
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
setTimelineAtEnd,
|
|
30
30
|
wantsEarlier,
|
|
31
31
|
type ItemJumpOptions,
|
|
32
|
+
type ReadingAnchor,
|
|
32
33
|
type TurnTarget
|
|
33
34
|
} from '../timeline-scroll';
|
|
34
35
|
import { useTimelineFlash } from '../timeline-flash';
|
|
@@ -226,6 +227,8 @@ export function Timeline({ chatId, composer, overlay }: { chatId: string; compos
|
|
|
226
227
|
const [frame, setFrame] = useState({ width: 0, height: 0, column: false });
|
|
227
228
|
const [loadingEarlier, setLoadingEarlier] = useState(false);
|
|
228
229
|
const loadingEarlierRef = useRef(false);
|
|
230
|
+
const anchorRef = useRef<ReadingAnchor | null>(null);
|
|
231
|
+
const headRef = useRef<string | undefined>(undefined);
|
|
229
232
|
|
|
230
233
|
const loadEarlier = useCallback(() => {
|
|
231
234
|
if (cursor === null || loadingEarlierRef.current) {
|
|
@@ -355,7 +358,7 @@ export function Timeline({ chatId, composer, overlay }: { chatId: string; compos
|
|
|
355
358
|
// Changing this callback invalidates every cached measurement, including on a scroll.
|
|
356
359
|
const getItemKey = useCallback((index: number) => rows[index]!.id, [rows]);
|
|
357
360
|
|
|
358
|
-
//
|
|
361
|
+
// Follow the browser's actual end below. Virtual end anchoring drifts when growth still fits without scrolling.
|
|
359
362
|
// oxlint-disable-next-line react/incompatible-library
|
|
360
363
|
const virtualizer = useVirtualizer({
|
|
361
364
|
count: loadingHistory ? 0 : rows.length,
|
|
@@ -363,7 +366,6 @@ export function Timeline({ chatId, composer, overlay }: { chatId: string; compos
|
|
|
363
366
|
estimateSize: () => ESTIMATED_ROW_PX,
|
|
364
367
|
getItemKey,
|
|
365
368
|
overscan: 8,
|
|
366
|
-
anchorTo: 'end',
|
|
367
369
|
// Include the end before the first pin, so its actual heights are known before revealing it.
|
|
368
370
|
rangeExtractor: (range) => {
|
|
369
371
|
const indexes = defaultRangeExtractor(range);
|
|
@@ -414,6 +416,26 @@ export function Timeline({ chatId, composer, overlay }: { chatId: string; compos
|
|
|
414
416
|
scrollGeometry.current = { top: element.scrollTop, height: element.scrollHeight, viewport: element.clientHeight };
|
|
415
417
|
}, [rows.length, totalSize, composerHeight, frame.height]);
|
|
416
418
|
|
|
419
|
+
// Prepending history moves rows; keep the reader on the same row at the same height.
|
|
420
|
+
useLayoutEffect(() => {
|
|
421
|
+
const element = scrollRef.current;
|
|
422
|
+
const head = order?.[0];
|
|
423
|
+
const moved = head !== headRef.current;
|
|
424
|
+
headRef.current = head;
|
|
425
|
+
if (element === null) {
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
const anchor = anchorRef.current;
|
|
429
|
+
const index = moved && anchor !== null && !followRef.current ? rows.findIndex((row) => row.id === anchor.id) : -1;
|
|
430
|
+
if (anchor !== null && index !== -1) {
|
|
431
|
+
virtualizer.getTotalSize();
|
|
432
|
+
element.scrollTop = virtualizer.measurementsCache[index]!.start - anchor.offset;
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
const row = virtualizer.getVirtualItemForOffset(element.scrollTop);
|
|
436
|
+
anchorRef.current = row === undefined ? null : { id: String(row.key), offset: row.start - element.scrollTop };
|
|
437
|
+
});
|
|
438
|
+
|
|
417
439
|
// Close to the top, the page before goes in; again after each page that left too little above the reader.
|
|
418
440
|
useEffect(() => {
|
|
419
441
|
const element = scrollRef.current;
|
|
@@ -10,7 +10,9 @@ import type { TimelineTarget } from '../logic/timeline-target';
|
|
|
10
10
|
import { useChatHost } from '../../host';
|
|
11
11
|
import { useChatScope } from '../../scope';
|
|
12
12
|
import { useChatRow } from '../../state/chats';
|
|
13
|
+
import { copyWithChips, rowClipboardChips } from './chip-clipboard';
|
|
13
14
|
import { useFileLinkCwd, useFileLinkScopeId, useOpenFileLink } from './file-links';
|
|
15
|
+
import { useReferenceLookup } from './thread-references';
|
|
14
16
|
import { ContextMenu, copyText, DisabledReason, EDIT_SHORTCUTS, Icon, Kbd, selectAllWithin } from '@adecore/ui';
|
|
15
17
|
|
|
16
18
|
/* The menu behind a right-click in a thread, mostly for copying: a whole message, a code block or an answer's markdown. */
|
|
@@ -28,7 +30,8 @@ export function TimelineMenuPopup({
|
|
|
28
30
|
column?: boolean;
|
|
29
31
|
}) {
|
|
30
32
|
const { t } = useTranslation('agent-chat');
|
|
31
|
-
const
|
|
33
|
+
const row = target.row;
|
|
34
|
+
const message = row === null ? null : messageTextOf(row);
|
|
32
35
|
const turnId = chatId === null || target.row === null ? null : turnIdOfRow(target.row);
|
|
33
36
|
const forkBlocked = useChatRow(chatId ?? '', (row) => (turnId === null ? null : forkRefusal(row?.info ?? null, row?.structure[turnId])));
|
|
34
37
|
const markdown = target.row === null ? null : markdownOf(target.row);
|
|
@@ -36,6 +39,7 @@ export function TimelineMenuPopup({
|
|
|
36
39
|
// A thread outside a chat (a sub-agent's transcript) has no cwd of its own; the app answers for it then.
|
|
37
40
|
const cwd = useFileLinkCwd();
|
|
38
41
|
const scopeId = useFileLinkScopeId();
|
|
42
|
+
const lookup = useReferenceLookup(chatId ?? '');
|
|
39
43
|
const { fork, fileLinks } = useChatHost();
|
|
40
44
|
const openFileLink = useOpenFileLink();
|
|
41
45
|
const openInPreview = (): void => {
|
|
@@ -50,8 +54,8 @@ export function TimelineMenuPopup({
|
|
|
50
54
|
<ContextMenu.Item disabled={target.selection === ''} onClick={() => copyText(target.selection)}>
|
|
51
55
|
<Icon icon={Copy} size={14} /> {t('common.action.copy')} <Kbd shortcut={EDIT_SHORTCUTS.copy} />
|
|
52
56
|
</ContextMenu.Item>
|
|
53
|
-
{message !== null && (
|
|
54
|
-
<ContextMenu.Item onClick={() =>
|
|
57
|
+
{row !== null && message !== null && (
|
|
58
|
+
<ContextMenu.Item onClick={() => copyWithChips(rowClipboardChips(row, message, lookup))}>
|
|
55
59
|
<Icon icon={MessageSquare} size={14} /> {t('timeline.menu.copyMessage')}
|
|
56
60
|
</ContextMenu.Item>
|
|
57
61
|
)}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import type { ChatAssistantItem, ChatUserItem } from '@adecore/agent-contracts';
|
|
2
|
+
import { copyText } from '@adecore/ui';
|
|
3
|
+
import { referenceTokens } from '../chat-references';
|
|
4
|
+
import {
|
|
5
|
+
clipboardChipsOf,
|
|
6
|
+
hasClipboardChips,
|
|
7
|
+
tokenizeChips,
|
|
8
|
+
tokenizeNamedReferences,
|
|
9
|
+
writeClipboardChips,
|
|
10
|
+
type ChipSegment,
|
|
11
|
+
type ClipboardChips
|
|
12
|
+
} from '../mentions';
|
|
13
|
+
import type { TimelineRow } from '../logic/timeline';
|
|
14
|
+
import type { ReferenceLookup } from './reference-lookup';
|
|
15
|
+
|
|
16
|
+
/* The chips a copy of a whole message carries: what the person picked, or what a reply names. */
|
|
17
|
+
export function messageClipboardChips(
|
|
18
|
+
item: ChatUserItem | ChatAssistantItem,
|
|
19
|
+
text: string,
|
|
20
|
+
lookup: Pick<ReferenceLookup, 'items' | 'referenced'>
|
|
21
|
+
): ClipboardChips {
|
|
22
|
+
if (item.kind === 'assistant') {
|
|
23
|
+
return clipboardChipsOf(text, tokenizeNamedReferences(text, lookup.items, lookup.referenced));
|
|
24
|
+
}
|
|
25
|
+
const tokens = referenceTokens([...(item.chats ?? []), ...(item.references ?? [])], lookup.items);
|
|
26
|
+
return clipboardChipsOf(text, tokenizeChips(text, item.mentions ?? [], item.skills ?? [], tokens));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/* The chips a copy of a row of the thread carries; only a message has any. */
|
|
30
|
+
export function rowClipboardChips(row: TimelineRow, text: string, lookup: Pick<ReferenceLookup, 'items' | 'referenced'>): ClipboardChips {
|
|
31
|
+
if (row.kind === 'user' || row.kind === 'assistant') {
|
|
32
|
+
return messageClipboardChips(row.item, text, lookup);
|
|
33
|
+
}
|
|
34
|
+
return clipboardChipsOf(text, []);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/* What a chip the transcript drew stands for, read back from the attributes `TokenChip` writes. */
|
|
38
|
+
function segmentOf(chip: Element): ChipSegment | null {
|
|
39
|
+
const value = chip.getAttribute('data-chip-value');
|
|
40
|
+
if (value === null) {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
switch (chip.getAttribute('data-chip')) {
|
|
44
|
+
case 'mention':
|
|
45
|
+
return { kind: 'mention', path: value };
|
|
46
|
+
case 'skill':
|
|
47
|
+
return { kind: 'skill', name: value };
|
|
48
|
+
case 'reference': {
|
|
49
|
+
const name = chip.getAttribute('data-chip-name');
|
|
50
|
+
return name === null || name === '' ? null : { kind: 'reference', id: value, name };
|
|
51
|
+
}
|
|
52
|
+
default:
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/* The chips a selection holds whole, beside the text it copies; a chip cut in half is only text. */
|
|
58
|
+
export function selectionChips(selection: Selection): ClipboardChips {
|
|
59
|
+
const segments: ChipSegment[] = [];
|
|
60
|
+
for (let i = 0; i < selection.rangeCount; i++) {
|
|
61
|
+
const container = selection.getRangeAt(i).commonAncestorContainer;
|
|
62
|
+
const root = container instanceof Element ? container : container.parentElement;
|
|
63
|
+
const own = root?.closest('[data-chip]');
|
|
64
|
+
const candidates = own ? [own] : [...(root?.querySelectorAll('[data-chip]') ?? [])];
|
|
65
|
+
for (const chip of candidates) {
|
|
66
|
+
const segment = selection.containsNode(chip, false) ? segmentOf(chip) : null;
|
|
67
|
+
if (segment !== null) {
|
|
68
|
+
segments.push(segment);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return clipboardChipsOf(selection.toString(), segments);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/* A copy of a selection in the thread, which carries the chips in it as well when it holds any. */
|
|
76
|
+
export function copySelectionWithChips(event: ClipboardEvent): void {
|
|
77
|
+
const selection = document.getSelection();
|
|
78
|
+
if (event.defaultPrevented || event.clipboardData === null || selection === null || selection.isCollapsed) {
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
const chips = selectionChips(selection);
|
|
82
|
+
if (!hasClipboardChips(chips)) {
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
writeClipboardChips(event.clipboardData, chips);
|
|
86
|
+
event.preventDefault();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/*
|
|
90
|
+
* Copies a text and its chips. The asynchronous clipboard writes no type of an app's own that a paste
|
|
91
|
+
* can read, so the copy goes through a copy event, which only a page with the user's gesture gets.
|
|
92
|
+
*/
|
|
93
|
+
export function copyWithChips(chips: ClipboardChips): void {
|
|
94
|
+
if (!hasClipboardChips(chips) || typeof document.execCommand !== 'function') {
|
|
95
|
+
void copyText(chips.text);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
let written = false;
|
|
99
|
+
const write = (event: ClipboardEvent): void => {
|
|
100
|
+
if (event.clipboardData !== null) {
|
|
101
|
+
writeClipboardChips(event.clipboardData, chips);
|
|
102
|
+
event.preventDefault();
|
|
103
|
+
written = true;
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
document.addEventListener('copy', write, true);
|
|
107
|
+
try {
|
|
108
|
+
document.execCommand('copy');
|
|
109
|
+
} finally {
|
|
110
|
+
document.removeEventListener('copy', write, true);
|
|
111
|
+
}
|
|
112
|
+
if (!written) {
|
|
113
|
+
void copyText(chips.text);
|
|
114
|
+
}
|
|
115
|
+
}
|
package/src/chat/ui/chips.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/* An `@file` or
|
|
1
|
+
/* An `@file`, `$skill` or `#reference` token drawn as a pill: the tone says which it is, the shape
|
|
2
2
|
says where it is drawn. */
|
|
3
3
|
|
|
4
4
|
export const MENTION_TONE = 'bg-accent-soft text-accent shadow-[inset_0_0_0_1px_color-mix(in_srgb,var(--accent)_25%,transparent)]';
|
|
@@ -6,10 +6,29 @@ export const MENTION_TONE = 'bg-accent-soft text-accent shadow-[inset_0_0_0_1px_
|
|
|
6
6
|
/* A skill is the same token shape as a file mention, in its own hue. */
|
|
7
7
|
export const SKILL_TONE = 'bg-skill-soft text-skill shadow-[inset_0_0_0_1px_color-mix(in_srgb,var(--skill)_25%,transparent)]';
|
|
8
8
|
|
|
9
|
+
/* A `#` to a view, node or chat is neutral, so it never reads as a file or a skill. */
|
|
10
|
+
export const REFERENCE_TONE = 'bg-text/8 text-text shadow-[inset_0_0_0_1px_var(--border-strong)]';
|
|
11
|
+
|
|
12
|
+
/* A `#` whose view or node is no longer in the project. */
|
|
13
|
+
export const REFERENCE_GONE_TONE = 'bg-text/4 text-text-faint shadow-[inset_0_0_0_1px_var(--border)]';
|
|
14
|
+
|
|
9
15
|
/* The pill in the composer's editor, around the token's own characters: the editor places the caret
|
|
10
16
|
from what it drew, so padding is safe, and a chip that wraps keeps it on every fragment. */
|
|
11
17
|
export const CHIP_IN_EDITOR = 'box-decoration-clone rounded-md px-1 py-0.5';
|
|
12
18
|
|
|
19
|
+
/* A picked or named token in a message or a reply, a shade smaller than the text around it. Inline like
|
|
20
|
+
the editor's pill, so its name sits on the baseline of the sentence, and a selection copies it on the
|
|
21
|
+
same line as the words around it, where a flex box would put it on lines of its own. */
|
|
22
|
+
export const TOKEN_IN_MESSAGE = 'box-decoration-clone rounded-md px-1.5 py-0.5 text-xs font-medium';
|
|
23
|
+
|
|
24
|
+
/* The mark in place of the sigil, a 14px icon. Inline, since the base styles make an svg a block of its
|
|
25
|
+
own. Its middle goes on the middle of the name's capitals, where a mark reads as centered; the middle
|
|
26
|
+
of the x-height `Icon` aligns on sits low next to a capital. */
|
|
27
|
+
export const TOKEN_ICON = 'mr-1 inline align-[calc(0.5cap-7px)]!';
|
|
28
|
+
|
|
29
|
+
/* The same middle for the 12px mark the composer draws, in a box whose bottom edge is its baseline. */
|
|
30
|
+
export const TOKEN_ICON_IN_EDITOR = 'mr-1 inline-block align-[calc(0.5cap-6px)]';
|
|
31
|
+
|
|
13
32
|
/* The same pill in the transcript: a real inline box a shade smaller than the text around it, with
|
|
14
33
|
the icon in place of the sigil. */
|
|
15
34
|
export const CHIP_IN_MESSAGE = 'inline-flex h-5 max-w-full items-center gap-1 rounded-md px-1.5 align-[-3px] text-xs/none font-medium';
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import type { ReactNode } from 'react';
|
|
2
2
|
import { useTranslation } from 'react-i18next';
|
|
3
3
|
import clsx from 'clsx';
|
|
4
|
-
import {
|
|
4
|
+
import { SquareSlash, Zap } from 'lucide-react';
|
|
5
5
|
import { FileIcon, Icon, Menu, MiddleTruncate } from '@adecore/ui';
|
|
6
|
+
import { REFERENCE_ICONS } from '../reference-icons';
|
|
6
7
|
import type { ComposerMenus as Menus } from './use-composer-menus';
|
|
7
8
|
import { optionIdOf } from './menu-aria';
|
|
8
9
|
|
|
@@ -72,52 +73,64 @@ function SkillMenu({ menus }: { menus: Menus }) {
|
|
|
72
73
|
|
|
73
74
|
function MentionMenu({ menus, query }: { menus: Menus; query: string }) {
|
|
74
75
|
const { t } = useTranslation('agent-chat');
|
|
75
|
-
const { listId,
|
|
76
|
-
const mentionsLabelId = `${listId}-chats`;
|
|
76
|
+
const { listId, files, mentionCount } = menus;
|
|
77
77
|
const filesLabelId = `${listId}-files`;
|
|
78
|
-
const filesLabelShown = query === ''
|
|
78
|
+
const filesLabelShown = query === '';
|
|
79
79
|
return (
|
|
80
80
|
<div className="border-b border-border px-1.5 py-1.5">
|
|
81
81
|
{mentionCount > 0 && (
|
|
82
|
-
<div id={listId} role="listbox" aria-label
|
|
83
|
-
{
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
)}
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
82
|
+
<div id={listId} role="listbox" {...(filesLabelShown ? { 'aria-labelledby': filesLabelId } : { 'aria-label': t('composer.mentions.files') })}>
|
|
83
|
+
{filesLabelShown && <Menu.Label id={filesLabelId}>{t('composer.mentions.here')}</Menu.Label>}
|
|
84
|
+
{files.map((path, index) => {
|
|
85
|
+
const { name, dir } = splitPath(path);
|
|
86
|
+
return (
|
|
87
|
+
<MenuOption key={path} menus={menus} index={index} className="items-center" onPick={() => menus.chooseMention(path)}>
|
|
88
|
+
<FileIcon path={path} size={14} />
|
|
89
|
+
<span className="truncate font-mono text-text">{name}</span>
|
|
90
|
+
{dir && <MiddleTruncate text={dir} tooltip={false} className="text-text-faint" />}
|
|
91
|
+
</MenuOption>
|
|
92
|
+
);
|
|
93
|
+
})}
|
|
94
|
+
</div>
|
|
95
|
+
)}
|
|
96
|
+
{mentionCount === 0 && (
|
|
97
|
+
<div className="px-2 py-1 text-xs text-text-faint">{query ? t('composer.mentions.noMatch') : t('composer.mentions.empty')}</div>
|
|
98
|
+
)}
|
|
99
|
+
</div>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/* The views, nodes and chats a `#` offers, what stands on this canvas first, each with its kind or what tells it apart. */
|
|
104
|
+
function ReferenceMenu({ menus }: { menus: Menus }) {
|
|
105
|
+
const { t } = useTranslation('agent-chat');
|
|
106
|
+
const { listId, referenceMatches } = menus;
|
|
107
|
+
const groups = (['canvas', 'views'] as const)
|
|
108
|
+
.map((group) => ({ group, items: referenceMatches.filter((item) => item.group === group) }))
|
|
109
|
+
.filter((entry) => entry.items.length > 0);
|
|
110
|
+
return (
|
|
111
|
+
<div className="border-b border-border px-1.5 py-1.5">
|
|
112
|
+
{referenceMatches.length > 0 ? (
|
|
113
|
+
<div id={listId} role="listbox" aria-label={t('composer.menu.references')}>
|
|
114
|
+
{groups.map(({ group, items }) => (
|
|
115
|
+
<div key={group} role="group" aria-labelledby={`${listId}-${group}`}>
|
|
116
|
+
<Menu.Label id={`${listId}-${group}`}>{t(`composer.references.${group}`)}</Menu.Label>
|
|
117
|
+
{items.map((item) => {
|
|
118
|
+
const index = referenceMatches.indexOf(item);
|
|
101
119
|
return (
|
|
102
|
-
<MenuOption
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
>
|
|
109
|
-
<FileIcon path={path} size={14} />
|
|
110
|
-
<span className="truncate font-mono text-text">{name}</span>
|
|
111
|
-
{dir && <MiddleTruncate text={dir} tooltip={false} className="text-text-faint" />}
|
|
120
|
+
<MenuOption key={item.id} menus={menus} index={index} className="items-center" onPick={() => menus.chooseReference(item)}>
|
|
121
|
+
<Icon icon={REFERENCE_ICONS[item.kind]} size={14} className="shrink-0 text-text-faint" />
|
|
122
|
+
<span className="truncate text-text">{item.name}</span>
|
|
123
|
+
<span className="ml-auto shrink-0 truncate pl-2 text-text-faint">
|
|
124
|
+
{item.detail ?? t(`composer.references.kinds.${item.kind}`)}
|
|
125
|
+
</span>
|
|
112
126
|
</MenuOption>
|
|
113
127
|
);
|
|
114
128
|
})}
|
|
115
129
|
</div>
|
|
116
|
-
)}
|
|
130
|
+
))}
|
|
117
131
|
</div>
|
|
118
|
-
)
|
|
119
|
-
|
|
120
|
-
<div className="px-2 py-1 text-xs text-text-faint">{query ? t('composer.mentions.noMatch') : t('composer.mentions.empty')}</div>
|
|
132
|
+
) : (
|
|
133
|
+
<div className="px-2 py-1 text-xs text-text-faint">{t('composer.references.noMatch')}</div>
|
|
121
134
|
)}
|
|
122
135
|
</div>
|
|
123
136
|
);
|
|
@@ -134,5 +147,8 @@ export function ComposerMenu({ menus }: { menus: Menus }) {
|
|
|
134
147
|
if (menus.mentionMenuOpen && menus.mention !== null) {
|
|
135
148
|
return <MentionMenu menus={menus} query={menus.mention.query} />;
|
|
136
149
|
}
|
|
150
|
+
if (menus.referenceMenuOpen) {
|
|
151
|
+
return <ReferenceMenu menus={menus} />;
|
|
152
|
+
}
|
|
137
153
|
return null;
|
|
138
154
|
}
|
|
@@ -5,7 +5,6 @@ import { Icon, IconButton, Tooltip } from '@adecore/ui';
|
|
|
5
5
|
import { useChatScope } from '../../../scope';
|
|
6
6
|
import { formatBytes, isImageAttachment, uploadBytes } from '../../attachments';
|
|
7
7
|
import { jumpToTimelineItem } from '../../timeline-scroll';
|
|
8
|
-
import { ChatReferenceChip } from '../ChatReferenceChip';
|
|
9
8
|
import { UploadThumb } from '../UploadThumb';
|
|
10
9
|
|
|
11
10
|
const ROW = 'px-5 pt-4 @max-md/composer:px-3.5 @max-md/composer:pt-3';
|
|
@@ -43,17 +42,6 @@ export function DraftQuote({ chatId, quote, source, onRemove, onSourceGone }: Dr
|
|
|
43
42
|
);
|
|
44
43
|
}
|
|
45
44
|
|
|
46
|
-
/* Other chats the message points at, beside the text rather than in it. */
|
|
47
|
-
export function DraftChats({ chats, onRemove }: { chats: readonly string[]; onRemove(id: string): void }) {
|
|
48
|
-
return (
|
|
49
|
-
<div className={`flex flex-wrap gap-1.5 ${ROW}`}>
|
|
50
|
-
{chats.map((id) => (
|
|
51
|
-
<ChatReferenceChip key={id} chatId={id} onRemove={() => onRemove(id)} />
|
|
52
|
-
))}
|
|
53
|
-
</div>
|
|
54
|
-
);
|
|
55
|
-
}
|
|
56
|
-
|
|
57
45
|
/* The files the message carries: an image as a thumbnail, anything else by its name and size. */
|
|
58
46
|
export function DraftAttachments({ attachments, onRemove }: { attachments: readonly ChatAttachmentUpload[]; onRemove(index: number): void }) {
|
|
59
47
|
const { t } = useTranslation('agent-chat');
|