@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.
Files changed (98) hide show
  1. package/dist/chat/chat-client.d.ts +1 -0
  2. package/dist/chat/chat-client.js +2 -1
  3. package/dist/chat/chat-references.d.ts +16 -1
  4. package/dist/chat/chat-references.js +29 -3
  5. package/dist/chat/drafts.d.ts +2 -0
  6. package/dist/chat/drafts.js +21 -5
  7. package/dist/chat/index.d.ts +1 -1
  8. package/dist/chat/mentions.d.ts +31 -14
  9. package/dist/chat/mentions.js +186 -41
  10. package/dist/chat/stash.d.ts +1 -0
  11. package/dist/chat/stash.js +4 -2
  12. package/dist/chat/ui/Composer.js +438 -429
  13. package/dist/chat/ui/ComposerInput.d.ts +1 -0
  14. package/dist/chat/ui/ComposerInput.js +2 -0
  15. package/dist/chat/ui/Markdown.d.ts +3 -1
  16. package/dist/chat/ui/Markdown.js +194 -120
  17. package/dist/chat/ui/MessageActions.js +70 -66
  18. package/dist/chat/ui/ReferenceChip.d.ts +15 -0
  19. package/dist/chat/ui/ReferenceChip.js +255 -0
  20. package/dist/chat/ui/Timeline.js +52 -29
  21. package/dist/chat/ui/TimelineMenu.js +84 -78
  22. package/dist/chat/ui/chip-clipboard.d.ts +9 -0
  23. package/dist/chat/ui/chip-clipboard.js +105 -0
  24. package/dist/chat/ui/chips.d.ts +5 -0
  25. package/dist/chat/ui/chips.js +15 -1
  26. package/dist/chat/ui/composer/ComposerMenu.js +140 -79
  27. package/dist/chat/ui/composer/DraftParts.d.ts +0 -4
  28. package/dist/chat/ui/composer/DraftParts.js +0 -41
  29. package/dist/chat/ui/composer/chip-paste.d.ts +15 -0
  30. package/dist/chat/ui/composer/chip-paste.js +104 -0
  31. package/dist/chat/ui/composer/chips.d.ts +5 -0
  32. package/dist/chat/ui/composer/chips.js +59 -9
  33. package/dist/chat/ui/composer/key-handler.js +9 -0
  34. package/dist/chat/ui/composer/use-composer-draft.d.ts +0 -1
  35. package/dist/chat/ui/composer/use-composer-draft.js +2 -10
  36. package/dist/chat/ui/composer/use-composer-menus.d.ts +12 -2
  37. package/dist/chat/ui/composer/use-composer-menus.js +192 -149
  38. package/dist/chat/ui/composer/use-composer-submit.js +17 -12
  39. package/dist/chat/ui/composer/use-draft-files.d.ts +7 -1
  40. package/dist/chat/ui/composer/use-draft-files.js +90 -58
  41. package/dist/chat/ui/composer/use-take-back.js +2 -1
  42. package/dist/chat/ui/index.d.ts +0 -1
  43. package/dist/chat/ui/index.js +0 -1
  44. package/dist/chat/ui/reference-icons.d.ts +3 -0
  45. package/dist/chat/ui/reference-icons.js +15 -0
  46. package/dist/chat/ui/reference-lookup.d.ts +7 -0
  47. package/dist/chat/ui/reference-lookup.js +6 -0
  48. package/dist/chat/ui/rehype-chips.d.ts +8 -1
  49. package/dist/chat/ui/rehype-chips.js +35 -11
  50. package/dist/chat/ui/rows/MessageRows.js +218 -158
  51. package/dist/chat/ui/thread-references.d.ts +4 -0
  52. package/dist/chat/ui/thread-references.js +78 -0
  53. package/dist/host-hooks.d.ts +2 -0
  54. package/dist/host-hooks.js +7 -0
  55. package/dist/host.d.ts +3 -0
  56. package/dist/locales/en/agent-chat.d.ts +20 -3
  57. package/dist/locales/en/agent-chat.js +24 -7
  58. package/dist/locales/nl/agent-chat.d.ts +20 -3
  59. package/dist/locales/nl/agent-chat.js +24 -7
  60. package/package.json +8 -8
  61. package/src/chat/chat-client.ts +4 -1
  62. package/src/chat/chat-references.ts +44 -3
  63. package/src/chat/drafts.ts +24 -6
  64. package/src/chat/index.ts +1 -1
  65. package/src/chat/mentions.ts +202 -36
  66. package/src/chat/stash.ts +6 -2
  67. package/src/chat/ui/Composer.tsx +14 -4
  68. package/src/chat/ui/ComposerInput.tsx +4 -0
  69. package/src/chat/ui/Markdown.tsx +57 -25
  70. package/src/chat/ui/MessageActions.tsx +5 -2
  71. package/src/chat/ui/ReferenceChip.tsx +74 -0
  72. package/src/chat/ui/Timeline.tsx +24 -2
  73. package/src/chat/ui/TimelineMenu.tsx +7 -3
  74. package/src/chat/ui/chip-clipboard.ts +115 -0
  75. package/src/chat/ui/chips.ts +20 -1
  76. package/src/chat/ui/composer/ComposerMenu.tsx +53 -37
  77. package/src/chat/ui/composer/DraftParts.tsx +0 -12
  78. package/src/chat/ui/composer/chip-paste.ts +122 -0
  79. package/src/chat/ui/composer/chips.ts +60 -10
  80. package/src/chat/ui/composer/key-handler.ts +9 -0
  81. package/src/chat/ui/composer/use-composer-draft.ts +1 -7
  82. package/src/chat/ui/composer/use-composer-menus.ts +68 -29
  83. package/src/chat/ui/composer/use-composer-submit.ts +13 -6
  84. package/src/chat/ui/composer/use-draft-files.ts +39 -5
  85. package/src/chat/ui/composer/use-take-back.ts +2 -1
  86. package/src/chat/ui/index.ts +0 -1
  87. package/src/chat/ui/reference-icons.ts +17 -0
  88. package/src/chat/ui/reference-lookup.ts +13 -0
  89. package/src/chat/ui/rehype-chips.ts +38 -13
  90. package/src/chat/ui/rows/MessageRows.tsx +88 -64
  91. package/src/chat/ui/thread-references.ts +47 -0
  92. package/src/host-hooks.ts +9 -0
  93. package/src/host.ts +6 -1
  94. package/src/locales/en/agent-chat.ts +24 -7
  95. package/src/locales/nl/agent-chat.ts +24 -7
  96. package/dist/chat/ui/ChatReferenceChip.d.ts +0 -4
  97. package/dist/chat/ui/ChatReferenceChip.js +0 -110
  98. 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, RangeSetBuilder, StateField, type EditorState, type Extension } from '@codemirror/state';
3
- import { Decoration, EditorView, type DecorationSet } from '@codemirror/view';
4
- import { chipRanges } from '../../mentions';
5
- import { CHIP_IN_EDITOR, MENTION_TONE, SKILL_TONE } from '../chips';
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 builder = new RangeSetBuilder<Decoration>();
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
- builder.add(range.from, range.to, range.kind === 'skill' ? skillMark : mentionMark);
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 builder.finish();
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 `$name` tokens as chips; a new `ChipChoices` object is what redraws them. */
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, removeChat, removeAttachment };
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 { chatSuggestions } from '../../chat-references';
8
+ import { referenceSuggestions, referenceTokens, type Referable } from '../../chat-references';
9
9
  import { usableSlashCommands } from '../../guards';
10
10
  import {
11
- dropQuery,
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 { useHostReferableChats } from '../../../host-hooks';
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
- // The chats go above the files, so a few leave the files in view.
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 and `@` for a file or another chat.
59
- * One is open at a time, and the arrow keys and the editor's ARIA attributes follow that one.
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 chats = useHostReferableChats();
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 chatMatches = mention === null ? [] : chatSuggestions(chats, mention.query, [chatId, ...draft.chats], CHAT_RESULTS);
161
- const mentionCount = chatMatches.length + files.length;
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 neither. In code a sigil is just text. */
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 skill = caret === null ? null : findSkillQuery(value, caret);
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
- /* A picked chat sits beside the text as a chip, so the `@query` that found it goes. */
203
- const chooseChat = (id: string): void => {
204
- if (!mention) {
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 = dropQuery(text, mention);
208
- setDraft((current) => ({ ...current, text: result.text, chats: current.chats.includes(id) ? current.chats : [...current.chats, id] }));
209
- setMention(null);
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 chooseMentionAt = (index: number): void => {
214
- const pick = mention === null ? null : mentionPick(index, chatMatches, searched, mention.query);
215
- if (pick?.kind === 'chat') {
216
- chooseChat(pick.chat.id);
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: draft.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(prompt.chats, new Set(referable.map((chat) => chat.id)), chatId);
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 { carriesMentions, droppedMentions, pastedMentions } from '../../mentions';
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
- /* What reaches the draft besides typing: files as attachments, dragged paths as mentions, and a long paste as a file of its own. */
27
- export function useDraftFiles({ capabilities, providerName, writable, draft: { draft, setDraft, setText }, inputRef, setNotice }: DraftFilesInput) {
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
- return false;
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];
@@ -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
- /* A chip as an element: the renderer reads `data-chip` and `data-value`, the text keeps the sigil. */
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.kind === 'mention' ? segment.path : segment.name },
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, mentions: string[], skills: string[]): void {
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 tokenizeChips(child.value, mentions, skills).map((segment) =>
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' && !SKIPPED.has(child.tagName)) {
48
- split(child, mentions, skills);
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 `$name` tokens a person picked in the composer, cut out of the text as chips. */
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 (mentions.length === 0 && skills.length === 0) {
82
+ if (items.length === 0) {
58
83
  return;
59
84
  }
60
- split(tree, mentions, skills);
85
+ split(tree, (text) => (text.includes('#') ? tokenizeNamedReferences(text, items, referenced) : [{ kind: 'text', text }]), SKIPPED_IN_REPLIES);
61
86
  };
62
87
  }