@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.
- package/dist/chat/chat-client.d.ts +1 -0
- package/dist/chat/chat-client.js +2 -1
- package/dist/chat/chat-references.d.ts +16 -1
- package/dist/chat/chat-references.js +29 -3
- package/dist/chat/drafts.d.ts +2 -0
- package/dist/chat/drafts.js +21 -5
- package/dist/chat/index.d.ts +1 -1
- package/dist/chat/logic/visual-height.d.ts +1 -0
- package/dist/chat/logic/visual-height.js +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/HostMenuItems.d.ts +4 -0
- package/dist/chat/ui/HostMenuItems.js +15 -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/VisualFrame.d.ts +8 -0
- package/dist/chat/ui/VisualFrame.js +217 -115
- 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 +1 -1
- package/dist/chat/ui/index.js +1 -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/GeneratedImageRow.js +8 -4
- package/dist/chat/ui/rows/MessageRows.js +218 -158
- package/dist/chat/ui/rows/VisualRow.js +184 -142
- package/dist/chat/ui/rows/row-rhythm.js +6 -4
- 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 +14 -1
- package/dist/index.d.ts +1 -1
- package/dist/locales/en/agent-chat.d.ts +20 -3
- package/dist/locales/en/agent-chat.js +24 -7
- package/dist/locales/nl/agent-chat.d.ts +20 -3
- package/dist/locales/nl/agent-chat.js +24 -7
- package/package.json +8 -8
- package/src/chat/chat-client.ts +4 -1
- package/src/chat/chat-references.ts +44 -3
- package/src/chat/drafts.ts +24 -6
- package/src/chat/index.ts +1 -1
- package/src/chat/logic/visual-height.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/HostMenuItems.tsx +11 -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/VisualFrame.tsx +68 -21
- 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 +1 -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/GeneratedImageRow.tsx +9 -4
- package/src/chat/ui/rows/MessageRows.tsx +88 -64
- package/src/chat/ui/rows/VisualRow.tsx +5 -1
- package/src/chat/ui/rows/row-rhythm.ts +6 -4
- package/src/chat/ui/thread-references.ts +47 -0
- package/src/host-hooks.ts +9 -0
- package/src/host.ts +24 -1
- package/src/index.ts +1 -0
- package/src/locales/en/agent-chat.ts +24 -7
- package/src/locales/nl/agent-chat.ts +24 -7
- package/dist/chat/ui/ChatReferenceChip.d.ts +0 -4
- package/dist/chat/ui/ChatReferenceChip.js +0 -110
- package/src/chat/ui/ChatReferenceChip.tsx +0 -26
package/src/chat/mentions.ts
CHANGED
|
@@ -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
|
-
|
|
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) ||
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
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.
|
|
199
|
-
let index = findToken(text, token.sigil, token.
|
|
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.
|
|
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
|
-
|
|
217
|
-
|
|
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
|
|
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(
|
|
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
|
|
package/src/chat/ui/Composer.tsx
CHANGED
|
@@ -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,
|
|
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({
|
|
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
|
+
}
|
package/src/chat/ui/Markdown.tsx
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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
|
|
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
|
-
<
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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
|
-
<
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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={
|
|
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
|
|
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({
|
|
425
|
-
|
|
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={
|
|
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,
|
|
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
|
-
|
|
61
|
+
copyWithChips(messageClipboardChips(current.item, text, lookup));
|
|
59
62
|
setCopied(true);
|
|
60
63
|
}
|
|
61
64
|
};
|