@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
@@ -16,6 +16,7 @@ export interface ChatSendExtras {
16
16
  chats?: string[];
17
17
  quoteSource?: ChatQuoteSource;
18
18
  attachments?: ChatAttachmentUpload[];
19
+ references?: string[];
19
20
  }
20
21
  interface ProviderSink {
21
22
  setProviders(providers: ProviderInfo[]): void;
@@ -184,7 +184,8 @@ export class ChatClient {
184
184
  skills: extras.skills,
185
185
  chats: extras.chats,
186
186
  quoteSource: extras.quoteSource,
187
- attachments: extras.attachments
187
+ attachments: extras.attachments,
188
+ references: extras.references
188
189
  });
189
190
  }
190
191
  /* Marks a message; the list every client of the chat holds comes back as `chat.bookmarks`. */
@@ -1,5 +1,20 @@
1
+ import type { ReferenceToken } from './mentions.js';
1
2
  export interface ProjectChat {
2
3
  id: string;
3
4
  title: string;
4
5
  }
5
- export declare function chatSuggestions(chats: readonly ProjectChat[], query: string, exclude: readonly string[], limit: number): ProjectChat[];
6
+ export type ReferableKind = 'canvas' | 'chat' | 'terminal' | 'note' | 'text' | 'drawing' | 'diagram' | 'browser' | 'device' | 'file' | 'database';
7
+ export interface Referable {
8
+ id: string;
9
+ name: string;
10
+ kind: ReferableKind;
11
+ detail?: string;
12
+ group: 'canvas' | 'views';
13
+ }
14
+ export interface Referables {
15
+ items: readonly Referable[];
16
+ chatsOnly: boolean;
17
+ }
18
+ export declare function referablesOfChats(chats: readonly ProjectChat[]): Referables;
19
+ export declare function referenceTokens(ids: readonly string[], items: readonly Referable[], spelled?: Readonly<Record<string, string>>): ReferenceToken[];
20
+ export declare function referenceSuggestions(referables: Referables, query: string, exclude: readonly string[], limit: number): Referable[];
@@ -1,5 +1,31 @@
1
- /* The chats an `@query` offers: never the chat typing it or one already attached. */
2
- export function chatSuggestions(chats, query, exclude, limit) {
1
+ /* What a host without views offers: its chats, which every machine takes. */
2
+ export function referablesOfChats(chats) {
3
+ return {
4
+ items: chats.map(chat => ({
5
+ id: chat.id,
6
+ name: chat.title,
7
+ kind: 'chat',
8
+ group: 'views'
9
+ })),
10
+ chatsOnly: true
11
+ };
12
+ }
13
+ /*
14
+ * How each id is spelled in a text: under the name it was picked with where `spelled` knows it, else
15
+ * under the name it has now. An id with neither has no token.
16
+ */
17
+ export function referenceTokens(ids, items, spelled = {}) {
18
+ return [...new Set(ids)].flatMap(id => {
19
+ const name = spelled[id] ?? items.find(item => item.id === id)?.name;
20
+ return name === undefined || name === '' ? [] : [{
21
+ id,
22
+ name
23
+ }];
24
+ });
25
+ }
26
+ /* What a `#query` offers, the canvas first: never an id in `exclude`, and only chats where the machine takes no more. */
27
+ export function referenceSuggestions(referables, query, exclude, limit) {
3
28
  const needle = query.toLowerCase();
4
- return chats.filter(chat => !exclude.includes(chat.id) && chat.title.toLowerCase().includes(needle)).slice(0, limit);
29
+ const matches = referables.items.filter(item => item.name !== '' && (!referables.chatsOnly || item.kind === 'chat') && !exclude.includes(item.id) && item.name.toLowerCase().includes(needle));
30
+ return [...matches.filter(item => item.group === 'canvas'), ...matches.filter(item => item.group === 'views')].slice(0, limit);
5
31
  }
@@ -7,6 +7,8 @@ export interface ChatDraft {
7
7
  attachments: ChatAttachmentUpload[];
8
8
  quote: string;
9
9
  quoteSource: string | null;
10
+ references: string[];
11
+ referenceNames?: Record<string, string>;
10
12
  }
11
13
  export declare const EMPTY_DRAFT: ChatDraft;
12
14
  export declare function isEmptyDraft(draft: ChatDraft): boolean;
@@ -10,7 +10,8 @@ export const EMPTY_DRAFT = {
10
10
  chats: [],
11
11
  attachments: [],
12
12
  quote: '',
13
- quoteSource: null
13
+ quoteSource: null,
14
+ references: []
14
15
  };
15
16
  const storage = persistedJson(storageKey, raw => raw ? JSON.parse(raw) : {}, {}, () => chatStorageLegacyKeys('drafts'));
16
17
  export function isEmptyDraft(draft) {
@@ -58,7 +59,11 @@ export function readDraft(chatId) {
58
59
  chats: record.chats ?? [],
59
60
  attachments: record.attachments ?? [],
60
61
  quote: record.quote ?? '',
61
- quoteSource: record.quoteSource ?? null
62
+ quoteSource: record.quoteSource ?? null,
63
+ references: record.references ?? [],
64
+ ...(record.referenceNames === undefined ? {} : {
65
+ referenceNames: record.referenceNames
66
+ })
62
67
  } : EMPTY_DRAFT;
63
68
  }
64
69
  export function writeDraft(chatId, draft) {
@@ -78,7 +83,9 @@ export function writeDraft(chatId, draft) {
78
83
  quote: draft.quote,
79
84
  ...(draft.quoteSource === null ? {} : {
80
85
  quoteSource: draft.quoteSource
81
- })
86
+ }),
87
+ references: draft.references,
88
+ ...namesOf(draft)
82
89
  };
83
90
  if (storage.write(drafts)) {
84
91
  return;
@@ -92,10 +99,17 @@ export function writeDraft(chatId, draft) {
92
99
  quote: draft.quote,
93
100
  ...(draft.quoteSource === null ? {} : {
94
101
  quoteSource: draft.quoteSource
95
- })
102
+ }),
103
+ references: draft.references,
104
+ ...namesOf(draft)
96
105
  };
97
106
  storage.write(drafts);
98
107
  }
108
+ function namesOf(draft) {
109
+ return draft.referenceNames === undefined ? {} : {
110
+ referenceNames: draft.referenceNames
111
+ };
112
+ }
99
113
  /* Text handed to a draft from outside the composer goes under what was already typed, never over it. */
100
114
  export function joinDraftText(current, added) {
101
115
  return current.trim() === '' ? added : `${current.replace(/\s+$/, '')}\n\n${added}`;
@@ -123,7 +137,9 @@ export function takeBackIntoDraft(current, taken) {
123
137
  chats: union(taken.chats, current.chats),
124
138
  attachments: [...checked.accepted.map(entry => entry.upload), ...current.attachments],
125
139
  quote: current.quote,
126
- quoteSource: current.quoteSource
140
+ quoteSource: current.quoteSource,
141
+ references: union(taken.references, current.references),
142
+ ...namesOf(current)
127
143
  },
128
144
  rejected: checked.rejected
129
145
  };
@@ -1,7 +1,7 @@
1
1
  export { type AccountChoice, useAccountChoice, type NewAccountChoice, useNewAccountChoice } from './account-choice.js';
2
2
  export { transportActions, actionsOf, useChatActions } from './actions.js';
3
3
  export { type ChatSendExtras, ChatClient } from './chat-client.js';
4
- export type { ProjectChat } from './chat-references.js';
4
+ export type { ProjectChat, Referable, ReferableKind, Referables } from './chat-references.js';
5
5
  export { sendFromComposer, type ComposerSend } from './composer-send.js';
6
6
  export { stepEffortFromComposer } from './composer-effort.js';
7
7
  export type { EffortStep } from './logic/effort.js';
@@ -4,10 +4,25 @@ export declare const MENTION_DRAG_TYPES: readonly ["application/x-adecore-mentio
4
4
  export declare function carriesMentions(types: readonly string[]): boolean;
5
5
  export declare function droppedMentions(data: Pick<DataTransfer, 'getData'>): string[];
6
6
  export declare function writeMentionDrag(data: Pick<DataTransfer, 'setData'>, paths: readonly string[], legacy?: boolean): void;
7
+ export declare const CHIPS_CLIPBOARD_TYPE = "application/x-adecore-chips";
8
+ export interface ClipboardChips {
9
+ text: string;
10
+ mentions: string[];
11
+ skills: string[];
12
+ references: ReferenceToken[];
13
+ }
14
+ export declare function clipboardChipsOf(text: string, segments: readonly ChipSegment[]): ClipboardChips;
15
+ export declare function hasClipboardChips(chips: ClipboardChips): boolean;
16
+ export declare function writeClipboardChips(data: Pick<DataTransfer, 'setData'>, chips: ClipboardChips): void;
17
+ export declare function readClipboardChips(data: Pick<DataTransfer, 'getData'>): ClipboardChips | null;
7
18
  export interface MentionQuery {
8
19
  start: number;
9
20
  query: string;
10
21
  }
22
+ export interface ReferenceToken {
23
+ id: string;
24
+ name: string;
25
+ }
11
26
  export type ChipSegment = {
12
27
  kind: 'text';
13
28
  text: string;
@@ -17,22 +32,28 @@ export type ChipSegment = {
17
32
  } | {
18
33
  kind: 'skill';
19
34
  name: string;
35
+ } | {
36
+ kind: 'reference';
37
+ id: string;
38
+ name: string;
20
39
  };
40
+ export declare function queryOutsideChips(query: MentionQuery | null, chips: readonly ChipRange[]): MentionQuery | null;
21
41
  export declare function findMentionQuery(text: string, caret: number): MentionQuery | null;
22
42
  export declare function findSkillQuery(text: string, caret: number): MentionQuery | null;
43
+ export declare function findReferenceQuery(text: string, caret: number): MentionQuery | null;
23
44
  export declare function insertToken(text: string, query: MentionQuery, sigil: string, value: string): {
24
45
  text: string;
25
46
  caret: number;
26
47
  };
27
- export declare function dropQuery(text: string, query: MentionQuery): {
48
+ export declare function insertMention(text: string, query: MentionQuery, path: string): {
28
49
  text: string;
29
50
  caret: number;
30
51
  };
31
- export declare function insertMention(text: string, query: MentionQuery, path: string): {
52
+ export declare function insertSkill(text: string, query: MentionQuery, name: string): {
32
53
  text: string;
33
54
  caret: number;
34
55
  };
35
- export declare function insertSkill(text: string, query: MentionQuery, name: string): {
56
+ export declare function insertReference(text: string, query: MentionQuery, name: string): {
36
57
  text: string;
37
58
  caret: number;
38
59
  };
@@ -41,16 +62,10 @@ export interface MentionSearch {
41
62
  files: string[];
42
63
  }
43
64
  export declare const NO_MENTION_SEARCH: MentionSearch;
44
- export type MentionPick<T> = {
45
- kind: 'chat';
46
- chat: T;
47
- } | {
48
- kind: 'file';
49
- path: string;
50
- };
51
- export declare function mentionPick<T>(index: number, chats: readonly T[], search: MentionSearch, query: string): MentionPick<T> | null;
65
+ export declare function mentionPick(index: number, search: MentionSearch, query: string): string | null;
52
66
  export declare function presentMentions(text: string, chosen: string[]): string[];
53
67
  export declare function presentSkills(text: string, chosen: string[]): string[];
68
+ export declare function presentReferences(text: string, ids: readonly string[], tokens: readonly ReferenceToken[]): string[];
54
69
  export declare function chipText(segment: ChipSegment): string;
55
70
  export declare function pastedMentions(text: string): string[];
56
71
  export interface TextRange {
@@ -58,8 +73,10 @@ export interface TextRange {
58
73
  to: number;
59
74
  }
60
75
  export interface ChipRange extends TextRange {
61
- kind: 'mention' | 'skill';
76
+ kind: 'mention' | 'skill' | 'reference';
62
77
  value: string;
63
78
  }
64
- export declare function chipRanges(text: string, mentions: string[], skills?: string[], excluded?: readonly TextRange[]): ChipRange[];
65
- export declare function tokenizeChips(text: string, mentions: string[], skills?: string[]): ChipSegment[];
79
+ export declare function chipRanges(text: string, mentions: string[], skills?: string[], excluded?: readonly TextRange[], references?: readonly ReferenceToken[]): ChipRange[];
80
+ export declare function tokenizeChips(text: string, mentions: string[], skills?: string[], references?: readonly ReferenceToken[]): ChipSegment[];
81
+ export declare function namedReferenceRanges(text: string, items: readonly ReferenceToken[], referenced?: readonly string[], excluded?: readonly TextRange[]): ChipRange[];
82
+ export declare function tokenizeNamedReferences(text: string, items: readonly ReferenceToken[], referenced?: readonly string[]): ChipSegment[];
@@ -19,17 +19,91 @@ export function writeMentionDrag(data, paths, legacy = true) {
19
19
  data.setData(LEGACY_MENTION_DRAG_TYPE, text);
20
20
  }
21
21
  }
22
+ /* Beside the plain text of a copy from a thread or a composer: the chips in it, so a paste makes them chips again. */
23
+ export const CHIPS_CLIPBOARD_TYPE = 'application/x-adecore-chips';
24
+ /* The chips among `segments`, without doubles, for the copy of `text`. */
25
+ export function clipboardChipsOf(text, segments) {
26
+ const chips = {
27
+ text,
28
+ mentions: [],
29
+ skills: [],
30
+ references: []
31
+ };
32
+ for (const segment of segments) {
33
+ if (segment.kind === 'mention' && !chips.mentions.includes(segment.path)) {
34
+ chips.mentions.push(segment.path);
35
+ } else if (segment.kind === 'skill' && !chips.skills.includes(segment.name)) {
36
+ chips.skills.push(segment.name);
37
+ } else if (segment.kind === 'reference' && !chips.references.some(token => token.id === segment.id)) {
38
+ chips.references.push({
39
+ id: segment.id,
40
+ name: segment.name
41
+ });
42
+ }
43
+ }
44
+ return chips;
45
+ }
46
+ export function hasClipboardChips(chips) {
47
+ return chips.mentions.length > 0 || chips.skills.length > 0 || chips.references.length > 0;
48
+ }
49
+ export function writeClipboardChips(data, chips) {
50
+ data.setData('text/plain', chips.text);
51
+ data.setData(CHIPS_CLIPBOARD_TYPE, JSON.stringify(chips));
52
+ }
53
+ const lines = text => text.replace(/\r\n/g, '\n');
54
+ const isStrings = value => Array.isArray(value) && value.every(entry => typeof entry === 'string');
55
+ /* The chips of a paste, or null when it has none or its plain text is no longer the text they were copied with. */
56
+ export function readClipboardChips(data) {
57
+ const raw = data.getData(CHIPS_CLIPBOARD_TYPE);
58
+ if (raw === '') {
59
+ return null;
60
+ }
61
+ let parsed;
62
+ try {
63
+ parsed = JSON.parse(raw);
64
+ } catch {
65
+ return null;
66
+ }
67
+ if (typeof parsed !== 'object' || parsed === null) {
68
+ return null;
69
+ }
70
+ const {
71
+ text,
72
+ mentions,
73
+ skills,
74
+ references
75
+ } = parsed;
76
+ const tokens = Array.isArray(references) ? references.filter(token => typeof token === 'object' && token !== null && typeof token.id === 'string' && typeof token.name === 'string' && token.name !== '') : [];
77
+ if (typeof text !== 'string' || lines(text) !== lines(data.getData('text/plain')) || !isStrings(mentions) || !isStrings(skills)) {
78
+ return null;
79
+ }
80
+ return {
81
+ text: lines(text),
82
+ mentions,
83
+ skills,
84
+ references: tokens.map(({
85
+ id,
86
+ name
87
+ }) => ({
88
+ id,
89
+ name
90
+ }))
91
+ };
92
+ }
22
93
  function isBoundary(char) {
23
94
  return char === undefined || /\s/.test(char);
24
95
  }
96
+ const TOKEN_END = /[.,;:!?)\]}"'”’]/;
25
97
  // A path at the end of a sentence still counts, so "open @a.ts." is a mention of a.ts.
26
98
  function isTokenEnd(char) {
27
- return isBoundary(char) || /[.,;:!?)]/.test(char);
99
+ return isBoundary(char) || TOKEN_END.test(char);
28
100
  }
101
+ // A path goes on after a dot or a colon (`a.ts`, `C:`), so only the rest ends a file query.
102
+ const PATH_GOES_ON = /[.:]$/;
29
103
  /*
30
104
  * The `<sigil>word` the caret sits in, if any. The sigil has to open a word (start of text or after
31
- * whitespace) so an email address never opens the picker, and the word may not have been
32
- * closed with whitespace yet.
105
+ * whitespace) so an email address never opens the picker, and the word may not have been closed
106
+ * with whitespace or the punctuation that ends a token (`#Admin?`) yet.
33
107
  */
34
108
  function findQuery(text, caret, sigil) {
35
109
  const before = text.slice(0, caret);
@@ -41,11 +115,18 @@ function findQuery(text, caret, sigil) {
41
115
  if (/\s/.test(query) || !isBoundary(text[caret])) {
42
116
  return null;
43
117
  }
118
+ if (query !== '' && isTokenEnd(query.at(-1)) && !(sigil === '@' && PATH_GOES_ON.test(query))) {
119
+ return null;
120
+ }
44
121
  return {
45
122
  start: at,
46
123
  query
47
124
  };
48
125
  }
126
+ /* The query, unless its sigil opens a token that is already a chip, which the picker has nothing to add to. */
127
+ export function queryOutsideChips(query, chips) {
128
+ return query !== null && chips.some(chip => chip.from === query.start) ? null : query;
129
+ }
49
130
  export function findMentionQuery(text, caret) {
50
131
  return findQuery(text, caret, '@');
51
132
  }
@@ -54,6 +135,14 @@ export function findSkillQuery(text, caret) {
54
135
  const found = findQuery(text, caret, '$');
55
136
  return found === null || found.query !== '' && !/^[A-Za-z]/.test(found.query) ? null : found;
56
137
  }
138
+ /*
139
+ * The `#word` the caret sits in. It opens only once a letter follows the sigil, so a `# ` heading,
140
+ * `#123` and a bare `#` stay text. A name with a space is found by its first word.
141
+ */
142
+ export function findReferenceQuery(text, caret) {
143
+ const found = findQuery(text, caret, '#');
144
+ return found === null || !/^\p{L}/u.test(found.query) ? null : found;
145
+ }
57
146
  /* Replaces the `<sigil>query` under the caret with the chosen token and a space, and says where the caret goes. */
58
147
  export function insertToken(text, query, sigil, value) {
59
148
  const end = query.start + 1 + query.query.length;
@@ -63,40 +152,25 @@ export function insertToken(text, query, sigil, value) {
63
152
  caret: query.start + token.length
64
153
  };
65
154
  }
66
- /* Takes the `<sigil>query` under the caret out, for a pick that lands beside the text instead of in it. */
67
- export function dropQuery(text, query) {
68
- return {
69
- text: `${text.slice(0, query.start)}${text.slice(query.start + 1 + query.query.length)}`,
70
- caret: query.start
71
- };
72
- }
73
155
  export function insertMention(text, query, path) {
74
156
  return insertToken(text, query, '@', path);
75
157
  }
76
158
  export function insertSkill(text, query, name) {
77
159
  return insertToken(text, query, '$', name);
78
160
  }
161
+ export function insertReference(text, query, name) {
162
+ return insertToken(text, query, '#', name);
163
+ }
79
164
  export const NO_MENTION_SEARCH = {
80
165
  query: null,
81
166
  files: []
82
167
  };
83
- /* What Enter or Tab takes from the `@` list at `index`. Files still on screen from an older query are never taken. */
84
- export function mentionPick(index, chats, search, query) {
85
- const chat = chats[index];
86
- if (chat !== undefined) {
87
- return {
88
- kind: 'chat',
89
- chat
90
- };
91
- }
168
+ /* The path Enter or Tab takes from the `@` list at `index`. Files still on screen from an older query are never taken. */
169
+ export function mentionPick(index, search, query) {
92
170
  if (search.query !== query) {
93
171
  return null;
94
172
  }
95
- const path = search.files[index - chats.length] ?? search.files[0];
96
- return path === undefined ? null : {
97
- kind: 'file',
98
- path
99
- };
173
+ return search.files[index] ?? search.files[0] ?? null;
100
174
  }
101
175
  function findToken(text, sigil, value, from) {
102
176
  const token = `${sigil}${value}`;
@@ -130,6 +204,11 @@ export function presentMentions(text, chosen) {
130
204
  export function presentSkills(text, chosen) {
131
205
  return present(text, '$', chosen);
132
206
  }
207
+ /* The ids among `ids` whose `#name` still sits in the text as a whole token, in text order and without doubles. */
208
+ export function presentReferences(text, ids, tokens) {
209
+ const wanted = new Set(ids);
210
+ return chipRanges(text, [], [], [], tokens).map(range => range.value).filter((id, index, found) => wanted.has(id) && found.indexOf(id) === index);
211
+ }
133
212
  /* The characters a segment stands for, sigil included; joined, the segments spell the text they were cut from. */
134
213
  export function chipText(segment) {
135
214
  if (segment.kind === 'mention') {
@@ -138,6 +217,9 @@ export function chipText(segment) {
138
217
  if (segment.kind === 'skill') {
139
218
  return `$${segment.name}`;
140
219
  }
220
+ if (segment.kind === 'reference') {
221
+ return `#${segment.name}`;
222
+ }
141
223
  return segment.text;
142
224
  }
143
225
  /*
@@ -152,29 +234,36 @@ export function pastedMentions(text) {
152
234
  return [...new Set(tokens.map(token => token.slice(1)))];
153
235
  }
154
236
  /*
155
- * Where the chosen mentions and skills sit in the text, in text order; longer values win when one
156
- * prefixes another. A token that overlaps an excluded range (a code span, say) is text.
237
+ * Where the chosen mentions, skills and references sit in the text, in text order; longer values win
238
+ * when one prefixes another. A token that overlaps an excluded range (a code span, say) is text.
157
239
  */
158
- export function chipRanges(text, mentions, skills = [], excluded = []) {
240
+ export function chipRanges(text, mentions, skills = [], excluded = [], references = []) {
159
241
  const tokens = [...[...new Set(mentions)].map(value => ({
160
242
  sigil: '@',
161
243
  kind: 'mention',
244
+ spelled: value,
162
245
  value
163
246
  })), ...[...new Set(skills)].map(value => ({
164
247
  sigil: '$',
165
248
  kind: 'skill',
249
+ spelled: value,
166
250
  value
167
- }))].sort((a, b) => b.value.length - a.value.length);
251
+ })), ...references.filter(token => token.name !== '').map(token => ({
252
+ sigil: '#',
253
+ kind: 'reference',
254
+ spelled: token.name,
255
+ value: token.id
256
+ }))].sort((a, b) => b.spelled.length - a.spelled.length);
168
257
  const allowed = (from, to) => !excluded.some(range => from < range.to && to > range.from);
169
258
  const ranges = [];
170
259
  let cursor = 0;
171
260
  while (cursor < text.length) {
172
261
  let best = null;
173
262
  for (const token of tokens) {
174
- const length = token.value.length + 1;
175
- let index = findToken(text, token.sigil, token.value, cursor);
263
+ const length = token.spelled.length + 1;
264
+ let index = findToken(text, token.sigil, token.spelled, cursor);
176
265
  while (index >= 0 && !allowed(index, index + length)) {
177
- index = findToken(text, token.sigil, token.value, index + 1);
266
+ index = findToken(text, token.sigil, token.spelled, index + 1);
178
267
  }
179
268
  if (index >= 0 && (best === null || index < best.from)) {
180
269
  best = {
@@ -193,24 +282,80 @@ export function chipRanges(text, mentions, skills = [], excluded = []) {
193
282
  }
194
283
  return ranges;
195
284
  }
196
- /* Splits the text so a renderer can draw the mentions and skills as chips. */
197
- export function tokenizeChips(text, mentions, skills = []) {
285
+ function segmentOf(text, range) {
286
+ if (range.kind === 'mention') {
287
+ return {
288
+ kind: 'mention',
289
+ path: range.value
290
+ };
291
+ }
292
+ if (range.kind === 'skill') {
293
+ return {
294
+ kind: 'skill',
295
+ name: range.value
296
+ };
297
+ }
298
+ return {
299
+ kind: 'reference',
300
+ id: range.value,
301
+ name: text.slice(range.from + 1, range.to)
302
+ };
303
+ }
304
+ /* Splits the text so a renderer can draw the mentions, skills and references as chips. */
305
+ export function tokenizeChips(text, mentions, skills = [], references = []) {
306
+ return segmentsOf(text, chipRanges(text, mentions, skills, [], references));
307
+ }
308
+ // What a `#` may follow in a text nobody typed through the picker: a space, or an opening bracket or quote.
309
+ const REFERENCE_OPENER = /[\s([{"'“‘]/;
310
+ /*
311
+ * Where a text written without the picker, such as a reply, spells `#Name` for a view, node or chat
312
+ * by exactly its name. The longest name wins, so a name with a space is found whole. A name more
313
+ * than one of them carries stays text, unless one of those is in `referenced`, which then wins.
314
+ */
315
+ export function namedReferenceRanges(text, items, referenced = [], excluded = []) {
316
+ const byName = new Map();
317
+ for (const item of items) {
318
+ if (item.name !== '') {
319
+ byName.set(item.name, [...(byName.get(item.name) ?? []), item.id]);
320
+ }
321
+ }
322
+ const names = [...byName.keys()].sort((a, b) => b.length - a.length);
323
+ const preferred = new Set(referenced);
324
+ const ranges = [];
325
+ let index = text.indexOf('#');
326
+ while (index >= 0) {
327
+ const from = index;
328
+ const name = from > 0 && !REFERENCE_OPENER.test(text[from - 1]) ? undefined : names.find(candidate => text.startsWith(candidate, from + 1) && isTokenEnd(text[from + 1 + candidate.length]));
329
+ const to = name === undefined ? from + 1 : from + 1 + name.length;
330
+ const ids = name === undefined ? [] : byName.get(name);
331
+ const id = ids.find(candidate => preferred.has(candidate)) ?? (ids.length === 1 ? ids[0] : undefined);
332
+ if (id !== undefined && !excluded.some(range => from < range.to && to > range.from)) {
333
+ ranges.push({
334
+ from,
335
+ to,
336
+ kind: 'reference',
337
+ value: id
338
+ });
339
+ }
340
+ index = text.indexOf('#', to);
341
+ }
342
+ return ranges;
343
+ }
344
+ /* `namedReferenceRanges` as segments, for a renderer. */
345
+ export function tokenizeNamedReferences(text, items, referenced = []) {
346
+ return segmentsOf(text, namedReferenceRanges(text, items, referenced));
347
+ }
348
+ function segmentsOf(text, ranges) {
198
349
  const segments = [];
199
350
  let cursor = 0;
200
- for (const range of chipRanges(text, mentions, skills)) {
351
+ for (const range of ranges) {
201
352
  if (range.from > cursor) {
202
353
  segments.push({
203
354
  kind: 'text',
204
355
  text: text.slice(cursor, range.from)
205
356
  });
206
357
  }
207
- segments.push(range.kind === 'mention' ? {
208
- kind: 'mention',
209
- path: range.value
210
- } : {
211
- kind: 'skill',
212
- name: range.value
213
- });
358
+ segments.push(segmentOf(text, range));
214
359
  cursor = range.to;
215
360
  }
216
361
  if (cursor < text.length) {
@@ -15,6 +15,7 @@ export interface StashedPrompt {
15
15
  attachments: StashedAttachment[];
16
16
  quote?: StashedQuote;
17
17
  createdAt: number;
18
+ references: string[];
18
19
  }
19
20
  export interface StashedQuote {
20
21
  text: string;
@@ -57,7 +57,8 @@ export function parseStash(raw) {
57
57
  chats: strings(entry.chats),
58
58
  attachments: attachments(entry.attachments),
59
59
  ...stashedQuoteField(entry.quote),
60
- createdAt: typeof entry.createdAt === 'number' ? entry.createdAt : 0
60
+ createdAt: typeof entry.createdAt === 'number' ? entry.createdAt : 0,
61
+ references: strings(entry.references)
61
62
  })).slice(0, STASH_LIMIT);
62
63
  } catch {
63
64
  return [];
@@ -94,7 +95,8 @@ export function stashedFrom(draft, id, now, chatId) {
94
95
  mime: attachment.mime,
95
96
  size: uploadBytes(attachment)
96
97
  })),
97
- createdAt: now
98
+ createdAt: now,
99
+ references: [...draft.references]
98
100
  };
99
101
  }
100
102
  /* The limit of `chats` in a send. */