@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
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import type { EditorView } from '@codemirror/view';
|
|
2
|
+
import type { ChatDraft } from '../../drafts';
|
|
3
|
+
import type { Referable } from '../../chat-references';
|
|
4
|
+
import {
|
|
5
|
+
chipRanges,
|
|
6
|
+
clipboardChipsOf,
|
|
7
|
+
hasClipboardChips,
|
|
8
|
+
namedReferenceRanges,
|
|
9
|
+
tokenizeChips,
|
|
10
|
+
writeClipboardChips,
|
|
11
|
+
type ClipboardChips,
|
|
12
|
+
type ReferenceToken,
|
|
13
|
+
type TextRange
|
|
14
|
+
} from '../../mentions';
|
|
15
|
+
import { codeRanges } from './editor';
|
|
16
|
+
|
|
17
|
+
/* What a composer knows of the chips a paste may make: the skills there are and what `#` points at. */
|
|
18
|
+
export interface PasteChoices {
|
|
19
|
+
skills: readonly string[];
|
|
20
|
+
referables: readonly Referable[];
|
|
21
|
+
/* Ids the draft already points at, which win over another of the same name. */
|
|
22
|
+
referenced: readonly string[];
|
|
23
|
+
/* False for a CLI that does not read `@path`, which gets the text as it is. */
|
|
24
|
+
mentions: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/*
|
|
28
|
+
* The chips a plain paste spells between `from` and `to` of the text it landed in: `$name` for a skill
|
|
29
|
+
* there is, `#Name` by the reply rule. Code spans and code blocks keep what they hold.
|
|
30
|
+
*/
|
|
31
|
+
export function pastedChipsIn(text: string, from: number, to: number, code: readonly TextRange[], choices: PasteChoices): ClipboardChips {
|
|
32
|
+
const outside = [...code, { from: 0, to: from }, { from: to, to: text.length }];
|
|
33
|
+
const skills = chipRanges(text, [], [...choices.skills], outside);
|
|
34
|
+
const references = namedReferenceRanges(text, choices.referables, choices.referenced, outside);
|
|
35
|
+
return clipboardChipsOf(
|
|
36
|
+
text.slice(from, to),
|
|
37
|
+
[...skills, ...references].map((range) =>
|
|
38
|
+
range.kind === 'skill' ? { kind: 'skill', name: range.value } : { kind: 'reference', id: range.value, name: text.slice(range.from + 1, range.to) }
|
|
39
|
+
)
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/* Whether a plain paste could make a chip at all, before it goes in as text. */
|
|
44
|
+
export function mayHoldChips(text: string): boolean {
|
|
45
|
+
return /(^|[\s([{"'“‘])[$#]\p{L}/u.test(text);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/* The draft with the chips of a paste added, a chat beside the views and nodes as the picker keeps it. */
|
|
49
|
+
export function withPastedChips(draft: ChatDraft, chips: ClipboardChips, choices: PasteChoices): ChatDraft {
|
|
50
|
+
const add = (ids: readonly string[], more: readonly string[]): string[] => [
|
|
51
|
+
...ids,
|
|
52
|
+
...more.filter((id, index) => !ids.includes(id) && more.indexOf(id) === index)
|
|
53
|
+
];
|
|
54
|
+
const isChat = (token: ReferenceToken): boolean => choices.referables.some((item) => item.id === token.id && item.kind === 'chat');
|
|
55
|
+
const chats = chips.references.filter(isChat);
|
|
56
|
+
const references = chips.references.filter((token) => !isChat(token));
|
|
57
|
+
return {
|
|
58
|
+
...draft,
|
|
59
|
+
mentions: choices.mentions ? add(draft.mentions, chips.mentions) : draft.mentions,
|
|
60
|
+
skills: add(draft.skills, chips.skills),
|
|
61
|
+
chats: add(
|
|
62
|
+
draft.chats,
|
|
63
|
+
chats.map((token) => token.id)
|
|
64
|
+
),
|
|
65
|
+
references: add(
|
|
66
|
+
draft.references,
|
|
67
|
+
references.map((token) => token.id)
|
|
68
|
+
),
|
|
69
|
+
referenceNames:
|
|
70
|
+
chips.references.length === 0
|
|
71
|
+
? draft.referenceNames
|
|
72
|
+
: { ...draft.referenceNames, ...Object.fromEntries(chips.references.map((token) => [token.id, token.name])) }
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/*
|
|
77
|
+
* Puts a paste in the editor and says which chips it brings: the ones it was copied with, or the ones
|
|
78
|
+
* its plain text spells. Null when there are none, so the editor pastes it as it always does.
|
|
79
|
+
*/
|
|
80
|
+
export function pasteWithChips(view: EditorView, clip: string, copied: ClipboardChips | null, choices: PasteChoices): ClipboardChips | null {
|
|
81
|
+
if (copied !== null && hasClipboardChips(copied)) {
|
|
82
|
+
view.dispatch(view.state.replaceSelection(copied.text), { scrollIntoView: true, userEvent: 'input.paste' });
|
|
83
|
+
return copied;
|
|
84
|
+
}
|
|
85
|
+
if (!mayHoldChips(clip)) {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
const from = view.state.selection.main.from;
|
|
89
|
+
const inserted = view.state.replaceSelection(clip);
|
|
90
|
+
const state = view.state.update(inserted).state;
|
|
91
|
+
const to = state.selection.main.head;
|
|
92
|
+
const found = pastedChipsIn(state.doc.toString(), from, to, codeRanges(state), choices);
|
|
93
|
+
if (!hasClipboardChips(found)) {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
view.dispatch(inserted, { scrollIntoView: true, userEvent: 'input.paste' });
|
|
97
|
+
return found;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/* A copy or a cut of a selection that holds chips, which carries them beside its text. False leaves it to the editor. */
|
|
101
|
+
export function copyWithChipsFromEditor(
|
|
102
|
+
event: ClipboardEvent,
|
|
103
|
+
view: EditorView,
|
|
104
|
+
draft: Pick<ChatDraft, 'mentions' | 'skills'>,
|
|
105
|
+
references: readonly ReferenceToken[]
|
|
106
|
+
): boolean {
|
|
107
|
+
const { from, to } = view.state.selection.main;
|
|
108
|
+
if (from === to || event.clipboardData === null) {
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
const text = view.state.sliceDoc(from, to);
|
|
112
|
+
const chips = clipboardChipsOf(text, tokenizeChips(text, draft.mentions, draft.skills, references));
|
|
113
|
+
if (!hasClipboardChips(chips)) {
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
writeClipboardChips(event.clipboardData, chips);
|
|
117
|
+
event.preventDefault();
|
|
118
|
+
if (event.type === 'cut') {
|
|
119
|
+
view.dispatch({ changes: { from, to }, scrollIntoView: true, userEvent: 'delete.cut' });
|
|
120
|
+
}
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
@@ -1,13 +1,20 @@
|
|
|
1
|
+
import { createElement } from 'react';
|
|
2
|
+
import { createRoot, type Root } from 'react-dom/client';
|
|
1
3
|
import { syntaxTree } from '@codemirror/language';
|
|
2
|
-
import { Facet,
|
|
3
|
-
import { Decoration, EditorView, type DecorationSet } from '@codemirror/view';
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
4
|
+
import { Facet, StateField, type EditorState, type Extension, type Range } from '@codemirror/state';
|
|
5
|
+
import { Decoration, EditorView, WidgetType, type DecorationSet } from '@codemirror/view';
|
|
6
|
+
import { Icon } from '@adecore/ui';
|
|
7
|
+
import type { ReferableKind } from '../../chat-references';
|
|
8
|
+
import { chipRanges, type ReferenceToken } from '../../mentions';
|
|
9
|
+
import { CHIP_IN_EDITOR, MENTION_TONE, REFERENCE_TONE, SKILL_TONE, TOKEN_ICON_IN_EDITOR } from '../chips';
|
|
10
|
+
import { REFERENCE_ICONS } from '../reference-icons';
|
|
6
11
|
import { codeRanges } from './editor';
|
|
7
12
|
|
|
8
13
|
export interface ChipChoices {
|
|
9
14
|
mentions: string[];
|
|
10
15
|
skills: string[];
|
|
16
|
+
/* Null for a kind the host no longer names, which draws the chip without its mark. */
|
|
17
|
+
references?: ReadonlyArray<ReferenceToken & { kind: ReferableKind | null }>;
|
|
11
18
|
}
|
|
12
19
|
|
|
13
20
|
const NO_CHOICES: ChipChoices = { mentions: [], skills: [] };
|
|
@@ -16,15 +23,58 @@ const choices = Facet.define<ChipChoices, ChipChoices>({ combine: (values) => va
|
|
|
16
23
|
|
|
17
24
|
const mentionMark = Decoration.mark({ class: `${MENTION_TONE} ${CHIP_IN_EDITOR}` });
|
|
18
25
|
const skillMark = Decoration.mark({ class: `${SKILL_TONE} ${CHIP_IN_EDITOR}` });
|
|
26
|
+
// Inclusive at the start, so the pill opens before the mark of the kind that replaces its `#` and holds it.
|
|
27
|
+
const referenceMark = Decoration.mark({ class: `${REFERENCE_TONE} ${CHIP_IN_EDITOR}`, inclusiveStart: true });
|
|
28
|
+
|
|
29
|
+
const roots = new WeakMap<HTMLElement, Root>();
|
|
30
|
+
|
|
31
|
+
/* The kind's mark in place of the `#`, the way a sent message draws it. */
|
|
32
|
+
class ReferenceIcon extends WidgetType {
|
|
33
|
+
readonly kind: ReferableKind;
|
|
34
|
+
|
|
35
|
+
constructor(kind: ReferableKind) {
|
|
36
|
+
super();
|
|
37
|
+
this.kind = kind;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
override eq(other: ReferenceIcon): boolean {
|
|
41
|
+
return other.kind === this.kind;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
toDOM(): HTMLElement {
|
|
45
|
+
const holder = document.createElement('span');
|
|
46
|
+
holder.className = TOKEN_ICON_IN_EDITOR;
|
|
47
|
+
const root = createRoot(holder);
|
|
48
|
+
root.render(createElement(Icon, { icon: REFERENCE_ICONS[this.kind], size: 12 }));
|
|
49
|
+
roots.set(holder, root);
|
|
50
|
+
return holder;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
override destroy(dom: HTMLElement): void {
|
|
54
|
+
const root = roots.get(dom);
|
|
55
|
+
roots.delete(dom);
|
|
56
|
+
// The editor drops a widget inside React's own commit, where a root may not unmount at once.
|
|
57
|
+
queueMicrotask(() => root?.unmount());
|
|
58
|
+
}
|
|
59
|
+
}
|
|
19
60
|
|
|
20
61
|
function chipMarks(state: EditorState): DecorationSet {
|
|
21
|
-
const { mentions, skills } = state.facet(choices);
|
|
22
|
-
const
|
|
62
|
+
const { mentions, skills, references = [] } = state.facet(choices);
|
|
63
|
+
const kinds = new Map(references.map((token) => [token.id, token.kind]));
|
|
64
|
+
const marks: Range<Decoration>[] = [];
|
|
23
65
|
// `@a.ts` in backticks is code, not a mention.
|
|
24
|
-
for (const range of chipRanges(state.doc.toString(), mentions, skills, codeRanges(state))) {
|
|
25
|
-
|
|
66
|
+
for (const range of chipRanges(state.doc.toString(), mentions, skills, codeRanges(state), references)) {
|
|
67
|
+
if (range.kind !== 'reference') {
|
|
68
|
+
marks.push((range.kind === 'skill' ? skillMark : mentionMark).range(range.from, range.to));
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
marks.push(referenceMark.range(range.from, range.to));
|
|
72
|
+
const kind = kinds.get(range.value);
|
|
73
|
+
if (kind !== undefined && kind !== null) {
|
|
74
|
+
marks.push(Decoration.replace({ widget: new ReferenceIcon(kind) }).range(range.from, range.from + 1));
|
|
75
|
+
}
|
|
26
76
|
}
|
|
27
|
-
return
|
|
77
|
+
return Decoration.set(marks, true);
|
|
28
78
|
}
|
|
29
79
|
|
|
30
80
|
const chipField = StateField.define<DecorationSet>({
|
|
@@ -37,7 +87,7 @@ const chipField = StateField.define<DecorationSet>({
|
|
|
37
87
|
provide: (field) => EditorView.decorations.from(field)
|
|
38
88
|
});
|
|
39
89
|
|
|
40
|
-
/* Draws the chosen `@path` and
|
|
90
|
+
/* Draws the chosen `@path`, `$name` and `#name` tokens as chips; a new `ChipChoices` object is what redraws them. */
|
|
41
91
|
export function chipDecorations(value: ChipChoices): Extension {
|
|
42
92
|
return [choices.of(value), chipField];
|
|
43
93
|
}
|
|
@@ -84,6 +84,15 @@ function pickInMenu(e: KeyboardEvent, { menus, setText }: KeyHandlerInput): bool
|
|
|
84
84
|
return true;
|
|
85
85
|
}
|
|
86
86
|
}
|
|
87
|
+
if (menus.referenceMenuOpen && menus.referenceMatches.length > 0) {
|
|
88
|
+
if (menus.moveInMenu(e.key, menus.referenceMatches.length)) {
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
if (e.key === 'Tab' || e.key === 'Enter') {
|
|
92
|
+
menus.chooseReferenceAt(activeIndex);
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
87
96
|
return false;
|
|
88
97
|
}
|
|
89
98
|
|
|
@@ -12,7 +12,6 @@ export interface ComposerDraft {
|
|
|
12
12
|
setText(next: string, mentions?: string[], skills?: string[]): void;
|
|
13
13
|
clear(): void;
|
|
14
14
|
removeQuote(): void;
|
|
15
|
-
removeChat(id: string): void;
|
|
16
15
|
removeAttachment(index: number): void;
|
|
17
16
|
}
|
|
18
17
|
|
|
@@ -58,14 +57,9 @@ export function useComposerDraft(chatId: string, inputRef: RefObject<ComposerInp
|
|
|
58
57
|
inputRef.current?.focus();
|
|
59
58
|
};
|
|
60
59
|
|
|
61
|
-
const removeChat = (id: string): void => {
|
|
62
|
-
setDraft((current) => ({ ...current, chats: current.chats.filter((entry) => entry !== id) }));
|
|
63
|
-
inputRef.current?.focus();
|
|
64
|
-
};
|
|
65
|
-
|
|
66
60
|
const removeAttachment = (index: number): void => {
|
|
67
61
|
setDraft((current) => ({ ...current, attachments: current.attachments.filter((_, i) => i !== index) }));
|
|
68
62
|
};
|
|
69
63
|
|
|
70
|
-
return { draft, setDraft, latest, setText, clear, removeQuote,
|
|
64
|
+
return { draft, setDraft, latest, setText, clear, removeQuote, removeAttachment };
|
|
71
65
|
}
|
|
@@ -5,16 +5,19 @@ import type { EditorState } from '@codemirror/state';
|
|
|
5
5
|
import type { ChatInfo, ChatSkill, ProviderCapabilities } from '@adecore/agent-contracts';
|
|
6
6
|
import { useChatHost } from '../../../host';
|
|
7
7
|
import { useChatScope } from '../../../scope';
|
|
8
|
-
import {
|
|
8
|
+
import { referenceSuggestions, referenceTokens, type Referable } from '../../chat-references';
|
|
9
9
|
import { usableSlashCommands } from '../../guards';
|
|
10
10
|
import {
|
|
11
|
-
|
|
11
|
+
chipRanges,
|
|
12
12
|
findMentionQuery,
|
|
13
|
+
findReferenceQuery,
|
|
13
14
|
findSkillQuery,
|
|
14
15
|
insertMention,
|
|
16
|
+
insertReference,
|
|
15
17
|
insertSkill,
|
|
16
18
|
mentionPick,
|
|
17
19
|
NO_MENTION_SEARCH,
|
|
20
|
+
queryOutsideChips,
|
|
18
21
|
type MentionQuery,
|
|
19
22
|
type MentionSearch,
|
|
20
23
|
type TextRange
|
|
@@ -23,13 +26,12 @@ import type { ComposerInputHandle } from '../ComposerInput';
|
|
|
23
26
|
import type { ComposerDraft } from './use-composer-draft';
|
|
24
27
|
import { inCode } from './keys';
|
|
25
28
|
import { clampMenuIndex, composerMenuAria } from './menu-aria';
|
|
26
|
-
import {
|
|
29
|
+
import { useHostReferables } from '../../../host-hooks';
|
|
27
30
|
|
|
28
31
|
const SEARCH_DEBOUNCE_MS = 80;
|
|
29
32
|
// What fits above the composer without turning the picker into a file tree.
|
|
30
33
|
const MENTION_RESULTS = 8;
|
|
31
|
-
|
|
32
|
-
const CHAT_RESULTS = 4;
|
|
34
|
+
const REFERENCE_RESULTS = 8;
|
|
33
35
|
|
|
34
36
|
// Commands the composer handles itself; the CLI's own ones are sent through as text.
|
|
35
37
|
export const LOCAL_COMMANDS = ['model', 'compact', 'clear'];
|
|
@@ -55,17 +57,19 @@ function sameQuery(left: MentionQuery | null, right: MentionQuery | null): boole
|
|
|
55
57
|
}
|
|
56
58
|
|
|
57
59
|
/*
|
|
58
|
-
* The pickers over the box: `/` for a command, `$` for a skill
|
|
59
|
-
* One is open at a time, and the arrow keys and the editor's ARIA attributes
|
|
60
|
+
* The pickers over the box: `/` for a command, `$` for a skill, `@` for a file and `#` for a view,
|
|
61
|
+
* a node or another chat. One is open at a time, and the arrow keys and the editor's ARIA attributes
|
|
62
|
+
* follow that one.
|
|
60
63
|
*/
|
|
61
64
|
export function useComposerMenus({ chatId, info, capabilities, providerName, draft: { draft, setDraft, setText }, inputRef }: MenusInput) {
|
|
62
65
|
const { t } = useTranslation('agent-chat');
|
|
63
66
|
const scope = useChatScope();
|
|
64
67
|
const host = useChatHost();
|
|
65
68
|
const { searchFiles } = host;
|
|
66
|
-
const
|
|
69
|
+
const referables = useHostReferables(scope.id, chatId);
|
|
67
70
|
const [menuIndex, setMenuIndex] = useState(0);
|
|
68
71
|
const [mention, setMention] = useState<MentionQuery | null>(null);
|
|
72
|
+
const [referenceQuery, setReferenceQuery] = useState<MentionQuery | null>(null);
|
|
69
73
|
const [skillQuery, setSkillQuery] = useState<MentionQuery | null>(null);
|
|
70
74
|
const [skills, setSkills] = useState<ChatSkill[]>([]);
|
|
71
75
|
const [searched, setSearched] = useState<MentionSearch>(NO_MENTION_SEARCH);
|
|
@@ -157,26 +161,39 @@ export function useComposerMenus({ chatId, info, capabilities, providerName, dra
|
|
|
157
161
|
const mentionMenuOpen = !commandMenuOpen && !skillMenuOpen && mention !== null;
|
|
158
162
|
// Results belong to the query that asked for them; a closed picker shows none while the next answer is on its way.
|
|
159
163
|
const files = mention === null ? [] : searched.files;
|
|
160
|
-
const
|
|
161
|
-
|
|
164
|
+
const mentionCount = files.length;
|
|
165
|
+
// A host with nothing to point at keeps `#fff` out of the way rather than saying nothing matches.
|
|
166
|
+
const offersReferences = referables.items.some((item) => !referables.chatsOnly || item.kind === 'chat');
|
|
167
|
+
const referenceMenuOpen = !commandMenuOpen && !skillMenuOpen && !mentionMenuOpen && referenceQuery !== null && offersReferences;
|
|
168
|
+
const referenceMatches = referenceMenuOpen
|
|
169
|
+
? referenceSuggestions(referables, referenceQuery.query, [chatId, ...draft.chats, ...draft.references], REFERENCE_RESULTS)
|
|
170
|
+
: [];
|
|
171
|
+
// What the editor draws as chips and the send checks are still in the text, under the name each was picked with.
|
|
172
|
+
const referenceChips = referenceTokens([...draft.chats, ...draft.references], referables.items, draft.referenceNames).map((token) => ({
|
|
173
|
+
...token,
|
|
174
|
+
kind: referables.items.find((item) => item.id === token.id)?.kind ?? null
|
|
175
|
+
}));
|
|
162
176
|
// One menu is open at a time, so they share an id.
|
|
163
177
|
const listId = useId();
|
|
164
|
-
const menuOpen = commandMenuOpen || skillMenuOpen || mentionMenuOpen;
|
|
165
|
-
const menuCount = commandMenuOpen ? commands.length : skillMenuOpen ? skillMatches.length : mentionCount;
|
|
178
|
+
const menuOpen = commandMenuOpen || skillMenuOpen || mentionMenuOpen || referenceMenuOpen;
|
|
179
|
+
const menuCount = commandMenuOpen ? commands.length : skillMenuOpen ? skillMatches.length : mentionMenuOpen ? mentionCount : referenceMatches.length;
|
|
166
180
|
// The list can shrink under a stale index; every reader takes the one that still points at an option.
|
|
167
181
|
const activeIndex = clampMenuIndex(menuIndex, menuCount);
|
|
168
182
|
const inputAria = composerMenuAria({ label: t('composer.inputLabel'), listId, open: menuOpen, activeIndex, count: menuCount });
|
|
169
183
|
|
|
170
|
-
/* Which picker the caret opens: `@` for a file, `$` for a skill, or
|
|
184
|
+
/* Which picker the caret opens: `@` for a file, `$` for a skill, `#` for a reference, or none. In code a sigil is just text. */
|
|
171
185
|
const trackTriggers = (value: string, selection: TextRange, state: EditorState): void => {
|
|
172
186
|
const empty = selection.from === selection.to;
|
|
173
187
|
const tree = empty ? (ensureSyntaxTree(state, selection.from, 50) ?? syntaxTree(state)) : null;
|
|
174
188
|
const caret = tree !== null && !inCode(tree, value, selection.from) ? selection.from : null;
|
|
175
|
-
const
|
|
189
|
+
const chips = caret === null ? [] : chipRanges(value, draft.mentions, draft.skills, [], referenceChips);
|
|
190
|
+
const skill = caret === null ? null : queryOutsideChips(findSkillQuery(value, caret), chips);
|
|
176
191
|
setSkillQuery((current) => (sameQuery(current, skill) ? current : skill));
|
|
177
192
|
// A CLI that does not expand `@path` gets the text as it is, so the picker stays out of the way.
|
|
178
|
-
const next = caret === null || capabilities?.mentions === false ? null : findMentionQuery(value, caret);
|
|
193
|
+
const next = caret === null || capabilities?.mentions === false ? null : queryOutsideChips(findMentionQuery(value, caret), chips);
|
|
179
194
|
setMention((current) => (sameQuery(current, next) ? current : next));
|
|
195
|
+
const reference = caret === null ? null : queryOutsideChips(findReferenceQuery(value, caret), chips);
|
|
196
|
+
setReferenceQuery((current) => (sameQuery(current, reference) ? current : reference));
|
|
180
197
|
};
|
|
181
198
|
|
|
182
199
|
// The inserted text reaches the editor with the render it causes, so the caret waits a frame for it.
|
|
@@ -187,6 +204,7 @@ export function useComposerMenus({ chatId, info, capabilities, providerName, dra
|
|
|
187
204
|
const close = (): void => {
|
|
188
205
|
setMention(null);
|
|
189
206
|
setSkillQuery(null);
|
|
207
|
+
setReferenceQuery(null);
|
|
190
208
|
};
|
|
191
209
|
|
|
192
210
|
const chooseMention = (path: string): void => {
|
|
@@ -199,23 +217,39 @@ export function useComposerMenus({ chatId, info, capabilities, providerName, dra
|
|
|
199
217
|
moveCaret(result.caret);
|
|
200
218
|
};
|
|
201
219
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
if (
|
|
220
|
+
const chooseMentionAt = (index: number): void => {
|
|
221
|
+
const path = mention === null ? null : mentionPick(index, searched, mention.query);
|
|
222
|
+
if (path !== null) {
|
|
223
|
+
chooseMention(path);
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
/*
|
|
228
|
+
* A picked view, node or chat becomes a `#name` chip in the text. The draft keeps the name it was
|
|
229
|
+
* spelled with, since a rename would otherwise unmake the chip. A chat goes apart, as `chats`.
|
|
230
|
+
*/
|
|
231
|
+
const chooseReference = (item: Referable): void => {
|
|
232
|
+
if (!referenceQuery) {
|
|
205
233
|
return;
|
|
206
234
|
}
|
|
207
|
-
const result =
|
|
208
|
-
|
|
209
|
-
|
|
235
|
+
const result = insertReference(text, referenceQuery, item.name);
|
|
236
|
+
const chat = item.kind === 'chat';
|
|
237
|
+
const add = (ids: string[]): string[] => (ids.includes(item.id) ? ids : [...ids, item.id]);
|
|
238
|
+
setDraft((current) => ({
|
|
239
|
+
...current,
|
|
240
|
+
text: result.text,
|
|
241
|
+
chats: chat ? add(current.chats) : current.chats,
|
|
242
|
+
references: chat ? current.references : add(current.references),
|
|
243
|
+
referenceNames: { ...current.referenceNames, [item.id]: item.name }
|
|
244
|
+
}));
|
|
245
|
+
setReferenceQuery(null);
|
|
210
246
|
moveCaret(result.caret);
|
|
211
247
|
};
|
|
212
248
|
|
|
213
|
-
const
|
|
214
|
-
const
|
|
215
|
-
if (
|
|
216
|
-
|
|
217
|
-
} else if (pick?.kind === 'file') {
|
|
218
|
-
chooseMention(pick.path);
|
|
249
|
+
const chooseReferenceAt = (index: number): void => {
|
|
250
|
+
const item = referenceMatches[index] ?? referenceMatches[0];
|
|
251
|
+
if (item !== undefined) {
|
|
252
|
+
chooseReference(item);
|
|
219
253
|
}
|
|
220
254
|
};
|
|
221
255
|
|
|
@@ -259,6 +293,7 @@ export function useComposerMenus({ chatId, info, capabilities, providerName, dra
|
|
|
259
293
|
activeIndex,
|
|
260
294
|
inputAria,
|
|
261
295
|
hasSkills: skills.length > 0,
|
|
296
|
+
skillNames: skills.map((skill) => skill.name),
|
|
262
297
|
commandQuery,
|
|
263
298
|
commands,
|
|
264
299
|
commandMenuOpen,
|
|
@@ -267,14 +302,18 @@ export function useComposerMenus({ chatId, info, capabilities, providerName, dra
|
|
|
267
302
|
mention,
|
|
268
303
|
mentionMenuOpen,
|
|
269
304
|
mentionCount,
|
|
270
|
-
chatMatches,
|
|
271
305
|
files,
|
|
306
|
+
referenceMenuOpen,
|
|
307
|
+
referenceMatches,
|
|
308
|
+
referables,
|
|
309
|
+
referenceChips,
|
|
272
310
|
setMenuIndex,
|
|
273
311
|
trackTriggers,
|
|
274
312
|
close,
|
|
275
313
|
chooseMention,
|
|
276
|
-
chooseChat,
|
|
277
314
|
chooseMentionAt,
|
|
315
|
+
chooseReference,
|
|
316
|
+
chooseReferenceAt,
|
|
278
317
|
chooseSkill,
|
|
279
318
|
pickCommand,
|
|
280
319
|
moveInMenu
|
|
@@ -8,7 +8,7 @@ import { registerComposerSender, type ComposerSend } from '../../composer-send';
|
|
|
8
8
|
import { isEmptyDraft } from '../../drafts';
|
|
9
9
|
import { CONTINUE_PROMPT } from '../../logic/continue-after-stop';
|
|
10
10
|
import type { PromptGuard } from '../../guards';
|
|
11
|
-
import { presentMentions, presentSkills } from '../../mentions';
|
|
11
|
+
import { presentMentions, presentReferences, presentSkills } from '../../mentions';
|
|
12
12
|
import { withQuote } from '../../quote';
|
|
13
13
|
import { restorableChats, stashDraft, useStash, type StashedPrompt } from '../../stash';
|
|
14
14
|
import { scrollTimelineToEnd } from '../../timeline-scroll';
|
|
@@ -19,7 +19,6 @@ import type { ComposerDraft } from './use-composer-draft';
|
|
|
19
19
|
import type { ComposerMenus } from './use-composer-menus';
|
|
20
20
|
import type { PromptHistory } from './use-prompt-history';
|
|
21
21
|
import type { ComposerPromptHold } from './use-prompt-hold';
|
|
22
|
-
import { useHostReferableChats } from '../../../host-hooks';
|
|
23
22
|
|
|
24
23
|
interface SubmitInput {
|
|
25
24
|
chatId: string;
|
|
@@ -59,7 +58,6 @@ export function useComposerSubmit({
|
|
|
59
58
|
const host = useChatHost();
|
|
60
59
|
const scope = useChatScope();
|
|
61
60
|
const { draft, setDraft } = box;
|
|
62
|
-
const referable = useHostReferableChats();
|
|
63
61
|
|
|
64
62
|
/* Opens the host's new chat once the host took the message; a host that answers nothing took it. */
|
|
65
63
|
const afterSend = (sent: void | Promise<boolean>, andNew: boolean): void => {
|
|
@@ -111,13 +109,17 @@ export function useComposerSubmit({
|
|
|
111
109
|
box.clear();
|
|
112
110
|
return;
|
|
113
111
|
}
|
|
112
|
+
const chats = presentReferences(trimmed, draft.chats, menus.referenceChips);
|
|
113
|
+
// A machine without `viewReferences` drops the field, so only the chats it does know reach it.
|
|
114
|
+
const references = menus.referables.chatsOnly ? [] : presentReferences(trimmed, draft.references, menus.referenceChips);
|
|
114
115
|
const sent = onSend(withQuote(quote, trimmed), {
|
|
115
116
|
delivery,
|
|
116
117
|
mentions: presentMentions(trimmed, draft.mentions),
|
|
117
118
|
skills: presentSkills(trimmed, draft.skills),
|
|
118
|
-
chats
|
|
119
|
+
chats,
|
|
119
120
|
quoteSource: quote !== '' && quoteSource !== null ? { itemId: quoteSource } : undefined,
|
|
120
|
-
attachments: draft.attachments
|
|
121
|
+
attachments: draft.attachments,
|
|
122
|
+
references: references.length > 0 ? references : undefined
|
|
121
123
|
});
|
|
122
124
|
afterSend(sent, how === 'and-new');
|
|
123
125
|
box.clear();
|
|
@@ -140,13 +142,18 @@ export function useComposerSubmit({
|
|
|
140
142
|
const restoreStashed = (prompt: StashedPrompt): void => {
|
|
141
143
|
const stashedQuote = prompt.quote;
|
|
142
144
|
const here = stashedQuote !== undefined && stashedQuote.chatId === chatId;
|
|
143
|
-
const { chats, dropped } = restorableChats(
|
|
145
|
+
const { chats, dropped } = restorableChats(
|
|
146
|
+
prompt.chats,
|
|
147
|
+
new Set(menus.referables.items.filter((item) => item.kind === 'chat').map((item) => item.id)),
|
|
148
|
+
chatId
|
|
149
|
+
);
|
|
144
150
|
setDraft((current) => ({
|
|
145
151
|
...current,
|
|
146
152
|
text: stashedQuote === undefined || here ? prompt.text : withQuote(stashedQuote.text, prompt.text),
|
|
147
153
|
mentions: [...prompt.mentions],
|
|
148
154
|
skills: [...prompt.skills],
|
|
149
155
|
chats,
|
|
156
|
+
references: [...prompt.references],
|
|
150
157
|
...(here ? { quote: stashedQuote.text, quoteSource: stashedQuote.itemId } : {})
|
|
151
158
|
}));
|
|
152
159
|
if (dropped > 0) {
|
|
@@ -5,8 +5,10 @@ import type { ProviderCapabilities } from '@adecore/agent-contracts';
|
|
|
5
5
|
import { useChatHost } from '../../../host';
|
|
6
6
|
import { checkAttachmentLimits, filesOf, readAttachments, uploadBytes } from '../../attachments';
|
|
7
7
|
import { pasteBecomesAttachment, pastedTextName } from '../../guards';
|
|
8
|
-
import {
|
|
8
|
+
import type { Referable } from '../../chat-references';
|
|
9
|
+
import { carriesMentions, droppedMentions, pastedMentions, readClipboardChips, type ReferenceToken } from '../../mentions';
|
|
9
10
|
import type { ComposerInputHandle } from '../ComposerInput';
|
|
11
|
+
import { copyWithChipsFromEditor, pasteWithChips, withPastedChips, type PasteChoices } from './chip-paste';
|
|
10
12
|
import type { ComposerDraft } from './use-composer-draft';
|
|
11
13
|
|
|
12
14
|
/* The card turns dropped files and paths into attachments and chips; CodeMirror would also paste their text at the drop point. */
|
|
@@ -21,10 +23,28 @@ interface DraftFilesInput {
|
|
|
21
23
|
draft: ComposerDraft;
|
|
22
24
|
inputRef: RefObject<ComposerInputHandle | null>;
|
|
23
25
|
setNotice(notice: string): void;
|
|
26
|
+
/* The skills there are and what `#` points at, which a paste makes chips of. */
|
|
27
|
+
skills: readonly string[];
|
|
28
|
+
referables: readonly Referable[];
|
|
29
|
+
/* The draft's `#` picks under the names the text spells them by. */
|
|
30
|
+
referenceChips: readonly ReferenceToken[];
|
|
24
31
|
}
|
|
25
32
|
|
|
26
|
-
/*
|
|
27
|
-
|
|
33
|
+
/*
|
|
34
|
+
* What reaches the draft besides typing: files as attachments, dragged paths as mentions, a long paste
|
|
35
|
+
* as a file of its own, and the chips a copy carried or a pasted text spells.
|
|
36
|
+
*/
|
|
37
|
+
export function useDraftFiles({
|
|
38
|
+
capabilities,
|
|
39
|
+
providerName,
|
|
40
|
+
writable,
|
|
41
|
+
draft: { draft, setDraft, setText },
|
|
42
|
+
inputRef,
|
|
43
|
+
setNotice,
|
|
44
|
+
skills,
|
|
45
|
+
referables,
|
|
46
|
+
referenceChips
|
|
47
|
+
}: DraftFilesInput) {
|
|
28
48
|
const { t } = useTranslation('agent-chat');
|
|
29
49
|
const { isApplePlatform } = useChatHost();
|
|
30
50
|
const [dragging, setDragging] = useState(false);
|
|
@@ -94,7 +114,19 @@ export function useDraftFiles({ capabilities, providerName, writable, draft: { d
|
|
|
94
114
|
}
|
|
95
115
|
// Without attachments there is nowhere else for the text to go, so it pastes as it always did.
|
|
96
116
|
if (inline || capabilities?.attachments === false || !pasteBecomesAttachment(clip)) {
|
|
97
|
-
|
|
117
|
+
const choices: PasteChoices = {
|
|
118
|
+
skills,
|
|
119
|
+
referables,
|
|
120
|
+
referenced: [...draft.chats, ...draft.references],
|
|
121
|
+
mentions: capabilities?.mentions !== false
|
|
122
|
+
};
|
|
123
|
+
const chips = pasteWithChips(view, clip, readClipboardChips(data), choices);
|
|
124
|
+
if (chips === null) {
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
// After the editor's own change, which reads the draft as it was rendered.
|
|
128
|
+
setDraft((current) => withPastedChips(current, chips, choices));
|
|
129
|
+
return true;
|
|
98
130
|
}
|
|
99
131
|
// The paste still replaces what was selected; it just puts nothing in its place.
|
|
100
132
|
const { from, to } = view.state.selection.main;
|
|
@@ -106,6 +138,8 @@ export function useDraftFiles({ capabilities, providerName, writable, draft: { d
|
|
|
106
138
|
return true;
|
|
107
139
|
};
|
|
108
140
|
|
|
141
|
+
const onCopy = (e: ClipboardEvent, view: EditorView): boolean => copyWithChipsFromEditor(e, view, draft, referenceChips);
|
|
142
|
+
|
|
109
143
|
/* The whole card takes a dropped file, so a layout around it that takes drops of its own leaves it alone. */
|
|
110
144
|
const dropTarget = {
|
|
111
145
|
'data-takes-drop': 'all',
|
|
@@ -131,5 +165,5 @@ export function useDraftFiles({ capabilities, providerName, writable, draft: { d
|
|
|
131
165
|
}
|
|
132
166
|
};
|
|
133
167
|
|
|
134
|
-
return { dragging, dropTarget, addFiles, noteKey, onPaste };
|
|
168
|
+
return { dragging, dropTarget, addFiles, noteKey, onPaste, onCopy };
|
|
135
169
|
}
|
|
@@ -58,7 +58,8 @@ export function useTakeBack({ chatId, draft: { latest, setDraft }, setNotice, on
|
|
|
58
58
|
mentions: message.mentions ?? [],
|
|
59
59
|
skills: message.skills ?? [],
|
|
60
60
|
chats: message.chats ?? [],
|
|
61
|
-
attachments: uploads
|
|
61
|
+
attachments: uploads,
|
|
62
|
+
references: message.references ?? []
|
|
62
63
|
};
|
|
63
64
|
// A file that would not fit is refused here, while the message still holds it on the machine.
|
|
64
65
|
const wouldReject = takeBackIntoDraft(latest.current, taken).rejected[0];
|
package/src/chat/ui/index.ts
CHANGED
|
@@ -3,7 +3,6 @@ export { AccountPill } from './AccountPill';
|
|
|
3
3
|
export { AnsiOutput } from './AnsiOutput';
|
|
4
4
|
export { BookmarkSubmenu } from './BookmarkSubmenu';
|
|
5
5
|
export { ChatActivity, StatusIcon } from './ChatActivity';
|
|
6
|
-
export { ChatReferenceChip } from './ChatReferenceChip';
|
|
7
6
|
export { CodeBlock } from './CodeBlock';
|
|
8
7
|
export { Composer } from './Composer';
|
|
9
8
|
export { type ComposerInputHandle, ComposerInput, type InputSelection } from './ComposerInput';
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { Database, FileText, Frame, Globe, MessageSquare, PenTool, Smartphone, StickyNote, Terminal, Type, Workflow, type LucideIcon } from 'lucide-react';
|
|
2
|
+
import type { ReferableKind } from '../chat-references';
|
|
3
|
+
|
|
4
|
+
/* The mark of what a `#` points at, in the picker and on its chip. */
|
|
5
|
+
export const REFERENCE_ICONS: Record<ReferableKind, LucideIcon> = {
|
|
6
|
+
canvas: Frame,
|
|
7
|
+
chat: MessageSquare,
|
|
8
|
+
terminal: Terminal,
|
|
9
|
+
note: StickyNote,
|
|
10
|
+
text: Type,
|
|
11
|
+
drawing: PenTool,
|
|
12
|
+
diagram: Workflow,
|
|
13
|
+
browser: Globe,
|
|
14
|
+
device: Smartphone,
|
|
15
|
+
file: FileText,
|
|
16
|
+
database: Database
|
|
17
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { createContext } from 'react';
|
|
2
|
+
import type { Referable } from '../chat-references';
|
|
3
|
+
|
|
4
|
+
export interface ReferenceLookup {
|
|
5
|
+
items: readonly Referable[];
|
|
6
|
+
/* Null where the host opens nothing. */
|
|
7
|
+
open: ((id: string) => void) | null;
|
|
8
|
+
/* What a person pointed at with `#` in this thread, which a reply wins over another of the same name. */
|
|
9
|
+
referenced?: readonly string[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/* What the views and nodes of a message stand for now, handed down by the row that draws it. */
|
|
13
|
+
export const ReferenceLookupContext = createContext<ReferenceLookup>({ items: [], open: null });
|