@adecore/agents-react 0.37.2 → 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/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/ReferenceChip.d.ts +15 -0
- package/dist/chat/ui/ReferenceChip.js +255 -0
- 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/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/ReferenceChip.tsx +74 -0
- 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
|
@@ -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 });
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { chipText, tokenizeChips, type ChipSegment } from '../mentions';
|
|
1
|
+
import { chipText, tokenizeChips, tokenizeNamedReferences, type ChipSegment, type ReferenceToken } from '../mentions';
|
|
2
2
|
|
|
3
3
|
/* Just the part of hast this plugin touches; the package's own types are not a dependency here. */
|
|
4
4
|
interface HastText {
|
|
@@ -22,41 +22,66 @@ interface HastParent {
|
|
|
22
22
|
export interface ChipOptions {
|
|
23
23
|
mentions?: string[];
|
|
24
24
|
skills?: string[];
|
|
25
|
+
references?: readonly ReferenceToken[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface NamedReferenceOptions {
|
|
29
|
+
items: readonly ReferenceToken[];
|
|
30
|
+
/* Ids a person pointed at with `#` in the thread, which win over another of the same name. */
|
|
31
|
+
referenced?: readonly string[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function valueOf(segment: Exclude<ChipSegment, { kind: 'text' }>): string {
|
|
35
|
+
if (segment.kind === 'mention') {
|
|
36
|
+
return segment.path;
|
|
37
|
+
}
|
|
38
|
+
return segment.kind === 'skill' ? segment.name : segment.id;
|
|
25
39
|
}
|
|
26
40
|
|
|
27
41
|
// Inline code overrules a chip: `@a.ts` between backticks is code, not a mention.
|
|
28
42
|
const SKIPPED = new Set(['code', 'pre']);
|
|
29
43
|
|
|
30
|
-
|
|
44
|
+
// A link already leads somewhere of its own.
|
|
45
|
+
const SKIPPED_IN_REPLIES = new Set(['code', 'pre', 'a']);
|
|
46
|
+
|
|
47
|
+
/* A chip as an element: the renderer reads `data-chip` and `data-value` (a reference's id), the text keeps the sigil. */
|
|
31
48
|
function chipElement(segment: Exclude<ChipSegment, { kind: 'text' }>): HastElement {
|
|
32
49
|
return {
|
|
33
50
|
type: 'element',
|
|
34
51
|
tagName: 'span',
|
|
35
|
-
properties: { dataChip: segment.kind, dataValue: segment
|
|
52
|
+
properties: { dataChip: segment.kind, dataValue: valueOf(segment) },
|
|
36
53
|
children: [{ type: 'text', value: chipText(segment) }]
|
|
37
54
|
};
|
|
38
55
|
}
|
|
39
56
|
|
|
40
|
-
function split(parent: HastParent,
|
|
57
|
+
function split(parent: HastParent, tokenize: (text: string) => ChipSegment[], skipped: ReadonlySet<string>): void {
|
|
41
58
|
parent.children = parent.children.flatMap((child): HastNode[] => {
|
|
42
59
|
if (child.type === 'text') {
|
|
43
|
-
return
|
|
44
|
-
segment.kind === 'text' ? { type: 'text', value: segment.text } : chipElement(segment)
|
|
45
|
-
);
|
|
60
|
+
return tokenize(child.value).map((segment) => (segment.kind === 'text' ? { type: 'text', value: segment.text } : chipElement(segment)));
|
|
46
61
|
}
|
|
47
|
-
if (child.type === 'element' && !
|
|
48
|
-
split(child,
|
|
62
|
+
if (child.type === 'element' && !skipped.has(child.tagName)) {
|
|
63
|
+
split(child, tokenize, skipped);
|
|
49
64
|
}
|
|
50
65
|
return [child];
|
|
51
66
|
});
|
|
52
67
|
}
|
|
53
68
|
|
|
54
|
-
/* The `@path` and
|
|
55
|
-
export function rehypeChips({ mentions = [], skills = [] }: ChipOptions = {}) {
|
|
69
|
+
/* The `@path`, `$name` and `#name` tokens a person picked in the composer, cut out of the text as chips. */
|
|
70
|
+
export function rehypeChips({ mentions = [], skills = [], references = [] }: ChipOptions = {}) {
|
|
71
|
+
return (tree: HastParent) => {
|
|
72
|
+
if (mentions.length === 0 && skills.length === 0 && references.length === 0) {
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
split(tree, (text) => tokenizeChips(text, mentions, skills, references), SKIPPED);
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/* The `#Name` a reply spells for a view, node or chat by exactly its name, cut out of the text as chips. */
|
|
80
|
+
export function rehypeNamedReferences({ items, referenced = [] }: NamedReferenceOptions) {
|
|
56
81
|
return (tree: HastParent) => {
|
|
57
|
-
if (
|
|
82
|
+
if (items.length === 0) {
|
|
58
83
|
return;
|
|
59
84
|
}
|
|
60
|
-
split(tree,
|
|
85
|
+
split(tree, (text) => (text.includes('#') ? tokenizeNamedReferences(text, items, referenced) : [{ kind: 'text', text }]), SKIPPED_IN_REPLIES);
|
|
61
86
|
};
|
|
62
87
|
}
|