@ahggg/dsh-side-chat 0.7.3 → 1.0.1

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 (80) hide show
  1. package/README.md +52 -20
  2. package/README.zh-CN.md +52 -20
  3. package/lib/client/apply.js +48 -32
  4. package/lib/client/contracts.d.ts +3 -58
  5. package/lib/client/dsh/ReadOnlyConversation.d.ts +10 -0
  6. package/lib/client/dsh/ReadOnlyConversation.js +99 -0
  7. package/lib/client/dsh/SideChatOverlay.d.ts +7 -0
  8. package/lib/client/{rc6/Rc6SideChatOverlay.js → dsh/SideChatOverlay.js} +26 -56
  9. package/lib/client/{rc6 → dsh}/context.d.ts +4 -6
  10. package/lib/client/dsh/conversation-store.d.ts +22 -0
  11. package/lib/client/dsh/conversation-store.js +19 -0
  12. package/lib/client/dsh/primitive-labels.d.ts +2 -0
  13. package/lib/client/dsh/primitive-labels.js +4 -0
  14. package/lib/client/{rc6 → dsh}/remote-adapter.d.ts +2 -2
  15. package/lib/client/dsh/remote-adapter.js +22 -0
  16. package/lib/client/dsh/sessions-adapter.d.ts +56 -0
  17. package/lib/client/dsh/sessions-adapter.js +185 -0
  18. package/lib/client/index.d.ts +2 -2
  19. package/lib/client/panel/SelectionQuote.js +8 -1
  20. package/lib/client/panel/SideChatErrorState.js +1 -1
  21. package/lib/client/panel/SideChatModelSelect.d.ts +2 -1
  22. package/lib/client/panel/SideChatModelSelect.js +43 -24
  23. package/lib/client/panel/SideChatPanel.js +12 -5
  24. package/lib/client/panel/messages.d.ts +1 -1
  25. package/lib/client/panel/messages.js +8 -8
  26. package/lib/client/parent-composer/ParentConversationAnnotations.d.ts +3 -2
  27. package/lib/client/parent-composer/ParentConversationAnnotations.js +50 -30
  28. package/lib/client/parent-composer/add-to-conversation.d.ts +14 -28
  29. package/lib/client/parent-composer/add-to-conversation.js +131 -545
  30. package/lib/client/parent-composer/annotation-persistence.d.ts +8 -8
  31. package/lib/client/parent-composer/annotation-persistence.js +106 -102
  32. package/lib/client/parent-composer/composer-reference.d.ts +17 -55
  33. package/lib/client/parent-composer/composer-reference.js +20 -72
  34. package/lib/client/parent-composer/referenced-conversation.d.ts +5 -7
  35. package/lib/client/parent-composer/referenced-conversation.js +21 -73
  36. package/lib/client/selection/ConversationAnnotationMarkers.js +5 -1
  37. package/lib/client/selection/SelectionActions.js +2 -1
  38. package/lib/client/selection/conversation-dom.d.ts +5 -0
  39. package/lib/client/selection/conversation-dom.js +24 -0
  40. package/lib/client/selection/selection-controller.d.ts +1 -1
  41. package/lib/client/selection/selection-controller.js +7 -3
  42. package/lib/client/selection/selection-normalizer.js +1 -1
  43. package/lib/client/side-chat-controller.d.ts +28 -41
  44. package/lib/client/side-chat-controller.js +247 -337
  45. package/lib/client.js +84 -54
  46. package/lib/host/index.d.ts +1 -1
  47. package/lib/host/index.js +1 -1
  48. package/lib/host/read-only-chat-service.d.ts +30 -0
  49. package/lib/host/read-only-chat-service.js +315 -0
  50. package/lib/index.d.ts +16 -8
  51. package/lib/index.js +34 -13
  52. package/lib/remote.d.ts +23 -14
  53. package/lib/remote.js +2 -5
  54. package/lib/shared/constants.d.ts +0 -13
  55. package/lib/shared/constants.js +0 -13
  56. package/lib/shared/contracts.d.ts +52 -19
  57. package/lib/shared/contracts.js +2 -3
  58. package/lib/shared/error-codes.d.ts +1 -1
  59. package/lib/shared/error-codes.js +2 -2
  60. package/lib/shared/{archived-wire.d.ts → side-chat-wire.d.ts} +109 -20
  61. package/lib/shared/side-chat-wire.js +34 -0
  62. package/lib/typert.d.ts +3 -68
  63. package/lib/typert.js +60 -4
  64. package/package.json +76 -56
  65. package/lib/client/rc6/ArchivedConversation.d.ts +0 -14
  66. package/lib/client/rc6/ArchivedConversation.js +0 -231
  67. package/lib/client/rc6/Rc6SideChatOverlay.d.ts +0 -7
  68. package/lib/client/rc6/SideChatTool.d.ts +0 -22
  69. package/lib/client/rc6/SideChatTool.js +0 -404
  70. package/lib/client/rc6/primitive-labels.d.ts +0 -7
  71. package/lib/client/rc6/primitive-labels.js +0 -60
  72. package/lib/client/rc6/remote-adapter.js +0 -34
  73. package/lib/client/rc6/runtime-compat.d.ts +0 -120
  74. package/lib/client/rc6/runtime-compat.js +0 -187
  75. package/lib/client/rc6/sessions-adapter.d.ts +0 -64
  76. package/lib/client/rc6/sessions-adapter.js +0 -387
  77. package/lib/host/archived-fork-service.d.ts +0 -28
  78. package/lib/host/archived-fork-service.js +0 -309
  79. package/lib/shared/archived-wire.js +0 -47
  80. /package/lib/client/{rc6 → dsh}/context.js +0 -0
@@ -1,38 +1,19 @@
1
- import { draftWithoutOccurrence, LEGACY_REFERENCE_PLACEHOLDER, newlyInsertedOccurrence, occurrenceEditSpan, occurrenceMatchesDraft, occurrenceRange, referenceDisplayText, } from './composer-reference.js';
2
- const SELECTION_REFERENCE_SOURCE = 'dsh-side-chat-selection';
1
+ import { newlyInsertedOccurrence, occurrenceEditSpan, occurrenceMatchesDraft } from './composer-reference.js';
2
+ export const SELECTION_REFERENCE_SOURCE = 'dsh-side-chat-selection';
3
3
  export const SELECTION_REFERENCE_LABEL = '__dsh_side_chat_annotations__';
4
4
  function escapeXmlText(value) {
5
- return value
6
- .replaceAll('&', '&')
7
- .replaceAll('<', '&lt;')
8
- .replaceAll('>', '&gt;');
5
+ return value.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;');
9
6
  }
10
- function annotationFromSelection(selection, comment) {
11
- const trimmedComment = comment?.trim();
12
- return {
13
- text: selection.text,
14
- selection,
15
- ...(trimmedComment === undefined || trimmedComment.length === 0
16
- ? {}
17
- : { comment: trimmedComment }),
18
- };
19
- }
20
- function encodeSelectionReference(annotations) {
21
- return JSON.stringify({
22
- version: 2,
23
- annotations,
24
- });
7
+ export function unescapeXmlText(value) {
8
+ return value.replaceAll('&lt;', '<').replaceAll('&gt;', '>').replaceAll('&amp;', '&');
25
9
  }
26
10
  function isSelection(value) {
27
11
  if (typeof value !== 'object' || value === null)
28
12
  return false;
29
13
  const selection = value;
30
- if (typeof selection.parentSessionId !== 'string'
31
- || typeof selection.text !== 'string'
32
- || !Number.isSafeInteger(selection.atSeq)
33
- || !Array.isArray(selection.fragments)
34
- || typeof selection.rect !== 'object'
35
- || selection.rect === null)
14
+ if (typeof selection.parentSessionId !== 'string' || typeof selection.text !== 'string'
15
+ || !Number.isSafeInteger(selection.atSeq) || !Array.isArray(selection.fragments)
16
+ || typeof selection.rect !== 'object' || selection.rect === null)
36
17
  return false;
37
18
  const rect = selection.rect;
38
19
  if (![rect.x, rect.y, rect.width, rect.height, rect.viewportWidth, rect.viewportHeight]
@@ -42,79 +23,38 @@ function isSelection(value) {
42
23
  if (typeof candidate !== 'object' || candidate === null)
43
24
  return false;
44
25
  const fragment = candidate;
45
- return typeof fragment.nodeKey === 'string'
46
- && typeof fragment.nodeKind === 'string'
47
- && typeof fragment.turnKey === 'string'
48
- && Number.isSafeInteger(fragment.seq)
49
- && Number.isSafeInteger(fragment.startOffset)
50
- && Number.isSafeInteger(fragment.endOffset)
26
+ return typeof fragment.nodeKey === 'string' && typeof fragment.nodeKind === 'string'
27
+ && typeof fragment.turnKey === 'string' && Number.isSafeInteger(fragment.seq)
28
+ && Number.isSafeInteger(fragment.startOffset) && Number.isSafeInteger(fragment.endOffset)
51
29
  && typeof fragment.text === 'string'
52
30
  && ['user', 'assistant', 'context', 'code'].includes(fragment.source ?? '')
53
- && typeof fragment.modelVisible === 'boolean'
54
- && typeof fragment.settled === 'boolean';
31
+ && typeof fragment.modelVisible === 'boolean' && typeof fragment.settled === 'boolean';
55
32
  });
56
33
  }
57
- function isStoredAnnotation(value) {
58
- if (typeof value !== 'object' || value === null)
59
- return false;
60
- const annotation = value;
61
- return typeof annotation.text === 'string'
62
- && (annotation.comment === undefined || typeof annotation.comment === 'string')
63
- && (annotation.selection === undefined || isSelection(annotation.selection));
64
- }
65
- function visibleAnnotation(annotation) {
66
- return {
67
- text: annotation.text,
68
- ...(annotation.comment === undefined ? {} : { comment: annotation.comment }),
69
- };
70
- }
71
34
  function decodeStoredSelectionReference(ref) {
72
- const value = JSON.parse(ref);
73
- // Accept the one-selection payload written by the first Add-to-chat build.
74
- if (isStoredAnnotation(value))
75
- return [value];
76
- if (typeof value !== 'object' || value === null) {
77
- throw new Error('The selected conversation annotation is no longer valid.');
78
- }
79
- const payload = value;
80
- if ((payload.version !== 1 && payload.version !== 2)
81
- || !Array.isArray(payload.annotations)
82
- || payload.annotations.length === 0
83
- || !payload.annotations.every(isStoredAnnotation)) {
35
+ const payload = JSON.parse(ref);
36
+ if (payload === null || payload.version !== 2 || !Array.isArray(payload.annotations)
37
+ || payload.annotations.length === 0 || !payload.annotations.every((value) => {
38
+ if (typeof value !== 'object' || value === null)
39
+ return false;
40
+ const annotation = value;
41
+ return typeof annotation.text === 'string' && isSelection(annotation.selection)
42
+ && (annotation.comment === undefined || typeof annotation.comment === 'string');
43
+ }))
84
44
  throw new Error('The selected conversation annotation is no longer valid.');
85
- }
86
45
  return payload.annotations;
87
46
  }
47
+ function visibleAnnotation(annotation) {
48
+ return { text: annotation.text, ...(annotation.comment === undefined ? {} : { comment: annotation.comment }) };
49
+ }
88
50
  export function decodeSelectionReference(ref) {
89
51
  return decodeStoredSelectionReference(ref).map(visibleAnnotation);
90
52
  }
91
- function selectedContext(annotations) {
92
- return [
93
- '<selected_context>',
94
- ...annotations.flatMap((annotation, index) => annotation.comment === undefined
95
- ? [
96
- `<annotation index="${String(index + 1)}">`,
97
- escapeXmlText(annotation.text),
98
- '</annotation>',
99
- ]
100
- : [
101
- `<annotation index="${String(index + 1)}">`,
102
- '<selected_text>',
103
- escapeXmlText(annotation.text),
104
- '</selected_text>',
105
- '<user_comment>',
106
- escapeXmlText(annotation.comment),
107
- '</user_comment>',
108
- '</annotation>',
109
- ]),
110
- '</selected_context>',
111
- ].join('\n');
112
- }
113
53
  function selectionOccurrences(snapshot) {
114
54
  return snapshot.occurrences.filter(occurrence => occurrence.source === SELECTION_REFERENCE_SOURCE);
115
55
  }
116
- function storedConversationAnnotations(snapshot) {
117
- return selectionOccurrences(snapshot).flatMap((occurrence) => {
56
+ function storedAnnotations(snapshot) {
57
+ return selectionOccurrences(snapshot).flatMap(occurrence => {
118
58
  try {
119
59
  return [...decodeStoredSelectionReference(occurrence.ref)];
120
60
  }
@@ -123,497 +63,143 @@ function storedConversationAnnotations(snapshot) {
123
63
  }
124
64
  });
125
65
  }
126
- /** Read all plugin annotations represented by the current DSH input occurrence. */
127
66
  export function conversationAnnotations(snapshot) {
128
- return storedConversationAnnotations(snapshot).map(visibleAnnotation);
67
+ return storedAnnotations(snapshot).map(visibleAnnotation);
129
68
  }
130
- /** Read annotations that retain an exact source-selection anchor. */
131
69
  export function conversationSelectionAnnotations(snapshot) {
132
- const anchored = [];
133
- storedConversationAnnotations(snapshot).forEach((annotation, annotationIndex) => {
134
- if (annotation.selection === undefined)
135
- return;
136
- anchored.push({
137
- ...visibleAnnotation(annotation),
138
- annotationIndex,
139
- selection: annotation.selection,
140
- });
141
- });
142
- return anchored;
143
- }
144
- function draftWithoutSelectionOccurrences(snapshot) {
145
- const occurrences = [...selectionOccurrences(snapshot)].sort((a, b) => b.offset - a.offset);
146
- let current = snapshot;
147
- for (const occurrence of occurrences) {
148
- const draft = draftWithoutOccurrence(current, occurrence, SELECTION_REFERENCE_LABEL);
149
- if (draft === undefined)
150
- continue;
151
- current = { ...current, draft };
152
- }
153
- let draft = current.draft;
154
- // Add to chat owns the separator immediately after its leading occurrence.
155
- if (occurrences.some(occurrence => occurrence.offset === 0)) {
156
- if (draft.startsWith('\n\n'))
157
- draft = draft.slice(2);
158
- else if (draft.startsWith('\n') || draft.startsWith(' '))
159
- draft = draft.slice(1);
160
- }
161
- return draft;
162
- }
163
- /** Remove every unsent plugin annotation while preserving the user's draft. */
164
- export function removeConversationAnnotations(input) {
165
- const snapshot = input.state.getSnapshot();
166
- const occurrences = selectionOccurrences(snapshot);
167
- if (occurrences.length === 0)
168
- return false;
169
- if (input.referenceMode === 'lexical' && input.replaceText !== undefined) {
170
- for (const occurrenceId of occurrences.map(occurrence => occurrence.occurrenceId).reverse()) {
171
- const current = input.state.getSnapshot();
172
- const occurrence = current.occurrences.find(candidate => candidate.occurrenceId === occurrenceId);
173
- if (occurrence === undefined)
174
- continue;
175
- const span = occurrenceEditSpan(input, current, occurrence, {
176
- consumeFollowingSeparator: true,
177
- });
178
- if (span === undefined || !input.replaceText('', span))
179
- return false;
180
- }
181
- return selectionOccurrences(input.state.getSnapshot()).length === 0;
182
- }
183
- input.setDraft(draftWithoutSelectionOccurrences(snapshot));
184
- return true;
70
+ return storedAnnotations(snapshot).map((annotation, annotationIndex) => ({ ...annotation, annotationIndex }));
185
71
  }
186
- /** Reference codec used by DSH's native composer chip and submit pipeline. */
187
- export const selectionReferenceSource = {
188
- trigger: '@',
189
- name: SELECTION_REFERENCE_SOURCE,
190
- order: 1_000,
191
- candidates: async () => [],
192
- onPick: () => undefined,
193
- codec: {
194
- clipboardText: ref => decodeSelectionReference(ref).map(annotation => annotation.text).join('\n\n'),
195
- serialize: async (ref, signal) => {
196
- if (signal.aborted)
197
- throw signal.reason;
198
- return selectedContext(decodeSelectionReference(ref));
199
- },
200
- },
201
- };
202
- function writeTextConversationAnnotations(input, before, annotations) {
203
- const draft = draftWithoutSelectionOccurrences(before);
204
- const insertionState = input.state.getSnapshot();
205
- if (insertionState.draftRev !== before.draftRev || insertionState.draft !== before.draft)
206
- return false;
207
- const ref = encodeSelectionReference(annotations);
208
- const inserted = input.insertReference({
209
- source: SELECTION_REFERENCE_SOURCE,
210
- ref,
211
- label: SELECTION_REFERENCE_LABEL,
212
- clipboardText: annotations.map(annotation => annotation.text).join('\n\n'),
213
- }, {
214
- start: 0,
215
- end: 0,
216
- draftRev: insertionState.draftRev,
217
- });
218
- // Existing annotations are intentionally still live here. If DSH rejects the
219
- // insertion, the operation is a no-op instead of deleting the previous chip.
220
- if (!inserted)
221
- return false;
222
- const afterInsert = input.state.getSnapshot();
223
- const occurrence = newlyInsertedOccurrence(insertionState, afterInsert, SELECTION_REFERENCE_SOURCE, ref);
224
- const rollbackInsertion = () => {
225
- const current = input.state.getSnapshot();
226
- const insertedOccurrence = occurrence === undefined
227
- ? undefined
228
- : current.occurrences.find(candidate => candidate.occurrenceId === occurrence.occurrenceId);
229
- if (insertedOccurrence === undefined || insertedOccurrence.offset !== 0)
230
- return;
231
- const insertedRange = occurrenceRange(current, insertedOccurrence, SELECTION_REFERENCE_LABEL);
232
- if (insertedRange === undefined || insertedRange.start !== 0)
233
- return;
234
- let tail = current.draft.slice(insertedRange.end);
235
- if (tail.startsWith(' ') && tail.slice(1) === before.draft)
236
- tail = before.draft;
237
- else if (tail !== before.draft)
238
- return;
239
- input.setDraft(tail);
240
- };
241
- if (occurrence === undefined || occurrence.offset !== 0) {
242
- rollbackInsertion();
243
- return false;
244
- }
245
- const range = occurrenceRange(afterInsert, occurrence, SELECTION_REFERENCE_LABEL);
246
- if (range === undefined || range.start !== 0) {
247
- rollbackInsertion();
248
- return false;
249
- }
250
- // Current DSH inserts the complete `@label` display text followed by an
251
- // optional ASCII separating gap. Older releases insert a one-unit U+FFFC
252
- // placeholder. At this point the old occurrence is still in the tail; one
253
- // tail replacement preserves the new occurrence and removes the old one.
254
- let rest = afterInsert.draft.slice(range.end);
255
- if (rest.startsWith(' ') && rest.slice(1) === before.draft)
256
- rest = before.draft;
257
- else if (rest !== before.draft) {
258
- rollbackInsertion();
259
- return false;
260
- }
261
- const display = afterInsert.draft.slice(range.start, range.end);
262
- const normalizedDraft = `${display}\n\n${draft}`;
263
- // insertReference and setDraft are separate public transactions. Re-read the
264
- // exact occurrence before the second write so a synchronous subscriber cannot
265
- // make us normalize a stale insertion.
266
- const current = input.state.getSnapshot();
267
- const currentOccurrence = current.occurrences.find(candidate => candidate.occurrenceId === occurrence.occurrenceId);
268
- if (current.draftRev !== afterInsert.draftRev
269
- || current.draft !== afterInsert.draft
270
- || currentOccurrence === undefined
271
- || currentOccurrence.source !== SELECTION_REFERENCE_SOURCE
272
- || currentOccurrence.ref !== ref
273
- || !occurrenceMatchesDraft(current, currentOccurrence, SELECTION_REFERENCE_LABEL)) {
274
- rollbackInsertion();
275
- return false;
276
- }
277
- input.setDraft(normalizedDraft);
278
- const normalized = input.state.getSnapshot();
279
- const retained = normalized.occurrences.find(candidate => candidate.occurrenceId === occurrence.occurrenceId);
280
- return normalized.draft === normalizedDraft
281
- && retained !== undefined
282
- && retained.source === SELECTION_REFERENCE_SOURCE
283
- && retained.ref === ref
284
- && retained.offset === 0
285
- && occurrenceMatchesDraft(normalized, retained, SELECTION_REFERENCE_LABEL);
286
- }
287
- function writeLexicalConversationAnnotations(input, before, annotations) {
288
- if (input.replaceText === undefined)
289
- return false;
72
+ /** A single atomic chip replacement preserves other references and the user's draft. */
73
+ function writeAnnotations(input, before, annotations) {
290
74
  const existing = selectionOccurrences(before);
291
75
  if (existing.length > 1)
292
76
  return false;
293
77
  const previous = existing[0];
294
- if (previous !== undefined && previous.length === undefined)
295
- return false;
296
- let insertionState = before;
297
- let preparedGap = false;
298
- if (previous === undefined) {
299
- // A gap inserted before the chip is unambiguously plugin-owned. DSH's
300
- // automatic gap cannot otherwise be distinguished from a user's leading
301
- // space when the reference is later removed.
302
- if (!input.replaceText(' ', { start: 0, end: 0, draftRev: before.draftRev }))
303
- return false;
304
- insertionState = input.state.getSnapshot();
305
- if (insertionState.draft !== ` ${before.draft}`)
306
- return false;
307
- preparedGap = true;
308
- }
309
- const span = previous === undefined
310
- ? { start: 0, end: 0, draftRev: insertionState.draftRev }
311
- : occurrenceEditSpan(input, insertionState, previous);
78
+ const span = previous === undefined ? { start: 0, end: 0, draftRev: before.draftRev }
79
+ : occurrenceEditSpan(input, before, previous);
312
80
  if (span === undefined)
313
81
  return false;
314
- const ref = encodeSelectionReference(annotations);
315
- const clipboardText = annotations.map(annotation => annotation.text).join('\n\n');
82
+ const ref = JSON.stringify({ version: 2, annotations });
316
83
  const inserted = input.insertReference({
317
- source: SELECTION_REFERENCE_SOURCE,
318
- ref,
319
- label: SELECTION_REFERENCE_LABEL,
320
- clipboardText,
84
+ source: SELECTION_REFERENCE_SOURCE, ref, label: SELECTION_REFERENCE_LABEL,
85
+ clipboardText: annotations.map(annotation => annotation.text).join('\n\n'),
321
86
  }, span);
322
- if (!inserted) {
323
- if (preparedGap) {
324
- const current = input.state.getSnapshot();
325
- if (current.draft === ` ${before.draft}`) {
326
- input.replaceText('', { start: 0, end: 1, draftRev: current.draftRev });
327
- }
328
- }
87
+ if (!inserted)
329
88
  return false;
330
- }
331
- const afterInsert = input.state.getSnapshot();
332
- const occurrence = newlyInsertedOccurrence(insertionState, afterInsert, SELECTION_REFERENCE_SOURCE, ref);
333
- const previousEnd = previous === undefined ? 0 : previous.offset + (previous.length ?? 0);
334
- const previousTail = previous === undefined
335
- ? insertionState.draft
336
- : insertionState.draft.slice(previousEnd);
337
- const automaticGap = previousTail.startsWith(' ') ? '' : ' ';
338
- const expectedDraft = previous === undefined
339
- ? `${clipboardText}${insertionState.draft}`
340
- : insertionState.draft.slice(0, previous.offset) + clipboardText + automaticGap + previousTail;
341
- const valid = occurrence !== undefined
342
- && selectionOccurrences(afterInsert).length === 1
343
- && occurrence.offset === (previous?.offset ?? 0)
344
- && occurrence.label === SELECTION_REFERENCE_LABEL
345
- && occurrence.clipboardText === clipboardText
346
- && occurrenceMatchesDraft(afterInsert, occurrence, SELECTION_REFERENCE_LABEL)
347
- && afterInsert.draft === expectedDraft;
348
- if (valid)
349
- return true;
350
- // Best-effort rollback for a first insertion. Replacing an existing chip is
351
- // atomic and should never reach this path on a conforming DSH input facade.
352
- if (previous === undefined && occurrence !== undefined) {
353
- const rollback = occurrenceEditSpan(input, afterInsert, occurrence, {
354
- consumeFollowingSeparator: true,
355
- });
356
- if (rollback !== undefined)
357
- input.replaceText('', rollback);
358
- }
359
- return false;
360
- }
361
- function writeConversationAnnotations(input, before, annotations) {
362
- return input.referenceMode === 'lexical'
363
- ? writeLexicalConversationAnnotations(input, before, annotations)
364
- : writeTextConversationAnnotations(input, before, annotations);
89
+ const after = input.state.getSnapshot();
90
+ const occurrence = newlyInsertedOccurrence(before, after, SELECTION_REFERENCE_SOURCE, ref);
91
+ return occurrence !== undefined && selectionOccurrences(after).length === 1
92
+ && occurrenceMatchesDraft(after, occurrence, SELECTION_REFERENCE_LABEL);
365
93
  }
366
- /** Add one passage to the parent composer's aggregated annotation occurrence. */
367
94
  export function addSelectionToConversation(input, selection, comment) {
368
95
  const before = input.state.getSnapshot();
369
- const annotations = [...storedConversationAnnotations(before), annotationFromSelection(selection, comment)];
370
- return writeConversationAnnotations(input, before, annotations);
96
+ const trimmed = comment?.trim();
97
+ return writeAnnotations(input, before, [...storedAnnotations(before), {
98
+ text: selection.text, selection, ...(trimmed ? { comment: trimmed } : {}),
99
+ }]);
100
+ }
101
+ export function removeConversationAnnotations(input) {
102
+ const occurrences = selectionOccurrences(input.state.getSnapshot());
103
+ if (occurrences.length === 0)
104
+ return false;
105
+ for (const occurrenceId of occurrences.map(occurrence => occurrence.occurrenceId).reverse()) {
106
+ const snapshot = input.state.getSnapshot();
107
+ const occurrence = snapshot.occurrences.find(candidate => candidate.occurrenceId === occurrenceId);
108
+ if (occurrence === undefined)
109
+ continue;
110
+ // Delete only the chip, never a possibly user-owned leading space/newline.
111
+ const span = occurrenceEditSpan(input, snapshot, occurrence);
112
+ if (span === undefined || !input.replaceText('', span))
113
+ return false;
114
+ }
115
+ return selectionOccurrences(input.state.getSnapshot()).length === 0;
371
116
  }
372
- /** Remove one unsent selected-passage annotation, retaining the aggregate when needed. */
373
117
  export function removeConversationAnnotation(input, annotationIndex) {
374
118
  const before = input.state.getSnapshot();
375
- const annotations = [...storedConversationAnnotations(before)];
119
+ const annotations = [...storedAnnotations(before)];
376
120
  if (!Number.isSafeInteger(annotationIndex) || annotations[annotationIndex] === undefined)
377
121
  return false;
378
122
  annotations.splice(annotationIndex, 1);
379
- if (annotations.length === 0)
380
- return removeConversationAnnotations(input);
381
- return writeConversationAnnotations(input, before, annotations);
123
+ return annotations.length === 0 ? removeConversationAnnotations(input) : writeAnnotations(input, before, annotations);
382
124
  }
383
- /** Replace the optional comment on one unsent selected-passage annotation. */
384
125
  export function updateConversationAnnotation(input, annotationIndex, comment) {
385
126
  const before = input.state.getSnapshot();
386
- const annotations = [...storedConversationAnnotations(before)];
387
- const current = annotations[annotationIndex];
388
- if (!Number.isSafeInteger(annotationIndex) || current === undefined)
389
- return false;
390
- const trimmedComment = comment?.trim();
391
- annotations[annotationIndex] = {
392
- text: current.text,
393
- ...(current.selection === undefined ? {} : { selection: current.selection }),
394
- ...(trimmedComment === undefined || trimmedComment.length === 0
395
- ? {}
396
- : { comment: trimmedComment }),
397
- };
398
- return writeConversationAnnotations(input, before, annotations);
399
- }
400
- function exactClipboardProjection(snapshot, annotationOccurrence, annotationClipboardText) {
401
- let projected = '';
402
- let cursor = 0;
403
- const occurrences = [...snapshot.occurrences].sort((left, right) => left.offset - right.offset);
404
- for (const occurrence of occurrences) {
405
- const expectedLabel = occurrence.occurrenceId === annotationOccurrence.occurrenceId
406
- ? SELECTION_REFERENCE_LABEL
407
- : occurrence.label;
408
- if (occurrence.length === undefined
409
- && !snapshot.draft.startsWith(LEGACY_REFERENCE_PLACEHOLDER, occurrence.offset)
410
- && expectedLabel === undefined)
411
- return;
412
- const range = occurrenceRange(snapshot, occurrence, expectedLabel ?? '');
413
- if (range === undefined || range.start < cursor)
414
- return;
415
- const clipboardText = occurrence.occurrenceId === annotationOccurrence.occurrenceId
416
- ? annotationClipboardText
417
- : occurrence.clipboardText;
418
- if (clipboardText === undefined)
419
- return;
420
- projected += snapshot.draft.slice(cursor, range.start) + clipboardText;
421
- cursor = range.end;
422
- }
423
- return projected + snapshot.draft.slice(cursor);
424
- }
425
- function annotationSeparator(draftTail) {
426
- if (draftTail.startsWith('\n\n'))
427
- return '\n\n';
428
- if (draftTail.startsWith('\n'))
429
- return '\n';
430
- if (draftTail.startsWith(' '))
431
- return ' ';
432
- return '';
433
- }
434
- function baseDraftFromMirror(mirrorDraft, clipboardText, expectedBaseDraft) {
435
- const separators = ['\n\n', '\n', ' ', ''];
436
- if (expectedBaseDraft !== undefined) {
437
- return separators.some(separator => mirrorDraft === clipboardText + separator + expectedBaseDraft)
438
- ? expectedBaseDraft
439
- : undefined;
440
- }
441
- for (const separator of separators.slice(0, -1)) {
442
- const prefix = clipboardText + separator;
443
- if (mirrorDraft.startsWith(prefix))
444
- return mirrorDraft.slice(prefix.length);
445
- }
446
- return mirrorDraft === clipboardText ? '' : undefined;
447
- }
448
- /** Describe both the display draft and DSH's persisted clipboard projection. */
449
- export function conversationAnnotationRecoveryRecord(snapshot) {
450
- const occurrence = selectionOccurrences(snapshot).find(candidate => candidate.offset === 0);
451
- const ref = conversationAnnotationReference(snapshot);
452
- if (occurrence === undefined || ref === undefined)
453
- return;
454
- const range = occurrenceRange(snapshot, occurrence, SELECTION_REFERENCE_LABEL);
455
- if (range === undefined || range.start !== 0)
456
- return;
457
- let annotations;
458
- try {
459
- annotations = decodeStoredSelectionReference(ref);
460
- }
461
- catch {
462
- return;
463
- }
464
- const clipboardText = annotations.map(annotation => annotation.text).join('\n\n');
465
- const mirrorDraft = exactClipboardProjection(snapshot, occurrence, clipboardText);
466
- if (mirrorDraft === undefined)
467
- return;
468
- const separator = annotationSeparator(snapshot.draft.slice(range.end));
469
- const mirrorPrefix = clipboardText + separator;
470
- if (!mirrorDraft.startsWith(mirrorPrefix))
471
- return;
472
- return {
473
- ref,
474
- displayDraft: snapshot.draft,
475
- mirrorDraft,
476
- baseDraft: mirrorDraft.slice(mirrorPrefix.length),
477
- };
478
- }
479
- /** Return the valid aggregated reference currently occupying the leading draft slot. */
480
- export function conversationAnnotationReference(snapshot) {
481
- const occurrence = selectionOccurrences(snapshot).find(candidate => candidate.offset === 0);
482
- if (occurrence === undefined
483
- || !occurrenceMatchesDraft(snapshot, occurrence, SELECTION_REFERENCE_LABEL))
484
- return;
485
- try {
486
- decodeStoredSelectionReference(occurrence.ref);
487
- return occurrence.ref;
488
- }
489
- catch {
490
- return;
491
- }
492
- }
493
- function draftWithoutOrphanedAnnotationPrefix(snapshot) {
494
- if (snapshot.occurrences.some(occurrence => occurrence.offset === 0))
495
- return;
496
- const legacyPrefix = `${LEGACY_REFERENCE_PLACEHOLDER}\n\n`;
497
- if (snapshot.draft.startsWith(legacyPrefix))
498
- return snapshot.draft.slice(legacyPrefix.length);
499
- const display = referenceDisplayText(SELECTION_REFERENCE_LABEL);
500
- if (!snapshot.draft.startsWith(display))
501
- return;
502
- let rest = snapshot.draft.slice(display.length);
503
- if (rest.startsWith('\n\n'))
504
- rest = rest.slice(2);
505
- else if (rest.startsWith(' '))
506
- rest = rest.slice(1);
507
- else
508
- return;
509
- return rest;
510
- }
511
- /** Remove the plugin-owned prefix when rc.6 restored its draft without occurrences. */
512
- export function removeOrphanedConversationAnnotationPlaceholder(input) {
513
- const draft = draftWithoutOrphanedAnnotationPrefix(input.state.getSnapshot());
514
- if (draft === undefined)
515
- return false;
516
- input.setDraft(draft);
517
- return true;
518
- }
519
- /** Rehydrate a lost occurrence over either its display draft or exact mirror projection. */
520
- export function restoreConversationAnnotationReference(input, ref, expectedMirrorDraft, expectedBaseDraft) {
521
- let annotations;
522
- try {
523
- annotations = decodeStoredSelectionReference(ref);
524
- }
525
- catch {
127
+ const annotations = [...storedAnnotations(before)];
128
+ const annotation = annotations[annotationIndex];
129
+ if (!Number.isSafeInteger(annotationIndex) || annotation === undefined)
526
130
  return false;
527
- }
528
- const snapshot = input.state.getSnapshot();
529
- const orphanDraft = draftWithoutOrphanedAnnotationPrefix(snapshot);
530
- const clipboardText = annotations.map(annotation => annotation.text).join('\n\n');
531
- const mirrorBaseDraft = expectedMirrorDraft === undefined
532
- ? undefined
533
- : baseDraftFromMirror(expectedMirrorDraft, clipboardText, expectedBaseDraft);
534
- const draft = orphanDraft
535
- ?? (expectedMirrorDraft !== undefined
536
- && snapshot.occurrences.length === 0
537
- && snapshot.draft === expectedMirrorDraft
538
- && mirrorBaseDraft !== undefined
539
- ? mirrorBaseDraft
540
- : undefined);
541
- if (draft === undefined)
542
- return false;
543
- if (draft !== snapshot.draft)
544
- input.setDraft(draft);
545
- const beforeWrite = input.state.getSnapshot();
546
- if (beforeWrite.draft !== draft || beforeWrite.occurrences.length !== 0)
547
- return false;
548
- if (writeConversationAnnotations(input, beforeWrite, annotations))
549
- return true;
550
- // Recovery must be content preserving. If DSH rejects insertion, put the
551
- // exact mirror projection back rather than leaving a partially stripped draft.
552
- const afterFailure = input.state.getSnapshot();
553
- if (afterFailure.occurrences.length === 0 && expectedMirrorDraft !== undefined) {
554
- input.setDraft(expectedMirrorDraft);
555
- }
556
- return false;
131
+ const trimmed = comment?.trim();
132
+ annotations[annotationIndex] = { text: annotation.text, selection: annotation.selection,
133
+ ...(trimmed ? { comment: trimmed } : {}) };
134
+ return writeAnnotations(input, before, annotations);
557
135
  }
558
- function unescapeXmlText(value) {
559
- return value
560
- .replaceAll('&lt;', '<')
561
- .replaceAll('&gt;', '>')
562
- .replaceAll('&amp;', '&');
563
- }
564
- function parseAnnotationBody(body) {
565
- const selectedText = /^<selected_text>\n([\s\S]*?)\n<\/selected_text>(?:\n<user_comment>\n([\s\S]*?)\n<\/user_comment>)?$/u.exec(body);
566
- if (selectedText === null)
567
- return { text: unescapeXmlText(body) };
568
- const comment = selectedText[2];
569
- return {
570
- text: unescapeXmlText(selectedText[1] ?? ''),
571
- ...(comment === undefined ? {} : { comment: unescapeXmlText(comment) }),
572
- };
136
+ function selectedContext(annotations) {
137
+ return ['<selected_context>', ...annotations.flatMap((annotation, index) => [
138
+ `<annotation index="${String(index + 1)}">`,
139
+ '<selected_text>', escapeXmlText(annotation.text), '</selected_text>',
140
+ ...(annotation.comment === undefined ? [] : ['<user_comment>', escapeXmlText(annotation.comment), '</user_comment>']),
141
+ '</annotation>',
142
+ ]), '</selected_context>'].join('\n');
573
143
  }
574
- /** Parse the durable model form back into the user-facing annotation capsule. */
144
+ export const selectionReferenceSource = {
145
+ trigger: '@', name: SELECTION_REFERENCE_SOURCE, order: 1_000,
146
+ candidates: async () => [], onPick: () => undefined,
147
+ codec: {
148
+ clipboardText: ref => decodeSelectionReference(ref).map(annotation => annotation.text).join('\n\n'),
149
+ serialize: async (ref, signal) => {
150
+ if (signal.aborted)
151
+ throw signal.reason;
152
+ return selectedContext(decodeSelectionReference(ref));
153
+ },
154
+ },
155
+ };
575
156
  export function parseAnnotatedConversationPrompt(text) {
576
- const current = /^<selected_context>\n([\s\S]*?)\n<\/selected_context>\s*/u.exec(text);
577
- if (current !== null) {
578
- const annotations = [];
579
- const annotationPattern = /<annotation(?: index="\d+")?>\n([\s\S]*?)\n<\/annotation>/gu;
580
- for (const match of current[1]?.matchAll(annotationPattern) ?? []) {
581
- annotations.push(parseAnnotationBody(match[1] ?? ''));
582
- }
583
- if (annotations.length > 0) {
584
- return { annotations, message: text.slice(current[0].length).trim() };
157
+ // Stored messages outlive a plugin update. Reading their durable text does
158
+ // not require an old DSH runtime or an old composer implementation.
159
+ const prefix = /^\s*<selected_context(?: source="current-conversation" event-seq="\d+")?>\r?\n([\s\S]*?)\r?\n<\/selected_context>\s*/u.exec(text);
160
+ if (prefix === null)
161
+ return;
162
+ const annotations = [];
163
+ if (prefix[0].trimStart().startsWith('<selected_context source=')) {
164
+ annotations.push({ text: unescapeXmlText(prefix[1] ?? '') });
165
+ }
166
+ else {
167
+ for (const match of prefix[1]?.matchAll(/<annotation(?: index="\d+")?>\r?\n([\s\S]*?)\r?\n<\/annotation>/gu) ?? []) {
168
+ const body = match[1] ?? '';
169
+ const parts = /^<selected_text>\r?\n([\s\S]*?)\r?\n<\/selected_text>(?:\r?\n<user_comment>\r?\n([\s\S]*?)\r?\n<\/user_comment>)?$/u.exec(body);
170
+ annotations.push({ text: unescapeXmlText(parts?.[1] ?? body),
171
+ ...(parts?.[2] === undefined ? {} : { comment: unescapeXmlText(parts[2]) }) });
585
172
  }
586
173
  }
587
- const legacyXml = /^<selected_context source="current-conversation" event-seq="(\d+)">\n?([\s\S]*?)\n?<\/selected_context>\s*/u.exec(text);
588
- if (legacyXml !== null) {
589
- return {
590
- annotations: [{ text: unescapeXmlText(legacyXml[2] ?? '') }],
591
- message: text.slice(legacyXml[0].length).trim(),
592
- };
593
- }
594
- const legacyText = /^Selected passage from the current conversation \(message (\d+)\):\n([\s\S]*?)\nEnd selected passage\.\s*/u.exec(text);
595
- if (legacyText === null)
596
- return undefined;
597
- return {
598
- annotations: [{ text: legacyText[2] ?? '' }],
599
- message: text.slice(legacyText[0].length).trim(),
600
- };
174
+ return annotations.length === 0 ? undefined : { annotations, message: text.slice(prefix[0].length).trim() };
601
175
  }
602
- /** Add the selected quote to the first child prompt. */
176
+ /** The first prompt uses the same durable annotation format as the main composer. */
603
177
  export function buildSideChatPrompt(selection, question) {
604
178
  const trimmed = question.trim();
605
- if (selection === undefined)
606
- return [{ type: 'text', text: trimmed }];
607
- return [{
608
- type: 'text',
609
- text: [
610
- `<selected_context source="current-conversation" event-seq="${selection.atSeq}">`,
611
- escapeXmlText(selection.text),
612
- '</selected_context>',
613
- '',
614
- '<user_question>',
615
- escapeXmlText(trimmed),
616
- '</user_question>',
617
- ].join('\n'),
618
- }];
179
+ return [{ type: 'text', text: selection === undefined ? trimmed : [
180
+ selectedContext([{ text: selection.text }]), '',
181
+ '<user_question>', escapeXmlText(trimmed), '</user_question>',
182
+ ].join('\n') }];
183
+ }
184
+ /** Decode a question only after recognizing this plugin's annotation prefix. */
185
+ export function annotationMessageText(message) {
186
+ const question = /^<user_question>(?:\r?\n)?([\s\S]*?)(?:\r?\n)?<\/user_question>(?=$|\s)/u.exec(message);
187
+ if (question === null)
188
+ return message;
189
+ const text = unescapeXmlText((question[1] ?? '').trim());
190
+ // Attachments or additional text blocks must not make the question wrapper
191
+ // reappear, nor be discarded while projecting the model-facing payload.
192
+ const suffix = message.slice(question[0].length).trim();
193
+ return [text, suffix].filter(part => part.length > 0).join('\n');
194
+ }
195
+ /** One projection supplies both the annotation capsule and the visible question. */
196
+ export function parseSideChatPrompt(text) {
197
+ const annotated = parseAnnotatedConversationPrompt(text);
198
+ return annotated === undefined ? undefined : {
199
+ annotations: annotated.annotations,
200
+ message: annotationMessageText(annotated.message),
201
+ };
202
+ }
203
+ export function sideChatQuestionText(text) {
204
+ return parseSideChatPrompt(text)?.message ?? text;
619
205
  }