@adecore/agents-react 0.37.2 → 0.38.0

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 (115) 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/logic/visual-height.d.ts +1 -0
  9. package/dist/chat/logic/visual-height.js +1 -1
  10. package/dist/chat/mentions.d.ts +31 -14
  11. package/dist/chat/mentions.js +186 -41
  12. package/dist/chat/stash.d.ts +1 -0
  13. package/dist/chat/stash.js +4 -2
  14. package/dist/chat/ui/Composer.js +438 -429
  15. package/dist/chat/ui/ComposerInput.d.ts +1 -0
  16. package/dist/chat/ui/ComposerInput.js +2 -0
  17. package/dist/chat/ui/HostMenuItems.d.ts +4 -0
  18. package/dist/chat/ui/HostMenuItems.js +15 -0
  19. package/dist/chat/ui/Markdown.d.ts +3 -1
  20. package/dist/chat/ui/Markdown.js +194 -120
  21. package/dist/chat/ui/MessageActions.js +70 -66
  22. package/dist/chat/ui/ReferenceChip.d.ts +15 -0
  23. package/dist/chat/ui/ReferenceChip.js +255 -0
  24. package/dist/chat/ui/Timeline.js +52 -29
  25. package/dist/chat/ui/TimelineMenu.js +84 -78
  26. package/dist/chat/ui/VisualFrame.d.ts +8 -0
  27. package/dist/chat/ui/VisualFrame.js +217 -115
  28. package/dist/chat/ui/chip-clipboard.d.ts +9 -0
  29. package/dist/chat/ui/chip-clipboard.js +105 -0
  30. package/dist/chat/ui/chips.d.ts +5 -0
  31. package/dist/chat/ui/chips.js +15 -1
  32. package/dist/chat/ui/composer/ComposerMenu.js +140 -79
  33. package/dist/chat/ui/composer/DraftParts.d.ts +0 -4
  34. package/dist/chat/ui/composer/DraftParts.js +0 -41
  35. package/dist/chat/ui/composer/chip-paste.d.ts +15 -0
  36. package/dist/chat/ui/composer/chip-paste.js +104 -0
  37. package/dist/chat/ui/composer/chips.d.ts +5 -0
  38. package/dist/chat/ui/composer/chips.js +59 -9
  39. package/dist/chat/ui/composer/key-handler.js +9 -0
  40. package/dist/chat/ui/composer/use-composer-draft.d.ts +0 -1
  41. package/dist/chat/ui/composer/use-composer-draft.js +2 -10
  42. package/dist/chat/ui/composer/use-composer-menus.d.ts +12 -2
  43. package/dist/chat/ui/composer/use-composer-menus.js +192 -149
  44. package/dist/chat/ui/composer/use-composer-submit.js +17 -12
  45. package/dist/chat/ui/composer/use-draft-files.d.ts +7 -1
  46. package/dist/chat/ui/composer/use-draft-files.js +90 -58
  47. package/dist/chat/ui/composer/use-take-back.js +2 -1
  48. package/dist/chat/ui/index.d.ts +1 -1
  49. package/dist/chat/ui/index.js +1 -1
  50. package/dist/chat/ui/reference-icons.d.ts +3 -0
  51. package/dist/chat/ui/reference-icons.js +15 -0
  52. package/dist/chat/ui/reference-lookup.d.ts +7 -0
  53. package/dist/chat/ui/reference-lookup.js +6 -0
  54. package/dist/chat/ui/rehype-chips.d.ts +8 -1
  55. package/dist/chat/ui/rehype-chips.js +35 -11
  56. package/dist/chat/ui/rows/GeneratedImageRow.js +8 -4
  57. package/dist/chat/ui/rows/MessageRows.js +218 -158
  58. package/dist/chat/ui/rows/VisualRow.js +184 -142
  59. package/dist/chat/ui/rows/row-rhythm.js +6 -4
  60. package/dist/chat/ui/thread-references.d.ts +4 -0
  61. package/dist/chat/ui/thread-references.js +78 -0
  62. package/dist/host-hooks.d.ts +2 -0
  63. package/dist/host-hooks.js +7 -0
  64. package/dist/host.d.ts +14 -1
  65. package/dist/index.d.ts +1 -1
  66. package/dist/locales/en/agent-chat.d.ts +20 -3
  67. package/dist/locales/en/agent-chat.js +24 -7
  68. package/dist/locales/nl/agent-chat.d.ts +20 -3
  69. package/dist/locales/nl/agent-chat.js +24 -7
  70. package/package.json +8 -8
  71. package/src/chat/chat-client.ts +4 -1
  72. package/src/chat/chat-references.ts +44 -3
  73. package/src/chat/drafts.ts +24 -6
  74. package/src/chat/index.ts +1 -1
  75. package/src/chat/logic/visual-height.ts +1 -1
  76. package/src/chat/mentions.ts +202 -36
  77. package/src/chat/stash.ts +6 -2
  78. package/src/chat/ui/Composer.tsx +14 -4
  79. package/src/chat/ui/ComposerInput.tsx +4 -0
  80. package/src/chat/ui/HostMenuItems.tsx +11 -0
  81. package/src/chat/ui/Markdown.tsx +57 -25
  82. package/src/chat/ui/MessageActions.tsx +5 -2
  83. package/src/chat/ui/ReferenceChip.tsx +74 -0
  84. package/src/chat/ui/Timeline.tsx +24 -2
  85. package/src/chat/ui/TimelineMenu.tsx +7 -3
  86. package/src/chat/ui/VisualFrame.tsx +68 -21
  87. package/src/chat/ui/chip-clipboard.ts +115 -0
  88. package/src/chat/ui/chips.ts +20 -1
  89. package/src/chat/ui/composer/ComposerMenu.tsx +53 -37
  90. package/src/chat/ui/composer/DraftParts.tsx +0 -12
  91. package/src/chat/ui/composer/chip-paste.ts +122 -0
  92. package/src/chat/ui/composer/chips.ts +60 -10
  93. package/src/chat/ui/composer/key-handler.ts +9 -0
  94. package/src/chat/ui/composer/use-composer-draft.ts +1 -7
  95. package/src/chat/ui/composer/use-composer-menus.ts +68 -29
  96. package/src/chat/ui/composer/use-composer-submit.ts +13 -6
  97. package/src/chat/ui/composer/use-draft-files.ts +39 -5
  98. package/src/chat/ui/composer/use-take-back.ts +2 -1
  99. package/src/chat/ui/index.ts +1 -1
  100. package/src/chat/ui/reference-icons.ts +17 -0
  101. package/src/chat/ui/reference-lookup.ts +13 -0
  102. package/src/chat/ui/rehype-chips.ts +38 -13
  103. package/src/chat/ui/rows/GeneratedImageRow.tsx +9 -4
  104. package/src/chat/ui/rows/MessageRows.tsx +88 -64
  105. package/src/chat/ui/rows/VisualRow.tsx +5 -1
  106. package/src/chat/ui/rows/row-rhythm.ts +6 -4
  107. package/src/chat/ui/thread-references.ts +47 -0
  108. package/src/host-hooks.ts +9 -0
  109. package/src/host.ts +24 -1
  110. package/src/index.ts +1 -0
  111. package/src/locales/en/agent-chat.ts +24 -7
  112. package/src/locales/nl/agent-chat.ts +24 -7
  113. package/dist/chat/ui/ChatReferenceChip.d.ts +0 -4
  114. package/dist/chat/ui/ChatReferenceChip.js +0 -110
  115. package/src/chat/ui/ChatReferenceChip.tsx +0 -26
@@ -23,27 +23,108 @@ export function writeMentionDrag(data: Pick<DataTransfer, 'setData'>, paths: rea
23
23
  }
24
24
  }
25
25
 
26
+ /* Beside the plain text of a copy from a thread or a composer: the chips in it, so a paste makes them chips again. */
27
+ export const CHIPS_CLIPBOARD_TYPE = 'application/x-adecore-chips';
28
+
29
+ export interface ClipboardChips {
30
+ text: string;
31
+ mentions: string[];
32
+ skills: string[];
33
+ references: ReferenceToken[];
34
+ }
35
+
36
+ /* The chips among `segments`, without doubles, for the copy of `text`. */
37
+ export function clipboardChipsOf(text: string, segments: readonly ChipSegment[]): ClipboardChips {
38
+ const chips: ClipboardChips = { text, mentions: [], skills: [], references: [] };
39
+ for (const segment of segments) {
40
+ if (segment.kind === 'mention' && !chips.mentions.includes(segment.path)) {
41
+ chips.mentions.push(segment.path);
42
+ } else if (segment.kind === 'skill' && !chips.skills.includes(segment.name)) {
43
+ chips.skills.push(segment.name);
44
+ } else if (segment.kind === 'reference' && !chips.references.some((token) => token.id === segment.id)) {
45
+ chips.references.push({ id: segment.id, name: segment.name });
46
+ }
47
+ }
48
+ return chips;
49
+ }
50
+
51
+ export function hasClipboardChips(chips: ClipboardChips): boolean {
52
+ return chips.mentions.length > 0 || chips.skills.length > 0 || chips.references.length > 0;
53
+ }
54
+
55
+ export function writeClipboardChips(data: Pick<DataTransfer, 'setData'>, chips: ClipboardChips): void {
56
+ data.setData('text/plain', chips.text);
57
+ data.setData(CHIPS_CLIPBOARD_TYPE, JSON.stringify(chips));
58
+ }
59
+
60
+ const lines = (text: string): string => text.replace(/\r\n/g, '\n');
61
+
62
+ const isStrings = (value: unknown): value is string[] => Array.isArray(value) && value.every((entry) => typeof entry === 'string');
63
+
64
+ /* The chips of a paste, or null when it has none or its plain text is no longer the text they were copied with. */
65
+ export function readClipboardChips(data: Pick<DataTransfer, 'getData'>): ClipboardChips | null {
66
+ const raw = data.getData(CHIPS_CLIPBOARD_TYPE);
67
+ if (raw === '') {
68
+ return null;
69
+ }
70
+ let parsed: unknown;
71
+ try {
72
+ parsed = JSON.parse(raw);
73
+ } catch {
74
+ return null;
75
+ }
76
+ if (typeof parsed !== 'object' || parsed === null) {
77
+ return null;
78
+ }
79
+ const { text, mentions, skills, references } = parsed as Record<string, unknown>;
80
+ const tokens = Array.isArray(references)
81
+ ? references.filter(
82
+ (token): token is ReferenceToken =>
83
+ typeof token === 'object' && token !== null && typeof token.id === 'string' && typeof token.name === 'string' && token.name !== ''
84
+ )
85
+ : [];
86
+ if (typeof text !== 'string' || lines(text) !== lines(data.getData('text/plain')) || !isStrings(mentions) || !isStrings(skills)) {
87
+ return null;
88
+ }
89
+ return { text: lines(text), mentions, skills, references: tokens.map(({ id, name }) => ({ id, name })) };
90
+ }
91
+
26
92
  export interface MentionQuery {
27
93
  // Index of the sigil in the text.
28
94
  start: number;
29
95
  query: string;
30
96
  }
31
97
 
32
- export type ChipSegment = { kind: 'text'; text: string } | { kind: 'mention'; path: string } | { kind: 'skill'; name: string };
98
+ /* A view, node or chat picked with `#`, under the name the text spells it by, which may hold spaces. */
99
+ export interface ReferenceToken {
100
+ id: string;
101
+ name: string;
102
+ }
103
+
104
+ export type ChipSegment =
105
+ | { kind: 'text'; text: string }
106
+ | { kind: 'mention'; path: string }
107
+ | { kind: 'skill'; name: string }
108
+ | { kind: 'reference'; id: string; name: string };
33
109
 
34
110
  function isBoundary(char: string | undefined): boolean {
35
111
  return char === undefined || /\s/.test(char);
36
112
  }
37
113
 
114
+ const TOKEN_END = /[.,;:!?)\]}"'”’]/;
115
+
38
116
  // A path at the end of a sentence still counts, so "open @a.ts." is a mention of a.ts.
39
117
  function isTokenEnd(char: string | undefined): boolean {
40
- return isBoundary(char) || /[.,;:!?)]/.test(char!);
118
+ return isBoundary(char) || TOKEN_END.test(char!);
41
119
  }
42
120
 
121
+ // A path goes on after a dot or a colon (`a.ts`, `C:`), so only the rest ends a file query.
122
+ const PATH_GOES_ON = /[.:]$/;
123
+
43
124
  /*
44
125
  * The `<sigil>word` the caret sits in, if any. The sigil has to open a word (start of text or after
45
- * whitespace) so an email address never opens the picker, and the word may not have been
46
- * closed with whitespace yet.
126
+ * whitespace) so an email address never opens the picker, and the word may not have been closed
127
+ * with whitespace or the punctuation that ends a token (`#Admin?`) yet.
47
128
  */
48
129
  function findQuery(text: string, caret: number, sigil: string): MentionQuery | null {
49
130
  const before = text.slice(0, caret);
@@ -55,9 +136,17 @@ function findQuery(text: string, caret: number, sigil: string): MentionQuery | n
55
136
  if (/\s/.test(query) || !isBoundary(text[caret])) {
56
137
  return null;
57
138
  }
139
+ if (query !== '' && isTokenEnd(query.at(-1)) && !(sigil === '@' && PATH_GOES_ON.test(query))) {
140
+ return null;
141
+ }
58
142
  return { start: at, query };
59
143
  }
60
144
 
145
+ /* The query, unless its sigil opens a token that is already a chip, which the picker has nothing to add to. */
146
+ export function queryOutsideChips(query: MentionQuery | null, chips: readonly ChipRange[]): MentionQuery | null {
147
+ return query !== null && chips.some((chip) => chip.from === query.start) ? null : query;
148
+ }
149
+
61
150
  export function findMentionQuery(text: string, caret: number): MentionQuery | null {
62
151
  return findQuery(text, caret, '@');
63
152
  }
@@ -68,6 +157,15 @@ export function findSkillQuery(text: string, caret: number): MentionQuery | null
68
157
  return found === null || (found.query !== '' && !/^[A-Za-z]/.test(found.query)) ? null : found;
69
158
  }
70
159
 
160
+ /*
161
+ * The `#word` the caret sits in. It opens only once a letter follows the sigil, so a `# ` heading,
162
+ * `#123` and a bare `#` stay text. A name with a space is found by its first word.
163
+ */
164
+ export function findReferenceQuery(text: string, caret: number): MentionQuery | null {
165
+ const found = findQuery(text, caret, '#');
166
+ return found === null || !/^\p{L}/u.test(found.query) ? null : found;
167
+ }
168
+
71
169
  /* Replaces the `<sigil>query` under the caret with the chosen token and a space, and says where the caret goes. */
72
170
  export function insertToken(text: string, query: MentionQuery, sigil: string, value: string): { text: string; caret: number } {
73
171
  const end = query.start + 1 + query.query.length;
@@ -75,14 +173,6 @@ export function insertToken(text: string, query: MentionQuery, sigil: string, va
75
173
  return { text: `${text.slice(0, query.start)}${token}${text.slice(end)}`, caret: query.start + token.length };
76
174
  }
77
175
 
78
- /* Takes the `<sigil>query` under the caret out, for a pick that lands beside the text instead of in it. */
79
- export function dropQuery(text: string, query: MentionQuery): { text: string; caret: number } {
80
- return {
81
- text: `${text.slice(0, query.start)}${text.slice(query.start + 1 + query.query.length)}`,
82
- caret: query.start
83
- };
84
- }
85
-
86
176
  export function insertMention(text: string, query: MentionQuery, path: string): { text: string; caret: number } {
87
177
  return insertToken(text, query, '@', path);
88
178
  }
@@ -91,6 +181,10 @@ export function insertSkill(text: string, query: MentionQuery, name: string): {
91
181
  return insertToken(text, query, '$', name);
92
182
  }
93
183
 
184
+ export function insertReference(text: string, query: MentionQuery, name: string): { text: string; caret: number } {
185
+ return insertToken(text, query, '#', name);
186
+ }
187
+
94
188
  /* What a file search answered, with the query it answered, since the query moves on while the answer is on its way. */
95
189
  export interface MentionSearch {
96
190
  query: string | null;
@@ -99,19 +193,12 @@ export interface MentionSearch {
99
193
 
100
194
  export const NO_MENTION_SEARCH: MentionSearch = { query: null, files: [] };
101
195
 
102
- export type MentionPick<T> = { kind: 'chat'; chat: T } | { kind: 'file'; path: string };
103
-
104
- /* What Enter or Tab takes from the `@` list at `index`. Files still on screen from an older query are never taken. */
105
- export function mentionPick<T>(index: number, chats: readonly T[], search: MentionSearch, query: string): MentionPick<T> | null {
106
- const chat = chats[index];
107
- if (chat !== undefined) {
108
- return { kind: 'chat', chat };
109
- }
196
+ /* The path Enter or Tab takes from the `@` list at `index`. Files still on screen from an older query are never taken. */
197
+ export function mentionPick(index: number, search: MentionSearch, query: string): string | null {
110
198
  if (search.query !== query) {
111
199
  return null;
112
200
  }
113
- const path = search.files[index - chats.length] ?? search.files[0];
114
- return path === undefined ? null : { kind: 'file', path };
201
+ return search.files[index] ?? search.files[0] ?? null;
115
202
  }
116
203
 
117
204
  function findToken(text: string, sigil: string, value: string, from: number): number {
@@ -147,6 +234,14 @@ export function presentSkills(text: string, chosen: string[]): string[] {
147
234
  return present(text, '$', chosen);
148
235
  }
149
236
 
237
+ /* The ids among `ids` whose `#name` still sits in the text as a whole token, in text order and without doubles. */
238
+ export function presentReferences(text: string, ids: readonly string[], tokens: readonly ReferenceToken[]): string[] {
239
+ const wanted = new Set(ids);
240
+ return chipRanges(text, [], [], [], tokens)
241
+ .map((range) => range.value)
242
+ .filter((id, index, found) => wanted.has(id) && found.indexOf(id) === index);
243
+ }
244
+
150
245
  /* The characters a segment stands for, sigil included; joined, the segments spell the text they were cut from. */
151
246
  export function chipText(segment: ChipSegment): string {
152
247
  if (segment.kind === 'mention') {
@@ -155,6 +250,9 @@ export function chipText(segment: ChipSegment): string {
155
250
  if (segment.kind === 'skill') {
156
251
  return `$${segment.name}`;
157
252
  }
253
+ if (segment.kind === 'reference') {
254
+ return `#${segment.name}`;
255
+ }
158
256
  return segment.text;
159
257
  }
160
258
 
@@ -175,30 +273,38 @@ export interface TextRange {
175
273
  to: number;
176
274
  }
177
275
 
276
+ /* `value` is the path, the skill's name or, for a reference, its id; the range spans the name. */
178
277
  export interface ChipRange extends TextRange {
179
- kind: 'mention' | 'skill';
278
+ kind: 'mention' | 'skill' | 'reference';
180
279
  value: string;
181
280
  }
182
281
 
183
282
  /*
184
- * Where the chosen mentions and skills sit in the text, in text order; longer values win when one
185
- * prefixes another. A token that overlaps an excluded range (a code span, say) is text.
283
+ * Where the chosen mentions, skills and references sit in the text, in text order; longer values win
284
+ * when one prefixes another. A token that overlaps an excluded range (a code span, say) is text.
186
285
  */
187
- export function chipRanges(text: string, mentions: string[], skills: string[] = [], excluded: readonly TextRange[] = []): ChipRange[] {
286
+ export function chipRanges(
287
+ text: string,
288
+ mentions: string[],
289
+ skills: string[] = [],
290
+ excluded: readonly TextRange[] = [],
291
+ references: readonly ReferenceToken[] = []
292
+ ): ChipRange[] {
188
293
  const tokens = [
189
- ...[...new Set(mentions)].map((value) => ({ sigil: '@', kind: 'mention' as const, value })),
190
- ...[...new Set(skills)].map((value) => ({ sigil: '$', kind: 'skill' as const, value }))
191
- ].sort((a, b) => b.value.length - a.value.length);
294
+ ...[...new Set(mentions)].map((value) => ({ sigil: '@', kind: 'mention' as const, spelled: value, value })),
295
+ ...[...new Set(skills)].map((value) => ({ sigil: '$', kind: 'skill' as const, spelled: value, value })),
296
+ ...references.filter((token) => token.name !== '').map((token) => ({ sigil: '#', kind: 'reference' as const, spelled: token.name, value: token.id }))
297
+ ].sort((a, b) => b.spelled.length - a.spelled.length);
192
298
  const allowed = (from: number, to: number): boolean => !excluded.some((range) => from < range.to && to > range.from);
193
299
  const ranges: ChipRange[] = [];
194
300
  let cursor = 0;
195
301
  while (cursor < text.length) {
196
302
  let best: ChipRange | null = null;
197
303
  for (const token of tokens) {
198
- const length = token.value.length + 1;
199
- let index = findToken(text, token.sigil, token.value, cursor);
304
+ const length = token.spelled.length + 1;
305
+ let index = findToken(text, token.sigil, token.spelled, cursor);
200
306
  while (index >= 0 && !allowed(index, index + length)) {
201
- index = findToken(text, token.sigil, token.value, index + 1);
307
+ index = findToken(text, token.sigil, token.spelled, index + 1);
202
308
  }
203
309
  if (index >= 0 && (best === null || index < best.from)) {
204
310
  best = { from: index, to: index + length, kind: token.kind, value: token.value };
@@ -213,15 +319,75 @@ export function chipRanges(text: string, mentions: string[], skills: string[] =
213
319
  return ranges;
214
320
  }
215
321
 
216
- /* Splits the text so a renderer can draw the mentions and skills as chips. */
217
- export function tokenizeChips(text: string, mentions: string[], skills: string[] = []): ChipSegment[] {
322
+ function segmentOf(text: string, range: ChipRange): ChipSegment {
323
+ if (range.kind === 'mention') {
324
+ return { kind: 'mention', path: range.value };
325
+ }
326
+ if (range.kind === 'skill') {
327
+ return { kind: 'skill', name: range.value };
328
+ }
329
+ return { kind: 'reference', id: range.value, name: text.slice(range.from + 1, range.to) };
330
+ }
331
+
332
+ /* Splits the text so a renderer can draw the mentions, skills and references as chips. */
333
+ export function tokenizeChips(text: string, mentions: string[], skills: string[] = [], references: readonly ReferenceToken[] = []): ChipSegment[] {
334
+ return segmentsOf(text, chipRanges(text, mentions, skills, [], references));
335
+ }
336
+
337
+ // What a `#` may follow in a text nobody typed through the picker: a space, or an opening bracket or quote.
338
+ const REFERENCE_OPENER = /[\s([{"'“‘]/;
339
+
340
+ /*
341
+ * Where a text written without the picker, such as a reply, spells `#Name` for a view, node or chat
342
+ * by exactly its name. The longest name wins, so a name with a space is found whole. A name more
343
+ * than one of them carries stays text, unless one of those is in `referenced`, which then wins.
344
+ */
345
+ export function namedReferenceRanges(
346
+ text: string,
347
+ items: readonly ReferenceToken[],
348
+ referenced: readonly string[] = [],
349
+ excluded: readonly TextRange[] = []
350
+ ): ChipRange[] {
351
+ const byName = new Map<string, string[]>();
352
+ for (const item of items) {
353
+ if (item.name !== '') {
354
+ byName.set(item.name, [...(byName.get(item.name) ?? []), item.id]);
355
+ }
356
+ }
357
+ const names = [...byName.keys()].sort((a, b) => b.length - a.length);
358
+ const preferred = new Set(referenced);
359
+ const ranges: ChipRange[] = [];
360
+ let index = text.indexOf('#');
361
+ while (index >= 0) {
362
+ const from = index;
363
+ const name =
364
+ from > 0 && !REFERENCE_OPENER.test(text[from - 1]!)
365
+ ? undefined
366
+ : names.find((candidate) => text.startsWith(candidate, from + 1) && isTokenEnd(text[from + 1 + candidate.length]));
367
+ const to = name === undefined ? from + 1 : from + 1 + name.length;
368
+ const ids = name === undefined ? [] : byName.get(name)!;
369
+ const id = ids.find((candidate) => preferred.has(candidate)) ?? (ids.length === 1 ? ids[0] : undefined);
370
+ if (id !== undefined && !excluded.some((range) => from < range.to && to > range.from)) {
371
+ ranges.push({ from, to, kind: 'reference', value: id });
372
+ }
373
+ index = text.indexOf('#', to);
374
+ }
375
+ return ranges;
376
+ }
377
+
378
+ /* `namedReferenceRanges` as segments, for a renderer. */
379
+ export function tokenizeNamedReferences(text: string, items: readonly ReferenceToken[], referenced: readonly string[] = []): ChipSegment[] {
380
+ return segmentsOf(text, namedReferenceRanges(text, items, referenced));
381
+ }
382
+
383
+ function segmentsOf(text: string, ranges: readonly ChipRange[]): ChipSegment[] {
218
384
  const segments: ChipSegment[] = [];
219
385
  let cursor = 0;
220
- for (const range of chipRanges(text, mentions, skills)) {
386
+ for (const range of ranges) {
221
387
  if (range.from > cursor) {
222
388
  segments.push({ kind: 'text', text: text.slice(cursor, range.from) });
223
389
  }
224
- segments.push(range.kind === 'mention' ? { kind: 'mention', path: range.value } : { kind: 'skill', name: range.value });
390
+ segments.push(segmentOf(text, range));
225
391
  cursor = range.to;
226
392
  }
227
393
  if (cursor < text.length) {
package/src/chat/stash.ts CHANGED
@@ -32,6 +32,8 @@ export interface StashedPrompt {
32
32
  /* A quote that knew its reply, kept apart from the text so it can lead back to it in the chat it came from. */
33
33
  quote?: StashedQuote;
34
34
  createdAt: number;
35
+ /* Views and nodes of the project picked with `#`; an entry from before this field has none. */
36
+ references: string[];
35
37
  }
36
38
 
37
39
  export interface StashedQuote {
@@ -88,7 +90,8 @@ export function parseStash(raw: string | null): StashedPrompt[] {
88
90
  chats: strings(entry.chats),
89
91
  attachments: attachments(entry.attachments),
90
92
  ...stashedQuoteField(entry.quote),
91
- createdAt: typeof entry.createdAt === 'number' ? entry.createdAt : 0
93
+ createdAt: typeof entry.createdAt === 'number' ? entry.createdAt : 0,
94
+ references: strings(entry.references)
92
95
  }))
93
96
  .slice(0, STASH_LIMIT);
94
97
  } catch {
@@ -118,7 +121,8 @@ export function stashedFrom(draft: ChatDraft, id: string, now: number, chatId: s
118
121
  skills: [...draft.skills],
119
122
  chats: [...draft.chats],
120
123
  attachments: draft.attachments.map((attachment) => ({ name: attachment.name, mime: attachment.mime, size: uploadBytes(attachment) })),
121
- createdAt: now
124
+ createdAt: now,
125
+ references: [...draft.references]
122
126
  };
123
127
  }
124
128
 
@@ -26,7 +26,7 @@ import { ResumeCompactionDock } from './ResumeCompactionDock';
26
26
  import { chipDecorations } from './composer/chips';
27
27
  import { ComposerControls } from './composer/ComposerControls';
28
28
  import { ComposerMenu } from './composer/ComposerMenu';
29
- import { DraftAttachments, DraftChats, DraftQuote } from './composer/DraftParts';
29
+ import { DraftAttachments, DraftQuote } from './composer/DraftParts';
30
30
  import { composerKeyHandler } from './composer/key-handler';
31
31
  import { useHintedPlaceholder } from './composer/placeholder';
32
32
  import { QueuedMessages } from './composer/QueuedMessages';
@@ -96,7 +96,17 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
96
96
  const notice = useNotice();
97
97
  const history = usePromptHistory(chatId, box.setText);
98
98
  const menus = useComposerMenus({ chatId, info, capabilities, providerName: provider?.name, draft: box, inputRef });
99
- const files = useDraftFiles({ capabilities, providerName: provider?.name, writable, draft: box, inputRef, setNotice: notice.setNotice });
99
+ const files = useDraftFiles({
100
+ capabilities,
101
+ providerName: provider?.name,
102
+ writable,
103
+ draft: box,
104
+ inputRef,
105
+ setNotice: notice.setNotice,
106
+ skills: menus.skillNames,
107
+ referables: menus.referables.chatsOnly ? menus.referables.items.filter((item) => item.kind === 'chat') : menus.referables.items,
108
+ referenceChips: menus.referenceChips
109
+ });
100
110
  const controls = useChatControls({ chatId, info, notice, onRetarget });
101
111
  useEffortStepper({
102
112
  chatId,
@@ -154,7 +164,7 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
154
164
  const pending = usePendingRequests(chatId);
155
165
  const compactionOffer = useCompactionOffer(chatId, info, capabilities?.compaction);
156
166
  const placeholder = useHintedPlaceholder({ chatId, disabled, readOnly, mentionable: capabilities?.mentions !== false, hasSkills: menus.hasSkills });
157
- const editorExtensions = [chipDecorations({ mentions: draft.mentions, skills: draft.skills }), LEAVE_DROPS_TO_THE_CARD];
167
+ const editorExtensions = [chipDecorations({ mentions: draft.mentions, skills: draft.skills, references: menus.referenceChips }), LEAVE_DROPS_TO_THE_CARD];
158
168
  /* The timeline owns the scroller; this is the one bit of it the composer needs to know. */
159
169
  const atEnd = useSyncExternalStore(
160
170
  subscribeTimelineEnd,
@@ -263,7 +273,6 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
263
273
  onSourceGone={() => notice.setNotice(t('rows.user.quoteGone'), 'info')}
264
274
  />
265
275
  )}
266
- {draft.chats.length > 0 && <DraftChats chats={draft.chats} onRemove={box.removeChat} />}
267
276
  {draft.attachments.length > 0 && <DraftAttachments attachments={draft.attachments} onRemove={box.removeAttachment} />}
268
277
  <ComposerInput
269
278
  ref={inputRef}
@@ -290,6 +299,7 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
290
299
  onComposing={hold.setComposing}
291
300
  onKeyDown={onKeyDown}
292
301
  onPaste={files.onPaste}
302
+ onCopy={files.onCopy}
293
303
  />
294
304
  {notice.notice && (
295
305
  <div
@@ -40,6 +40,8 @@ interface ComposerInputProps {
40
40
  onKeyDown?(event: KeyboardEvent, view: EditorView): boolean;
41
41
  /* Runs before the editor pastes the plain text itself; true means the paste is handled. */
42
42
  onPaste?(event: ClipboardEvent, view: EditorView): boolean;
43
+ /* Runs before the editor copies or cuts the selection itself; true means the event is handled. */
44
+ onCopy?(event: ClipboardEvent, view: EditorView): boolean;
43
45
  }
44
46
 
45
47
  const NO_EXTENSIONS: Extension = [];
@@ -103,6 +105,8 @@ export function ComposerInput({
103
105
  EditorView.domEventHandlers({
104
106
  keydown: (event, target) => latest.current.handlers.onKeyDown?.(event, target) ?? false,
105
107
  paste: (event, target) => latest.current.handlers.onPaste?.(event, target) ?? false,
108
+ copy: (event, target) => latest.current.handlers.onCopy?.(event, target) ?? false,
109
+ cut: (event, target) => latest.current.handlers.onCopy?.(event, target) ?? false,
106
110
  compositionstart: () => {
107
111
  latest.current.handlers.onComposing?.(true);
108
112
  return false;
@@ -0,0 +1,11 @@
1
+ import { Icon, Menu } from '@adecore/ui';
2
+ import type { HostMenuAction } from '../../host';
3
+
4
+ /* The items a host adds to a menu of the chat's, in the order it gave them. */
5
+ export function HostMenuItems({ actions }: { actions: readonly HostMenuAction[] }) {
6
+ return actions.map((action) => (
7
+ <Menu.Item key={action.id} disabled={action.disabled} onClick={() => action.run()}>
8
+ {action.icon !== undefined && <Icon icon={action.icon} size={14} />} {action.label}
9
+ </Menu.Item>
10
+ ));
11
+ }
@@ -1,15 +1,18 @@
1
- import { createContext, memo, useContext, type ReactNode } from 'react';
1
+ import { createContext, memo, useContext, type ComponentProps, type ReactNode } from 'react';
2
2
  import clsx from 'clsx';
3
3
  import { Zap } from 'lucide-react';
4
4
  import ReactMarkdown, { type Components, type ExtraProps, type Options } from 'react-markdown';
5
5
  import remarkBreaks from 'remark-breaks';
6
6
  import remarkGfm from 'remark-gfm';
7
- import { CHIP_IN_MESSAGE, MENTION_TONE, SKILL_TONE } from './chips';
7
+ import { MENTION_TONE, SKILL_TONE, TOKEN_ICON } from './chips';
8
8
  import { CodeBlock } from './CodeBlock';
9
9
  import { MermaidBlock } from './MermaidBlock';
10
10
  import { CodeStreamingContext } from './code-streaming';
11
11
  import { splitMarkdownBlocks } from './markdown-blocks';
12
- import { rehypeChips, type ChipOptions } from './rehype-chips';
12
+ import { ReferenceChip, TokenChip } from './ReferenceChip';
13
+ import { ReferenceLookupContext } from './reference-lookup';
14
+ import { rehypeChips, rehypeNamedReferences, type ChipOptions, type NamedReferenceOptions } from './rehype-chips';
15
+ import type { ReferenceToken } from '../mentions';
13
16
  import { rehypeFadeWords } from './rehype-fade';
14
17
  import { remarkHtmlAsText } from './remark-html-as-text';
15
18
  import { useChatHost, type ChatHost, type FileRef, type ShellCodeBlockContext } from '../../host';
@@ -264,6 +267,7 @@ function replyHeading(level: 1 | 2 | 3 | 4 | 5 | 6) {
264
267
  const replyComponents = {
265
268
  ...components,
266
269
  code: ReplyCode,
270
+ span: ChipSpan,
267
271
  h1: replyHeading(1),
268
272
  h2: replyHeading(2),
269
273
  h3: replyHeading(3),
@@ -293,34 +297,47 @@ function ReplyCode({ className, children, node }: { className?: string; children
293
297
  return <InlineCode text={text} />;
294
298
  }
295
299
 
296
- /* A picked file or skill in a sent message. The glyph stands in for the sigil the text still carries. */
300
+ /* A picked file, skill or reference in a sent message. The glyph stands in for the sigil the text still carries. */
297
301
  function Chip({ kind, value }: { kind: string; value: string }) {
302
+ if (kind === 'reference') {
303
+ return <ReferenceChip id={value} />;
304
+ }
298
305
  if (kind === 'skill') {
299
306
  return (
300
- <span className={clsx(CHIP_IN_MESSAGE, SKILL_TONE)}>
301
- <Icon icon={Zap} size={14} className="shrink-0 opacity-85" />
302
- <span className="truncate">{value}</span>
303
- </span>
307
+ <TokenChip
308
+ kind="skill"
309
+ value={value}
310
+ sigil="$"
311
+ icon={<Icon icon={Zap} size={14} className={clsx(TOKEN_ICON, 'opacity-85')} />}
312
+ label={value}
313
+ tone={SKILL_TONE}
314
+ />
304
315
  );
305
316
  }
306
317
  return (
307
318
  // The path is what the thread's menu opens in the preview from here.
308
- <span className={clsx(CHIP_IN_MESSAGE, MENTION_TONE)} data-file-path={value} data-file-directory={/[\\/]$/.test(value)}>
309
- <FileIcon path={value} size={14} />
310
- <span className="truncate">{value}</span>
311
- </span>
319
+ <TokenChip
320
+ kind="mention"
321
+ value={value}
322
+ sigil="@"
323
+ icon={<FileIcon path={value} size={14} className={TOKEN_ICON} />}
324
+ label={value}
325
+ tone={MENTION_TONE}
326
+ data-file-path={value}
327
+ data-file-directory={/[\\/]$/.test(value)}
328
+ />
312
329
  );
313
330
  }
314
331
 
315
- const messageComponents = {
316
- ...replyComponents,
317
- span({ children, 'data-chip': chip, 'data-value': value }: { children?: ReactNode; 'data-chip'?: string; 'data-value'?: string }) {
318
- if (chip === undefined || value === undefined) {
319
- return <span>{children}</span>;
320
- }
321
- return <Chip kind={chip} value={value} />;
332
+ type ChipSpanProps = ComponentProps<'span'> & ExtraProps & { 'data-chip'?: string; 'data-value'?: string };
333
+
334
+ /* A span the chip plugins made is a chip; any other, such as a fading word, stays the span it was. */
335
+ function ChipSpan({ children, node: _node, 'data-chip': chip, 'data-value': value, ...rest }: ChipSpanProps) {
336
+ if (chip === undefined || value === undefined) {
337
+ return <span {...rest}>{children}</span>;
322
338
  }
323
- };
339
+ return <Chip kind={chip} value={value} />;
340
+ }
324
341
 
325
342
  // Module constants, so a render never hands react-markdown a fresh array and makes it parse again.
326
343
  const PLUGINS = [remarkGfm];
@@ -332,12 +349,17 @@ const NO_PLUGINS: typeof FADE_PLUGINS = [];
332
349
 
333
350
  /* One block of a reply. It renders no element of its own, so prose keeps its first and last margins. */
334
351
  const ReplyBlock = memo(function ReplyBlock({ text, fade, open, reply }: { text: string; fade: boolean; open: boolean; reply?: ShellCodeBlockReply }) {
352
+ const { items, referenced } = useContext(ReferenceLookupContext);
353
+ // Chips first, so the fade wraps the words around a chip and never cuts a name in two.
354
+ const named: NamedReferenceOptions = { items, referenced };
355
+ const rehypePlugins: Options['rehypePlugins'] =
356
+ items.length === 0 ? (fade ? FADE_PLUGINS : NO_PLUGINS) : [[rehypeNamedReferences, named], ...(fade ? FADE_PLUGINS : [])];
335
357
  return (
336
358
  // A context rather than a second set of components. A component that changed would mount the
337
359
  // code block again the moment its fence closes, and it would lose the lines it already has.
338
360
  <CodeStreamingContext.Provider value={open}>
339
361
  <ShellCodeBlockReplyContext.Provider value={reply ?? null}>
340
- <ReactMarkdown remarkPlugins={REPLY_PLUGINS} rehypePlugins={fade ? FADE_PLUGINS : NO_PLUGINS} components={replyComponents}>
362
+ <ReactMarkdown remarkPlugins={REPLY_PLUGINS} rehypePlugins={rehypePlugins} components={replyComponents}>
341
363
  {text}
342
364
  </ReactMarkdown>
343
365
  </ShellCodeBlockReplyContext.Provider>
@@ -419,13 +441,23 @@ export const ReplyMarkdown = memo(function ReplyMarkdown({
419
441
 
420
442
  /*
421
443
  * A message a person sent. Enter meant a line break, a tag typed without backticks stays the text
422
- * it was, and the files and skills picked in the composer are chips again.
444
+ * it was, and the files, skills and references picked in the composer are chips again.
423
445
  */
424
- export const MessageMarkdown = memo(function MessageMarkdown({ text, mentions, skills }: { text: string; mentions?: string[]; skills?: string[] }) {
425
- const rehypePlugins: [typeof rehypeChips, ChipOptions][] = [[rehypeChips, { mentions, skills }]];
446
+ export const MessageMarkdown = memo(function MessageMarkdown({
447
+ text,
448
+ mentions,
449
+ skills,
450
+ references
451
+ }: {
452
+ text: string;
453
+ mentions?: string[];
454
+ skills?: string[];
455
+ references?: readonly ReferenceToken[];
456
+ }) {
457
+ const rehypePlugins: [typeof rehypeChips, ChipOptions][] = [[rehypeChips, { mentions, skills, references }]];
426
458
  return (
427
459
  <div className="chat-markdown prose prose-sm max-w-none text-sm">
428
- <ReactMarkdown remarkPlugins={MESSAGE_PLUGINS} rehypePlugins={rehypePlugins} components={messageComponents}>
460
+ <ReactMarkdown remarkPlugins={MESSAGE_PLUGINS} rehypePlugins={rehypePlugins} components={replyComponents}>
429
461
  {text}
430
462
  </ReactMarkdown>
431
463
  </div>
@@ -9,8 +9,10 @@ import { markdownOf, messageTextOf } from '../logic/timeline-copy';
9
9
  import { useChatHost } from '../../host';
10
10
  import { useChatScope } from '../../scope';
11
11
  import { useChatRow, useChats } from '../../state/chats';
12
- import { ButtonGroup, copyText, IconButton, Tooltip } from '@adecore/ui';
12
+ import { ButtonGroup, IconButton, Tooltip } from '@adecore/ui';
13
13
  import { formatClock, formatDateTime, useFormatLocale } from '@adecore/ui/format';
14
+ import { copyWithChips, messageClipboardChips } from './chip-clipboard';
15
+ import { useReferenceLookup } from './thread-references';
14
16
  import { TIMESTAMP_FORMAT } from './timestamp-format';
15
17
 
16
18
  const COPIED_MS = 1500;
@@ -40,6 +42,7 @@ export function MessageActions({ chatId, row }: { chatId: string; row: MessageRo
40
42
  const bookmarked = bookmarks?.some((candidate) => candidate.itemId === row.id) === true;
41
43
  const canFork = turnId !== null && forkRefusal(info, turn) === null;
42
44
  const [copied, setCopied] = useState(false);
45
+ const lookup = useReferenceLookup(chatId);
43
46
 
44
47
  useEffect(() => {
45
48
  if (!copied) {
@@ -55,7 +58,7 @@ export function MessageActions({ chatId, row }: { chatId: string; row: MessageRo
55
58
  const current = item?.kind === row.kind ? ({ ...row, item } as MessageRow) : row;
56
59
  const text = (plain ? null : markdownOf(current)) ?? messageTextOf(current);
57
60
  if (text !== null) {
58
- copyText(text);
61
+ copyWithChips(messageClipboardChips(current.item, text, lookup));
59
62
  setCopied(true);
60
63
  }
61
64
  };