dsh-select-quote 0.3.1 → 0.3.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 (3) hide show
  1. package/README.md +23 -9
  2. package/lib/client.js +600 -358
  3. package/package.json +5 -3
package/lib/client.js CHANGED
@@ -51,60 +51,122 @@ function lastEditable() {
51
51
  }
52
52
  //#endregion
53
53
  //#region src/client/styles.ts
54
+ /**
55
+ * Every style the plugin ships, in four layers.
56
+ *
57
+ * 1. tokens — product design tokens, aliased once with their fallbacks
58
+ * 2. atoms — shared shapes (`.dsq_a_*`), no numbers, no colors
59
+ * 3. components — one block per UI piece; everything measurable lives here
60
+ * 4. host — rules that reach into product DOM, theme flips, keyframes
61
+ *
62
+ * Injection order is the cascade order, so a component rule always wins over an
63
+ * atom on the same element — which is what lets atoms stay shape-only and the
64
+ * component blocks stay the single source of numbers.
65
+ */
66
+ /** Layer 2 — atoms. Shape only: adding a number here is a layering violation. */
67
+ const ATOMS = {
68
+ row: "display: flex; align-items: center;",
69
+ inlineRow: "display: inline-flex; align-items: center;",
70
+ col: "display: flex; flex-direction: column;",
71
+ colEnd: "display: flex; flex-direction: column; align-items: flex-end;",
72
+ gridMid: "display: grid; place-items: center;",
73
+ ell: "overflow: hidden; text-overflow: ellipsis; white-space: nowrap;",
74
+ plain: "appearance: none; border: none; background: transparent; cursor: pointer;",
75
+ keep: "flex: none;",
76
+ grow: "flex: 1; min-width: 0;",
77
+ box: "box-sizing: border-box;"
78
+ };
79
+ const toKebab = (name) => name.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);
80
+ const atomRules = Object.entries(ATOMS).map(([name, declarations]) => `.dsq_a_${toKebab(name)} { ${declarations} }`).join("\n");
81
+ /** Atom class names, for `cx(styles.card, A.row)`. */
82
+ const A = Object.fromEntries(Object.keys(ATOMS).map((name) => [name, `dsq_a_${toKebab(name)}`]));
83
+ /** Join class parts, dropping the falsy ones so a variant can be `cond && A.x`. */
84
+ function cx(...parts) {
85
+ return parts.filter(Boolean).join(" ");
86
+ }
54
87
  const CSS = `
88
+ /* Layer 1 — tokens. Every product token this plugin reads is aliased here once,
89
+ with the fallback it used to repeat inline at each use site. Scoped to \`body\`,
90
+ not \`:root\`: the product flips its palette on body[data-ds-dark-theme], so an
91
+ alias computed on the root element would freeze the light values and inherit
92
+ them into the dark page. */
93
+ body {
94
+ --dsq-chat-width: var(--dsh-chat-content-width, 748px);
95
+ --dsq-font-size: var(--dsh-content-font-size, 14px);
96
+ --dsq-font-delta: var(--dsh-content-font-delta, 0px);
97
+ --dsq-bg-raised: var(--dsw-alias-bg-layer-1, #fff);
98
+ --dsq-bg-panel: var(--dsw-alias-bg-layer-2, #fff);
99
+ --dsq-bg-toast: var(--dsw-alias-bg-layer-3, rgba(0, 0, 0, 0.78));
100
+ --dsq-line: var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.1));
101
+ --dsq-line-divider: var(--dsw-alias-border-l4, rgba(0, 0, 0, 0.12));
102
+ --dsq-focus-ring: var(--dsw-alias-button-primary-fill, #1677ff);
103
+ --dsq-fill-hover: var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, 0.05));
104
+ --dsq-label: var(--dsw-alias-label-primary, rgba(0, 0, 0, 0.88));
105
+ --dsq-label-inverted: var(--dsw-alias-label-primary-inverted, #fff);
106
+ --dsq-label-2: var(--dsw-alias-label-secondary, rgba(0, 0, 0, 0.65));
107
+ --dsq-label-3: var(--dsw-alias-label-tertiary, rgba(0, 0, 0, 0.45));
108
+ --dsq-link: var(--dsw-alias-link, rgba(0, 0, 0, 0.65));
109
+ --dsq-shadow-pop: var(--dsw-elevation-prominent, 0 3px 8px rgba(0, 0, 0, 0.04), 0 0 20px rgba(0, 0, 0, 0.05));
110
+ --dsq-shadow-soft: var(--dsw-elevation-soft, 0 1px 2px rgba(0, 0, 0, 0.03), 0 6px 20px rgba(0, 0, 0, 0.06));
111
+ --dsq-bubble: var(--dsw-specific-bubble, rgba(0, 0, 0, 0.05));
112
+ --dsq-menu: var(--dsw-specific-menu, #fff);
113
+ --dsq-accent-400: var(--dsw-static-blue-400, #1677ff);
114
+ --dsq-accent: var(--dsw-static-blue-500, #3b82f6);
115
+ --dsq-accent-tint: var(--dsw-static-blue-50p, #eaf3ff);
116
+ --dsq-accent-tint-hover: var(--dsw-static-blue-75, #e5f0ff);
117
+ --dsq-accent-tint-dark: var(--dsw-static-blue-950, #172554);
118
+ --dsq-neutral-50: var(--dsw-static-neutral-50, #fafafa);
119
+ --dsq-neutral-100: var(--dsw-static-neutral-100, #f5f5f5);
120
+ --dsq-neutral-800: var(--dsw-static-neutral-800, #292929);
121
+ --dsq-neutral-850: var(--dsw-static-neutral-850, #212123);
122
+ }
123
+
124
+ /* Layer 2 — atoms (generated from ATOMS above; the class names and the rules
125
+ cannot drift apart because both come from the same table). */
126
+ ${atomRules}
127
+
128
+ /* Layer 3 — components. Floating selection toolbar. */
55
129
  .dsq_toolbar {
56
130
  position: fixed;
57
131
  z-index: 10060;
58
132
  transform: translate(-50%, -100%);
59
- display: flex;
60
- align-items: center;
61
133
  gap: 2px;
62
134
  padding: 4px;
63
- /* 无边框:--dsw-elevation-soft 自带 0 0 0 .5px 的描边环,由它勾勒轮廓,
135
+ /* 无边框:--dsq-shadow-soft 自带 0 0 0 .5px 的描边环,由它勾勒轮廓,
64
136
  所以这里不要再画 border,否则描边会和环叠成两条线。 */
65
137
  border: 0;
66
- border-radius: 999px;
67
- background: var(--dsw-alias-bg-layer-2, #fff);
68
- box-shadow: var(--dsw-elevation-soft, 0 1px 2px rgba(0, 0, 0, 0.03), 0 6px 20px rgba(0, 0, 0, 0.06));
138
+ border-radius: 12px;
139
+ background: var(--dsq-bg-panel);
140
+ box-shadow: var(--dsq-shadow-soft);
69
141
  pointer-events: auto;
70
142
  /* Mounts once per selection, after the drag ends: soft rise + fade. */
71
143
  animation: dsq-toolbar-in 0.16s cubic-bezier(0.2, 0.8, 0.2, 1);
72
144
  }
73
145
 
74
- @keyframes dsq-toolbar-in {
75
- from {
76
- opacity: 0;
77
- transform: translate(-50%, -100%) translateY(4px);
78
- }
79
- to {
80
- opacity: 1;
81
- transform: translate(-50%, -100%);
82
- }
83
- }
84
-
85
146
  .dsq_button {
86
- appearance: none;
87
- border: none;
88
- background: transparent;
89
- color: var(--dsw-alias-label-primary, rgba(0, 0, 0, 0.88));
147
+ color: var(--dsq-label);
90
148
  font: inherit;
91
- font-size: 13px;
149
+ font-size: 12px;
92
150
  line-height: 20px;
93
- cursor: pointer;
94
- border-radius: 999px;
151
+ border-radius: 8px;
95
152
  padding: 4px 10px;
96
- display: inline-flex;
97
- align-items: center;
98
153
  gap: 6px;
99
154
  white-space: nowrap;
100
155
  }
101
-
102
156
  .dsq_button:hover {
103
- background: var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, 0.06));
157
+ background: var(--dsq-fill-hover);
158
+ }
159
+
160
+ .dsq_buttonAddComment {
161
+ background: var(--dsq-accent);
162
+ color:#fff;
163
+ }
164
+ .dsq_buttonAddComment:hover {
165
+ background: var(--dsq-accent-400);
104
166
  }
105
167
 
106
168
  .dsq_button:focus-visible {
107
- outline: 2px solid var(--dsw-alias-button-primary-fill, #1677ff);
169
+ outline: 2px solid var(--dsq-focus-ring);
108
170
  outline-offset: 1px;
109
171
  }
110
172
 
@@ -115,7 +177,7 @@ const CSS = `
115
177
  .dsq_divider {
116
178
  width: 1px;
117
179
  height: 14px;
118
- background: var(--dsw-alias-border-l4, rgba(0, 0, 0, 0.12));
180
+ background: var(--dsq-line-divider);
119
181
  margin: 0 2px;
120
182
  }
121
183
 
@@ -125,8 +187,8 @@ const CSS = `
125
187
  transform: translate(-50%, 8px);
126
188
  padding: 4px 10px;
127
189
  border-radius: 999px;
128
- background: var(--dsw-alias-bg-layer-3, rgba(0, 0, 0, 0.78));
129
- color: var(--dsw-alias-label-primary-inverted, #fff);
190
+ background: var(--dsq-bg-toast);
191
+ color: var(--dsq-label-inverted);
130
192
  font-size: 12px;
131
193
  line-height: 18px;
132
194
  pointer-events: none;
@@ -142,23 +204,20 @@ const CSS = `
142
204
  width: 20%;
143
205
  min-width: 148px;
144
206
  max-width: 220px;
145
- display: flex;
146
- flex-direction: column;
147
207
  align-items: stretch;
148
208
  gap: 6px;
149
209
  pointer-events: auto;
150
210
  }
151
211
 
152
- /* One quote card. Geometry mirrors the product's own file card
153
- (.nyYjTG_file in ui-deliverables): hairline border, neutral fill,
154
- 18px radius, fixed 72px row, centered content, background transition. */
212
+ /* One annotation card, shared by the composer and the transcript: the base
213
+ carries the shape, and \`.dsq_tCard\` / \`.dsq_tCardIcon\` override only the
214
+ numbers where the transcript really differs.
215
+ Geometry mirrors the product's own file card (\`.nyYjTG_file\` in
216
+ ui-deliverables): hairline border, neutral fill, 18px radius, fixed 72px row. */
155
217
  .dsq_card {
156
- --dsq-card-fill: var(--dsw-static-neutral-50, #fafafa);
157
- --dsq-card-hover: var(--dsw-static-neutral-100, #f5f5f5);
158
- box-sizing: border-box;
218
+ --dsq-card-fill: var(--dsq-neutral-50);
219
+ --dsq-card-hover: var(--dsq-neutral-100);
159
220
  flex: 0 0 auto;
160
- display: flex;
161
- align-items: center;
162
221
  gap: 10px;
163
222
  width: fit-content;
164
223
  max-width: calc(25% - 5px);
@@ -166,10 +225,10 @@ const CSS = `
166
225
  height: 64px;
167
226
  margin: 0;
168
227
  padding: 10px;
169
- border: 0.5px solid var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.1));
228
+ border: 0.5px solid var(--dsq-line);
170
229
  border-radius: 18px;
171
230
  background: var(--dsq-card-fill);
172
- color: var(--dsw-alias-label-primary, rgba(0, 0, 0, 0.88));
231
+ color: var(--dsq-label);
173
232
  transition: background-color 0.12s;
174
233
  position: relative;
175
234
  overflow: hidden;
@@ -185,15 +244,12 @@ const CSS = `
185
244
  quarter of the input, so 48px would leave no room for the quote text. */
186
245
  .dsq_cardIcon {
187
246
  z-index: 2;
188
- flex: none;
189
247
  width: 36px;
190
248
  height: 36px;
191
- border: 0.5px solid var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.1));
249
+ border: 0.5px solid var(--dsq-line);
192
250
  border-radius: 12px;
193
- display: grid;
194
- place-items: center;
195
251
  background: var(--dsq-card-fill);
196
- color: var(--dsw-alias-link, rgba(0, 0, 0, 0.65));
252
+ color: var(--dsq-link);
197
253
  font-size: 12px;
198
254
  font-weight: 600;
199
255
  letter-spacing: -0.02em;
@@ -201,13 +257,13 @@ const CSS = `
201
257
 
202
258
  .dsq_cardBody {
203
259
  z-index: 2;
204
- flex: 1;
205
- min-width: 0;
206
- display: flex;
207
- flex-direction: column;
208
260
  justify-content: center;
209
261
  gap: 2px;
210
- /* Keeps the ellipsised title clear of the corner remove button. */
262
+ }
263
+
264
+ /* Keeps the ellipsised title clear of the corner remove button, which only the
265
+ composer stack renders. */
266
+ .dsq_cardStack .dsq_cardBody {
211
267
  padding-right: 14px;
212
268
  }
213
269
 
@@ -215,19 +271,13 @@ const CSS = `
215
271
  font-size: 14px;
216
272
  font-weight: 500;
217
273
  line-height: 22px;
218
- color: var(--dsw-alias-label-primary, rgba(0, 0, 0, 0.88));
219
- overflow: hidden;
220
- text-overflow: ellipsis;
221
- white-space: nowrap;
274
+ color: var(--dsq-label);
222
275
  }
223
276
 
224
277
  .dsq_cardSubtitle {
225
278
  font-size: 12px;
226
279
  line-height: 18px;
227
- color: var(--dsw-alias-label-tertiary, rgba(0, 0, 0, 0.45));
228
- overflow: hidden;
229
- text-overflow: ellipsis;
230
- white-space: nowrap;
280
+ color: var(--dsq-label-3);
231
281
  }
232
282
 
233
283
  /* Corner remove button: hidden until the card is hovered or focused. */
@@ -236,16 +286,10 @@ const CSS = `
236
286
  top: 4px;
237
287
  right: 4px;
238
288
  z-index: 3;
239
- appearance: none;
240
- border: none;
241
- background: transparent;
242
289
  width: 20px;
243
290
  height: 20px;
244
291
  border-radius: 6px;
245
- cursor: pointer;
246
- color: var(--dsw-alias-label-tertiary, rgba(0, 0, 0, 0.45));
247
- display: grid;
248
- place-items: center;
292
+ color: var(--dsq-label-3);
249
293
  font-size: 14px;
250
294
  line-height: 1;
251
295
  opacity: 0;
@@ -262,8 +306,8 @@ const CSS = `
262
306
  }
263
307
 
264
308
  .dsq_cardClose:hover {
265
- background: var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, 0.06));
266
- color: var(--dsw-alias-label-primary, rgba(0, 0, 0, 0.88));
309
+ background: var(--dsq-fill-hover);
310
+ color: var(--dsq-label);
267
311
  }
268
312
 
269
313
  /* Secondary corner action (edit comment). */
@@ -272,16 +316,10 @@ const CSS = `
272
316
  top: 4px;
273
317
  right: 28px;
274
318
  z-index: 3;
275
- appearance: none;
276
- border: none;
277
- background: transparent;
278
319
  width: 20px;
279
320
  height: 20px;
280
321
  border-radius: 6px;
281
- cursor: pointer;
282
- color: var(--dsw-alias-label-tertiary, rgba(0, 0, 0, 0.45));
283
- display: grid;
284
- place-items: center;
322
+ color: var(--dsq-label-3);
285
323
  font-size: 12px;
286
324
  line-height: 1;
287
325
  opacity: 0;
@@ -296,42 +334,35 @@ const CSS = `
296
334
  }
297
335
 
298
336
  .dsq_cardAction:hover {
299
- background: var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, 0.06));
337
+ background: var(--dsq-fill-hover);
300
338
  }
301
339
 
302
340
  /* Qoder-style annotation summary (“N 条批注”) inside the composer. */
303
341
  .dsq_summary {
304
342
  position: relative;
305
343
  z-index: 10040;
306
- display: flex;
307
- align-items: center;
308
344
  gap: 10px;
309
345
  width: 100%;
310
346
  max-width: 100%;
311
347
  padding: 8px 10px;
312
- border: 0.5px solid var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.1));
348
+ border: 0.5px solid var(--dsq-line);
313
349
  border-radius: 18px;
314
- background: var(--dsw-static-neutral-50, #fafafa);
350
+ background: var(--dsq-neutral-50);
315
351
  pointer-events: auto;
316
352
  user-select: none;
317
353
  }
318
354
 
319
355
  .dsq_summaryIcon {
320
- flex: none;
321
356
  width: 32px;
322
357
  height: 32px;
323
358
  border-radius: 10px;
324
- display: grid;
325
- place-items: center;
326
- background: var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, 0.05));
327
- color: var(--dsw-alias-link, rgba(0, 0, 0, 0.65));
359
+ background: var(--dsq-fill-hover);
360
+ color: var(--dsq-link);
328
361
  font-size: 13px;
329
362
  font-weight: 600;
330
363
  }
331
364
 
332
365
  .dsq_summaryBody {
333
- flex: 1;
334
- min-width: 0;
335
366
  position: relative;
336
367
  }
337
368
 
@@ -339,16 +370,18 @@ const CSS = `
339
370
  font-size: 14px;
340
371
  font-weight: 500;
341
372
  line-height: 20px;
342
- color: var(--dsw-alias-label-primary, rgba(0, 0, 0, 0.88));
373
+ color: var(--dsq-label);
343
374
  }
344
375
 
345
376
  .dsq_summaryHint {
346
377
  font-size: 12px;
347
378
  line-height: 18px;
348
- color: var(--dsw-alias-label-tertiary, rgba(0, 0, 0, 0.45));
379
+ color: var(--dsq-label-3);
349
380
  }
350
381
 
351
382
  .dsq_summaryHover {
383
+ /* \`display: none\` here is the closed state, so this rule must not take the
384
+ flex atoms — the open class below turns display back on. */
352
385
  display: none;
353
386
  position: fixed;
354
387
  z-index: 10050;
@@ -378,63 +411,47 @@ const CSS = `
378
411
  transform-origin: bottom center;
379
412
  }
380
413
 
381
- @keyframes dsq-panel-in {
382
- from {
383
- transform: scale(0.96);
384
- }
385
- to {
386
- transform: scale(1);
387
- }
388
- }
389
-
390
414
  .dsq_summaryHoverInner {
391
- display: flex;
392
- flex-direction: column;
393
415
  gap: 4px;
394
416
  max-height: min(420px, calc(100vh - 120px));
395
417
  overflow-y: auto;
396
418
  overscroll-behavior: contain;
397
- padding: 6px;
398
- border-radius: 14px;
399
- border: 0.5px solid var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.1));
419
+ padding: 4px;
420
+ border-radius: 12px;
421
+ border: 0.5px solid var(--dsq-line);
400
422
  /* Frosted panel: the menu token at partial alpha over a blur. */
401
- background: color-mix(in srgb, var(--dsw-specific-menu, #fff) 82%, transparent);
423
+ background: color-mix(in srgb, var(--dsq-bg-raised) 50%, transparent);
402
424
  backdrop-filter: blur(14px) saturate(1.4);
403
425
  -webkit-backdrop-filter: blur(14px) saturate(1.4);
404
- box-shadow: var(--dsw-elevation-prominent, 0 3px 8px rgba(0, 0, 0, 0.04), 0 0 20px rgba(0, 0, 0, 0.05));
426
+ box-shadow: var(--dsq-shadow-pop);
405
427
  }
406
428
 
407
429
  @supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
408
430
  .dsq_summaryHoverInner {
409
- background: var(--dsw-specific-menu, #fff);
431
+ background: var(--dsq-menu);
410
432
  }
411
433
  }
412
434
 
413
435
  .dsq_summaryItem {
414
436
  display: block;
415
437
  padding: 8px 10px;
416
- border-radius: 10px;
438
+ border-radius: 8px;
417
439
  transition: background-color 0.12s;
418
440
  }
419
441
 
420
442
  .dsq_summaryItem:hover {
421
- background: var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, 0.05));
443
+ background: color-mix(in srgb, var(--dsq-fill-hover) 50%, transparent) ;
422
444
  }
423
445
 
424
446
  .dsq_summaryItemHead {
425
- display: flex;
426
- align-items: center;
427
447
  gap: 6px;
428
448
  }
429
449
 
430
450
  .dsq_summaryItemBadge {
431
- flex: none;
432
- display: grid;
433
- place-items: center;
434
451
  width: 18px;
435
452
  height: 18px;
436
453
  border-radius: 999px;
437
- background: var(--dsw-static-blue-500, #3b82f6);
454
+ background: var(--dsq-accent);
438
455
  color: #fff;
439
456
  font-size: 11px;
440
457
  font-weight: 600;
@@ -442,8 +459,6 @@ const CSS = `
442
459
  }
443
460
 
444
461
  .dsq_summaryItemActions {
445
- display: flex;
446
- align-items: center;
447
462
  gap: 2px;
448
463
  margin-left: auto;
449
464
  opacity: 0.5;
@@ -456,21 +471,15 @@ const CSS = `
456
471
  }
457
472
 
458
473
  .dsq_summaryItemAction {
459
- appearance: none;
460
- border: none;
461
- background: transparent;
462
474
  width: 26px;
463
475
  height: 26px;
464
476
  border-radius: 7px;
465
- cursor: pointer;
466
- color: var(--dsw-alias-label-tertiary, rgba(0, 0, 0, 0.45));
467
- display: grid;
468
- place-items: center;
477
+ color: var(--dsq-label-3);
469
478
  }
470
479
 
471
480
  .dsq_summaryItemAction:hover {
472
- background: var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, 0.06));
473
- color: var(--dsw-alias-label-primary, rgba(0, 0, 0, 0.88));
481
+ background: var(--dsq-fill-hover);
482
+ color: var(--dsq-label);
474
483
  }
475
484
 
476
485
  .dsq_summaryItemMain {
@@ -478,14 +487,10 @@ const CSS = `
478
487
  }
479
488
 
480
489
  .dsq_summaryItemButton {
481
- appearance: none;
482
490
  display: block;
483
491
  width: 100%;
484
492
  text-align: left;
485
493
  font: inherit;
486
- border: none;
487
- background: transparent;
488
- cursor: pointer;
489
494
  border-radius: 8px;
490
495
  padding: 2px 0 0;
491
496
  }
@@ -501,13 +506,13 @@ const CSS = `
501
506
  .dsq_summaryItemLabel {
502
507
  font-size: 11px;
503
508
  line-height: 16px;
504
- color: var(--dsw-alias-label-tertiary, rgba(0, 0, 0, 0.45));
509
+ color: var(--dsq-label-3);
505
510
  }
506
511
 
507
512
  .dsq_summaryItemText {
508
513
  font-size: 13px;
509
514
  line-height: 18px;
510
- color: var(--dsw-alias-label-primary, rgba(0, 0, 0, 0.88));
515
+ color: var(--dsq-label);
511
516
  white-space: pre-wrap;
512
517
  overflow-wrap: anywhere;
513
518
  display: -webkit-box;
@@ -517,14 +522,14 @@ const CSS = `
517
522
  }
518
523
 
519
524
  .dsq_summaryItemTextEmpty {
520
- color: var(--dsw-alias-label-tertiary, rgba(0, 0, 0, 0.45));
525
+ color: var(--dsq-label-3);
521
526
  }
522
527
 
523
528
  /* Inline edit form rendered inside the hover panel item (no extra layer). */
524
529
  .dsq_annEditText {
525
530
  font-size: 13px;
526
531
  line-height: 20px;
527
- color: var(--dsw-alias-label-primary, rgba(0, 0, 0, 0.88));
532
+ color: var(--dsq-label);
528
533
  white-space: pre-wrap;
529
534
  overflow-wrap: anywhere;
530
535
  max-height: 132px;
@@ -533,7 +538,6 @@ const CSS = `
533
538
  }
534
539
 
535
540
  .dsq_annEditInput {
536
- box-sizing: border-box;
537
541
  width: 100%;
538
542
  min-height: 72px;
539
543
  resize: vertical;
@@ -543,54 +547,49 @@ const CSS = `
543
547
  font: inherit;
544
548
  font-size: 13px;
545
549
  line-height: 20px;
546
- color: var(--dsw-alias-label-primary, rgba(0, 0, 0, 0.88));
547
- background: var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, 0.04));
550
+ color: var(--dsq-label);
551
+ background: var(--dsq-fill-hover);
548
552
  outline: none;
549
553
  }
550
554
 
551
555
  .dsq_annEditInput::placeholder {
552
- color: var(--dsw-alias-label-tertiary, rgba(0, 0, 0, 0.45));
556
+ color: var(--dsq-label-3);
553
557
  }
554
558
 
555
559
  .dsq_annEditActions {
556
- display: flex;
557
560
  justify-content: flex-end;
558
- align-items: center;
559
561
  gap: 8px;
562
+ margin-top: 4px;
560
563
  }
561
564
 
562
565
  .dsq_annEditCancel {
563
- appearance: none;
564
- border: none;
565
- background: transparent;
566
- color: var(--dsw-alias-label-secondary, rgba(0, 0, 0, 0.65));
566
+ color: var(--dsq-label-2);
567
567
  font: inherit;
568
568
  font-size: 13px;
569
- padding: 6px 10px;
570
- border-radius: 999px;
571
- cursor: pointer;
569
+ padding: 4px 12px;
570
+ border-radius: 10px;
572
571
  }
573
572
 
574
573
  .dsq_annEditCancel:hover {
575
- background: var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, 0.05));
574
+ background: var(--dsq-fill-hover);
576
575
  }
577
576
 
578
577
  .dsq_annEditSave {
579
578
  appearance: none;
580
579
  border: none;
581
- background: var(--dsw-static-blue-500, #3b82f6);
580
+ background: var(--dsq-accent);
582
581
  color: #fff;
583
582
  font: inherit;
584
583
  font-size: 13px;
585
584
  font-weight: 500;
586
- padding: 6px 16px;
587
- border-radius: 999px;
585
+ padding: 4px 12px;
586
+ border-radius: 10px;
588
587
  cursor: pointer;
589
588
  transition: filter 0.12s;
590
589
  }
591
590
 
592
591
  .dsq_annEditSave:hover {
593
- filter: brightness(1.06);
592
+ background: var(--dsq-accent-400);
594
593
  }
595
594
 
596
595
  .dsq_annEditSave:disabled {
@@ -604,36 +603,32 @@ const CSS = `
604
603
  z-index: 10050;
605
604
  transform: translate(-50%, 4px);
606
605
  width: min(320px, calc(100vw - 24px));
607
- display: flex;
608
- flex-direction: column;
609
606
  gap: 8px;
610
- padding: 10px;
611
- border: 0.5px solid var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.1));
612
- border-radius: 14px;
613
- background: color-mix(in srgb, var(--dsw-specific-menu, #fff) 88%, transparent);
614
- backdrop-filter: blur(14px) saturate(1.4);
607
+ padding: 8px;
608
+ border-radius: 16px;
609
+ background: color-mix(in srgb, var(--dsq-bg-raised) 75%, transparent);
610
+ backdrop-filter: blur(10px) saturate(1.4);
615
611
  -webkit-backdrop-filter: blur(14px) saturate(1.4);
616
- box-shadow: var(--dsw-elevation-prominent, 0 3px 8px rgba(0, 0, 0, 0.04), 0 0 20px rgba(0, 0, 0, 0.05));
612
+ box-shadow: var(--dsq-shadow-pop);
617
613
  }
618
614
 
619
615
  .dsq_commentInput {
620
- box-sizing: border-box;
621
616
  width: 100%;
622
617
  min-height: 64px;
623
618
  resize: none;
624
619
  border: none;
625
- border-radius: 10px;
620
+ border-radius: 8px;
626
621
  padding: 8px 10px;
627
622
  font: inherit;
628
623
  font-size: 13px;
629
624
  line-height: 20px;
630
- color: var(--dsw-alias-label-primary, rgba(0, 0, 0, 0.88));
631
- background: var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, 0.04));
625
+ color: var(--dsq-label);
626
+ background: var(--dsq-fill-hover);
632
627
  outline: none;
633
628
  }
634
629
 
635
630
  .dsq_commentInput::placeholder {
636
- color: var(--dsw-alias-label-tertiary, rgba(0, 0, 0, 0.45));
631
+ color: var(--dsq-label-3);
637
632
  }
638
633
 
639
634
  .dsq_commentActions {
@@ -659,104 +654,38 @@ const CSS = `
659
654
  margin-right: 0;
660
655
  }
661
656
 
657
+ /* Transcript-only differences from the shared card. Unlike the composer
658
+ summary (one collapsed row per message) each history card shows its own
659
+ comment, so the fixed height and single-line clamps are lifted on \`.dsq_tCardButton\`. */
662
660
  .dsq_tCard {
663
- --dsq-card-fill: var(--dsw-static-neutral-50, #fafafa);
664
- --dsq-card-hover: var(--dsw-static-neutral-100, #f5f5f5);
665
- box-sizing: border-box;
666
- display: flex;
667
- align-items: center;
668
- gap: 10px;
669
- width: fit-content;
670
- max-width: min(calc(var(--dsh-chat-content-width, 748px) * 0.72), 520px);
671
- min-width: 0;
672
- height: 64px;
673
- margin: 0 0 0 auto;
674
- padding: 10px;
675
- border: 0.5px solid var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.1));
676
- border-radius: 18px;
677
- background: var(--dsq-card-fill);
678
- color: var(--dsw-alias-label-primary, rgba(0, 0, 0, 0.88));
679
- transition: background-color 0.12s;
680
- position: relative;
681
- overflow: hidden;
682
- user-select: none;
683
- }
684
-
685
- .dsq_tCard:hover {
686
- background: var(--dsq-card-hover);
661
+ max-width: min(calc(var(--dsq-chat-width) * 0.72), 520px);
662
+ margin-left: auto;
687
663
  }
688
664
 
689
665
  .dsq_tCardIcon {
690
- z-index: 2;
691
- flex: none;
692
666
  width: 40px;
693
667
  height: 40px;
694
- border: 0.5px solid var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.1));
695
- border-radius: 12px;
696
- display: grid;
697
- place-items: center;
698
- background: var(--dsq-card-fill);
699
- color: var(--dsw-alias-link, rgba(0, 0, 0, 0.65));
700
- font-size: 14px;
701
- font-weight: 600;
702
- letter-spacing: -0.02em;
703
- }
704
-
705
- .dsq_tCardBody {
706
- z-index: 2;
707
- flex: 1;
708
- min-width: 0;
709
- display: flex;
710
- flex-direction: column;
711
- justify-content: center;
712
- gap: 2px;
713
- }
714
-
715
- .dsq_tCardTitle {
716
668
  font-size: 14px;
717
- font-weight: 500;
718
- line-height: 22px;
719
- color: var(--dsw-alias-label-primary, rgba(0, 0, 0, 0.88));
720
- overflow: hidden;
721
- text-overflow: ellipsis;
722
- white-space: nowrap;
723
669
  }
724
670
 
725
- .dsq_tCardSubtitle {
726
- font-size: 12px;
727
- line-height: 18px;
728
- color: var(--dsw-alias-label-tertiary, rgba(0, 0, 0, 0.45));
729
- overflow: hidden;
730
- text-overflow: ellipsis;
731
- white-space: nowrap;
732
- }
733
-
734
- /* Transcript card wrapper: the card itself is a click target that scrolls back
735
- to the marked text. Unlike the composer summary (one collapsed row per
736
- message) each history card shows its own comment, so the fixed 64px height
737
- and the single-line clamps are lifted here. */
738
671
  .dsq_tCardButton {
739
- appearance: none;
740
- display: block;
741
672
  /* Compact cards: several fit in one row before wrapping. */
673
+ display: block;
742
674
  flex: 0 1 auto;
743
675
  width: 230px;
744
676
  max-width: 100%;
745
677
  padding: 0;
746
- border: none;
747
- background: transparent;
748
678
  font: inherit;
749
679
  text-align: left;
750
- cursor: pointer;
751
680
  border-radius: 14px;
752
681
  }
753
682
 
754
683
  .dsq_tCardButton:focus-visible {
755
- outline: 2px solid var(--dsw-static-blue-500, #3b82f6);
684
+ outline: 2px solid var(--dsq-accent);
756
685
  outline-offset: 2px;
757
686
  }
758
687
 
759
- .dsq_tCardButton .dsq_tCard {
688
+ .dsq_tCardButton .dsq_card {
760
689
  height: auto;
761
690
  min-height: 0;
762
691
  align-items: flex-start;
@@ -766,14 +695,14 @@ const CSS = `
766
695
  border-radius: 14px;
767
696
  }
768
697
 
769
- .dsq_tCardButton .dsq_tCardIcon {
698
+ .dsq_tCardButton .dsq_cardIcon {
770
699
  width: 22px;
771
700
  height: 22px;
772
701
  border-radius: 8px;
773
702
  font-size: 11px;
774
703
  }
775
704
 
776
- .dsq_tCardButton .dsq_tCardTitle {
705
+ .dsq_tCardButton .dsq_cardTitle {
777
706
  display: -webkit-box;
778
707
  -webkit-line-clamp: 2;
779
708
  -webkit-box-orient: vertical;
@@ -784,7 +713,7 @@ const CSS = `
784
713
  line-height: 18px;
785
714
  }
786
715
 
787
- .dsq_tCardButton .dsq_tCardSubtitle {
716
+ .dsq_tCardButton .dsq_cardSubtitle {
788
717
  display: -webkit-box;
789
718
  -webkit-line-clamp: 3;
790
719
  -webkit-box-orient: vertical;
@@ -798,24 +727,18 @@ const CSS = `
798
727
 
799
728
  /* A comment is the user's own words — give it more presence than the neutral
800
729
  "no comment" placeholder. */
801
- .dsq_tCardButton .dsq_tCardSubtitle[data-dsq-has-comment='true'] {
802
- color: var(--dsw-alias-label-secondary, rgba(0, 0, 0, 0.65));
730
+ .dsq_cardSubtitle[data-dsq-has-comment='true'] {
731
+ color: var(--dsq-label-2);
803
732
  }
804
733
 
805
734
  /* Replacement user bubble (quote block stripped from display). */
806
735
  .dsq_userRow {
807
- display: flex;
808
- flex-direction: column;
809
- align-items: flex-end;
810
736
  gap: 6px;
811
737
  }
812
738
 
813
739
  .dsq_userStack {
814
740
  min-width: 0;
815
- max-width: min(calc(var(--dsh-chat-content-width, 748px) * 0.702), 82%);
816
- display: flex;
817
- flex-direction: column;
818
- align-items: flex-end;
741
+ max-width: min(calc(var(--dsq-chat-width) * 0.702), 82%);
819
742
  gap: 8px;
820
743
  }
821
744
 
@@ -823,48 +746,28 @@ const CSS = `
823
746
  max-width: 100%;
824
747
  padding: 10px 16px;
825
748
  border-radius: 22px;
826
- background: var(--dsw-specific-bubble, rgba(0, 0, 0, 0.05));
827
- color: var(--dsw-alias-label-primary, rgba(0, 0, 0, 0.88));
828
- font-size: var(--dsh-content-font-size, 14px);
829
- line-height: calc(22px + var(--dsh-content-font-delta, 0px));
749
+ background: var(--dsq-bubble);
750
+ color: var(--dsq-label);
751
+ font-size: var(--dsq-font-size);
752
+ line-height: calc(22px + var(--dsq-font-delta));
830
753
  white-space: pre-wrap;
831
754
  word-break: break-word;
832
755
  }
833
756
 
834
- /* Reserve room for the floating quote cards inside the composer card, so the
835
- attachment rail and the editor flow below them instead of being covered. */
836
- .dsq_cardPad {
837
- padding-top: var(--dsq-quote-pad, 88px) !important;
838
- }
839
-
840
757
  /* Durable message images of a replacement user bubble. */
841
758
  .dsq_userImages {
842
- display: flex;
843
- flex-direction: column;
844
- align-items: flex-end;
845
- gap: 8px;
846
759
  max-width: 100%;
847
760
  }
848
761
 
849
- /* Dark theme: the product flips its palette on body[data-ds-dark-theme], and
850
- every --dsw-alias-* token follows it. Only the card fill is ours. */
851
- body[data-ds-dark-theme] .dsq_card,
852
- body[data-ds-dark-theme] .dsq_tCard {
853
- --dsq-card-fill: var(--dsw-static-neutral-850, #212123);
854
- --dsq-card-hover: var(--dsw-static-neutral-800, #292929);
855
- }
856
-
857
762
  /* Model-written :dsh-annotation{index="N"} chips. */
858
763
  .dsq_annDirective {
859
764
  appearance: none;
860
- display: inline-flex;
861
- align-items: center;
862
765
  margin: 0 2px;
863
766
  padding: 0 6px;
864
- border: 0.5px solid var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.12));
767
+ border: 0.5px solid var(--dsq-line);
865
768
  border-radius: 6px;
866
- background: var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, 0.05));
867
- color: var(--dsw-alias-link, #1677ff);
769
+ background: var(--dsq-fill-hover);
770
+ color: var(--dsq-link);
868
771
  font: inherit;
869
772
  font-size: 12px;
870
773
  line-height: 18px;
@@ -872,14 +775,10 @@ body[data-ds-dark-theme] .dsq_tCard {
872
775
  vertical-align: baseline;
873
776
  }
874
777
 
875
- .dsq_annDirective:hover {
876
- background: var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, 0.08));
877
- }
878
-
879
778
  /* Inline annotation mark in message bodies: highlight underline + ordinal badge. */
880
779
  .dsq_annMark {
881
- --dsq-mark-color: var(--dsw-static-blue-500, #3b82f6);
882
- --dsq-mark-tint: var(--dsw-static-blue-50p, #eaf3ff);
780
+ --dsq-mark-color: var(--dsq-accent);
781
+ --dsq-mark-tint: var(--dsq-accent-tint);
883
782
  background: var(--dsq-mark-tint);
884
783
  text-decoration: underline;
885
784
  text-decoration-style: dashed;
@@ -912,13 +811,36 @@ body[data-ds-dark-theme] .dsq_tCard {
912
811
  }
913
812
 
914
813
  .dsq_annMark:hover {
915
- background: var(--dsw-static-blue-75, #e5f0ff);
814
+ background: var(--dsq-accent-tint-hover);
916
815
  }
917
816
 
918
817
  .dsq_annMarkFlash {
919
818
  animation: dsq-ann-flash 1.4s ease-out;
920
819
  }
921
820
 
821
+ /* Layer 4 — host surface: keyframes, theme flips, and the two rules that reach
822
+ into product-owned DOM. */
823
+
824
+ @keyframes dsq-toolbar-in {
825
+ from {
826
+ opacity: 0;
827
+ transform: translate(-50%, -100%) translateY(4px);
828
+ }
829
+ to {
830
+ opacity: 1;
831
+ transform: translate(-50%, -100%);
832
+ }
833
+ }
834
+
835
+ @keyframes dsq-panel-in {
836
+ from {
837
+ transform: scale(0.96);
838
+ }
839
+ to {
840
+ transform: scale(1);
841
+ }
842
+ }
843
+
922
844
  @keyframes dsq-ann-flash {
923
845
  0% {
924
846
  background: var(--dsq-mark-color);
@@ -930,8 +852,22 @@ body[data-ds-dark-theme] .dsq_tCard {
930
852
  }
931
853
  }
932
854
 
855
+ /* The product flips its palette on body[data-ds-dark-theme], and every
856
+ --dsq-* alias follows it. Only values we own ourselves need restating — the
857
+ transcript card carries .dsq_card too, so one selector covers both. */
858
+ body[data-ds-dark-theme] .dsq_card {
859
+ --dsq-card-fill: var(--dsq-neutral-850);
860
+ --dsq-card-hover: var(--dsq-neutral-800);
861
+ }
862
+
933
863
  body[data-ds-dark-theme] .dsq_annMark {
934
- --dsq-mark-tint: var(--dsw-static-blue-950, #172554);
864
+ --dsq-mark-tint: var(--dsq-accent-tint-dark);
865
+ }
866
+
867
+ /* Reserve room for the floating quote cards inside the composer card, so the
868
+ attachment rail and the editor flow below them instead of being covered. */
869
+ .dsq_cardPad {
870
+ padding-top: var(--dsq-quote-pad, 88px) !important;
935
871
  }
936
872
  `;
937
873
  const TAG_ID = "dsh-select-quote/toolbar.css";
@@ -947,6 +883,7 @@ function ensureToolbarStyles() {
947
883
  const styles = {
948
884
  toolbar: "dsq_toolbar",
949
885
  button: "dsq_button",
886
+ buttonAddComment: "dsq_buttonAddComment",
950
887
  divider: "dsq_divider",
951
888
  status: "dsq_status",
952
889
  cardStack: "dsq_cardStack",
@@ -992,9 +929,6 @@ const styles = {
992
929
  tCardButton: "dsq_tCardButton",
993
930
  tCard: "dsq_tCard",
994
931
  tCardIcon: "dsq_tCardIcon",
995
- tCardBody: "dsq_tCardBody",
996
- tCardTitle: "dsq_tCardTitle",
997
- tCardSubtitle: "dsq_tCardSubtitle",
998
932
  userRow: "dsq_userRow",
999
933
  userStack: "dsq_userStack",
1000
934
  userImages: "dsq_userImages",
@@ -1041,9 +975,17 @@ function collectCleanText(root) {
1041
975
  return entries;
1042
976
  }
1043
977
  function flowItemOf(node) {
1044
- const item = (node instanceof Element ? node : node.parentElement)?.closest(FLOW_ITEM);
978
+ const item = (node instanceof Element ? node : node?.parentElement)?.closest(FLOW_ITEM);
1045
979
  return item instanceof HTMLElement ? item : null;
1046
980
  }
981
+ /**
982
+ * True when the node lives inside a conversation message. Selections in the
983
+ * sidebar, settings, model picker or any other app chrome are not quotes, so
984
+ * the toolbar must stay away from them.
985
+ */
986
+ function isInsideTranscript(node) {
987
+ return flowItemOf(node) !== null;
988
+ }
1047
989
  function pointOffset(entries, container, offset) {
1048
990
  for (const entry of entries) if (entry.node === container) return entry.start + Math.min(offset, entry.end - entry.start);
1049
991
  if (!(container instanceof Element)) return null;
@@ -1383,22 +1325,6 @@ function findElementByText(text) {
1383
1325
  }
1384
1326
  return best;
1385
1327
  }
1386
- //#endregion
1387
- //#region src/client/runtime.ts
1388
- /**
1389
- * Shared runtime handles across slot scopes.
1390
- * The selection toolbar mounts at root (`shell.overlay`) while the annotation
1391
- * panel is session-scoped — pending annotations use one shared store key so
1392
- * both sides always read the same list.
1393
- */
1394
- const runtime = {};
1395
- function setRuntimeSession(sessionId) {
1396
- if (sessionId) runtime.sessionId = sessionId;
1397
- }
1398
- /** Store partition for pending annotations (single active composer). */
1399
- function annotationStoreKey() {
1400
- return "pending";
1401
- }
1402
1328
  const DRAFT_MARKER_RE = /\u200B/g;
1403
1329
  /** Plain text from Chat user-node content blocks (string or block array). */
1404
1330
  function contentBlocksToText(content) {
@@ -1591,11 +1517,27 @@ function scanAnnotatedMessage(text) {
1591
1517
  };
1592
1518
  }
1593
1519
  //#endregion
1520
+ //#region src/client/runtime.ts
1521
+ /**
1522
+ * Shared runtime handles across slot scopes.
1523
+ *
1524
+ * The selection toolbar mounts at root (`shell.overlay`) while the annotation
1525
+ * panel is session-scoped, so both sides read one partition keyed by the
1526
+ * session the host minted for the active composer.
1527
+ */
1528
+ const runtime = {};
1529
+ function setRuntimeSession(sessionId) {
1530
+ if (sessionId) runtime.sessionId = sessionId;
1531
+ }
1532
+ //#endregion
1594
1533
  //#region src/client/state/annotation-store.ts
1595
1534
  /** Per-session pending selection annotations (composer-side store). */
1596
1535
  const EMPTY$1 = [];
1536
+ /** Bucket for annotations added before a session id is known (never persisted). */
1537
+ const MEMORY_KEY = "pending";
1597
1538
  const bySession = /* @__PURE__ */ new Map();
1598
1539
  const listeners = /* @__PURE__ */ new Set();
1540
+ let persistence = null;
1599
1541
  function notify() {
1600
1542
  for (const listener of [...listeners]) listener();
1601
1543
  }
@@ -1605,12 +1547,25 @@ function subscribeAnnotations(listener) {
1605
1547
  listeners.delete(listener);
1606
1548
  };
1607
1549
  }
1608
- function key(sessionId) {
1609
- return sessionId ?? annotationStoreKey();
1550
+ /**
1551
+ * Store partition: the live session when one is bound, otherwise a memory-only
1552
+ * bucket. The host always mints a session before the composer accepts text, so
1553
+ * the bucket is the "cannot happen" path and never reaches storage.
1554
+ */
1555
+ function partition(sessionId) {
1556
+ return sessionId ?? runtime.sessionId ?? MEMORY_KEY;
1557
+ }
1558
+ /** Attach the persistence port (null detaches it). */
1559
+ function setAnnotationPersistence(port) {
1560
+ persistence = port;
1561
+ }
1562
+ function persist(partitionKey) {
1563
+ if (!persistence || partitionKey === MEMORY_KEY) return;
1564
+ persistence.save(partitionKey, bySession.get(partitionKey) ?? EMPTY$1);
1610
1565
  }
1611
1566
  /** Append one selection; an identical text+comment pair is reused. */
1612
1567
  function saveAnnotation(sessionId, input) {
1613
- const k = key(sessionId);
1568
+ const k = partition(sessionId);
1614
1569
  const comment = input.comment?.trim() ?? "";
1615
1570
  const current = bySession.get(k) ?? EMPTY$1;
1616
1571
  const duplicate = current.find((item) => item.text === input.text && (item.comment ?? "") === comment);
@@ -1622,14 +1577,15 @@ function saveAnnotation(sessionId, input) {
1622
1577
  sources: input.sources?.length ? input.sources : [{ text: input.text }]
1623
1578
  };
1624
1579
  bySession.set(k, [...current, annotation]);
1580
+ persist(k);
1625
1581
  notify();
1626
1582
  return annotation;
1627
1583
  }
1628
1584
  function getAnnotations(sessionId) {
1629
- return bySession.get(key(sessionId)) ?? EMPTY$1;
1585
+ return bySession.get(partition(sessionId)) ?? EMPTY$1;
1630
1586
  }
1631
1587
  function updateAnnotationComment(sessionId, id, comment) {
1632
- const k = key(sessionId);
1588
+ const k = partition(sessionId);
1633
1589
  const current = bySession.get(k);
1634
1590
  if (!current) return;
1635
1591
  const next = current.map((item) => {
@@ -1644,20 +1600,59 @@ function updateAnnotationComment(sessionId, id, comment) {
1644
1600
  };
1645
1601
  });
1646
1602
  bySession.set(k, next);
1603
+ persist(k);
1647
1604
  notify();
1648
1605
  }
1649
1606
  function removeAnnotation(sessionId, id) {
1650
- const k = key(sessionId);
1607
+ const k = partition(sessionId);
1651
1608
  const current = bySession.get(k);
1652
1609
  if (!current) return;
1653
1610
  const next = current.filter((item) => item.id !== id);
1654
1611
  if (next.length === current.length) return;
1655
1612
  if (next.length === 0) bySession.delete(k);
1656
1613
  else bySession.set(k, next);
1614
+ if (next.length === 0 && k !== MEMORY_KEY) persistence?.clear(k);
1615
+ else persist(k);
1657
1616
  notify();
1658
1617
  }
1659
1618
  function clearAnnotations(sessionId) {
1660
- if (bySession.delete(key(sessionId))) notify();
1619
+ const k = partition(sessionId);
1620
+ if (!bySession.delete(k)) return;
1621
+ if (k !== MEMORY_KEY) persistence?.clear(k);
1622
+ notify();
1623
+ }
1624
+ /**
1625
+ * Bind the active session and adopt whatever it left unsent.
1626
+ *
1627
+ * Called from render-phase bridges, so adoption is deferred to a microtask:
1628
+ * the resulting notify() must not land inside another component's render.
1629
+ * Re-binding the same session is a no-op.
1630
+ */
1631
+ function bindAnnotationSession(sessionId) {
1632
+ if (!sessionId || sessionId === runtime.sessionId) return;
1633
+ setRuntimeSession(sessionId);
1634
+ queueMicrotask(() => adoptPersistedAnnotations(sessionId));
1635
+ }
1636
+ /**
1637
+ * Adopt persisted annotations for a session.
1638
+ *
1639
+ * In-memory entries win on conflict — they can only be newer, because anything
1640
+ * added after the bind is persisted immediately. Annotations parked in the
1641
+ * memory-only bucket (added before the bind) move over as well.
1642
+ */
1643
+ function adoptPersistedAnnotations(sessionId) {
1644
+ if (!persistence) return;
1645
+ const stored = persistence.load(sessionId);
1646
+ const current = bySession.get(sessionId) ?? EMPTY$1;
1647
+ const orphaned = bySession.get(MEMORY_KEY) ?? EMPTY$1;
1648
+ const merged = /* @__PURE__ */ new Map();
1649
+ for (const item of stored) merged.set(item.id, item);
1650
+ for (const item of [...current, ...orphaned]) merged.set(item.id, item);
1651
+ const next = [...merged.values()];
1652
+ if (next.length === current.length && next.every((item, i) => item.id === current[i]?.id) && orphaned.length === 0) return;
1653
+ bySession.set(sessionId, next);
1654
+ if (orphaned.length > 0) bySession.delete(MEMORY_KEY);
1655
+ notify();
1661
1656
  }
1662
1657
  function annotationTitle(annotation) {
1663
1658
  return previewTitle(annotation.text);
@@ -1966,23 +1961,23 @@ function AnnotationCardView({ title, text, comment, variant, index, onRemove, on
1966
1961
  const composer = variant === "composer";
1967
1962
  const subtitle = comment ? composer ? t("card.commentSubtitle", { comment }) : comment : composer ? t("common.selectedText") : t("summary.noComment");
1968
1963
  return (0, react_jsx_runtime.jsxs)("div", {
1969
- className: composer ? styles.card : styles.tCard,
1964
+ className: cx(A.box, A.row, styles.card, !composer && styles.tCard),
1970
1965
  role: "group",
1971
1966
  "aria-label": index ? t("card.ariaIndexed", { index }) : t("card.ariaPlain"),
1972
1967
  children: [
1973
1968
  (0, react_jsx_runtime.jsx)("div", {
1974
- className: composer ? styles.cardIcon : styles.tCardIcon,
1969
+ className: cx(A.keep, A.gridMid, styles.cardIcon, !composer && styles.tCardIcon),
1975
1970
  "aria-hidden": true,
1976
1971
  children: index ? String(index) : "AI"
1977
1972
  }),
1978
1973
  (0, react_jsx_runtime.jsxs)("div", {
1979
- className: composer ? styles.cardBody : styles.tCardBody,
1974
+ className: cx(A.grow, A.col, styles.cardBody),
1980
1975
  children: [(0, react_jsx_runtime.jsx)("div", {
1981
- className: composer ? styles.cardTitle : styles.tCardTitle,
1976
+ className: cx(A.ell, styles.cardTitle),
1982
1977
  title: text,
1983
1978
  children: title
1984
1979
  }), (0, react_jsx_runtime.jsx)("div", {
1985
- className: composer ? styles.cardSubtitle : styles.tCardSubtitle,
1980
+ className: cx(A.ell, styles.cardSubtitle),
1986
1981
  ...comment ? {
1987
1982
  title: comment,
1988
1983
  "data-dsq-has-comment": "true"
@@ -1992,14 +1987,14 @@ function AnnotationCardView({ title, text, comment, variant, index, onRemove, on
1992
1987
  }),
1993
1988
  composer && onEditComment ? (0, react_jsx_runtime.jsx)("button", {
1994
1989
  type: "button",
1995
- className: styles.cardAction,
1990
+ className: cx(A.plain, A.gridMid, styles.cardAction),
1996
1991
  "aria-label": t("card.editComment"),
1997
1992
  onClick: onEditComment,
1998
1993
  children: "✎"
1999
1994
  }) : null,
2000
1995
  composer && onRemove ? (0, react_jsx_runtime.jsx)("button", {
2001
1996
  type: "button",
2002
- className: styles.cardClose,
1997
+ className: cx(A.plain, A.gridMid, styles.cardClose),
2003
1998
  "aria-label": t("card.remove"),
2004
1999
  onClick: onRemove,
2005
2000
  children: "×"
@@ -2183,16 +2178,16 @@ function AnnotationSummary({ count, items, onRemoveAll, onItemSelect, onItemRemo
2183
2178
  onPointerEnter: openPop,
2184
2179
  onPointerLeave,
2185
2180
  children: (0, react_jsx_runtime.jsx)("div", {
2186
- className: styles.summaryHoverInner,
2181
+ className: cx(A.col, styles.summaryHoverInner),
2187
2182
  children: items.map((item, i) => {
2188
2183
  const isEditing = editing === i;
2189
2184
  return (0, react_jsx_runtime.jsxs)("div", {
2190
2185
  className: styles.summaryItem,
2191
2186
  children: [(0, react_jsx_runtime.jsxs)("div", {
2192
- className: styles.summaryItemHead,
2187
+ className: cx(A.row, styles.summaryItemHead),
2193
2188
  children: [
2194
2189
  (0, react_jsx_runtime.jsx)("div", {
2195
- className: styles.summaryItemBadge,
2190
+ className: cx(A.keep, A.gridMid, styles.summaryItemBadge),
2196
2191
  "aria-hidden": true,
2197
2192
  children: String(i + 1)
2198
2193
  }),
@@ -2201,16 +2196,16 @@ function AnnotationSummary({ count, items, onRemoveAll, onItemSelect, onItemRemo
2201
2196
  children: t("common.selectedText")
2202
2197
  }),
2203
2198
  !isEditing && (onItemRemove || onItemEdit) ? (0, react_jsx_runtime.jsxs)("div", {
2204
- className: styles.summaryItemActions,
2199
+ className: cx(A.row, styles.summaryItemActions),
2205
2200
  children: [onItemEdit ? (0, react_jsx_runtime.jsx)("button", {
2206
2201
  type: "button",
2207
- className: styles.summaryItemAction,
2202
+ className: cx(A.plain, A.gridMid, styles.summaryItemAction),
2208
2203
  "aria-label": t("summary.edit", { index: i + 1 }),
2209
2204
  onClick: () => startEdit(i),
2210
2205
  children: (0, react_jsx_runtime.jsx)(LucideIcon, { name: "pencil" })
2211
2206
  }) : null, onItemRemove ? (0, react_jsx_runtime.jsx)("button", {
2212
2207
  type: "button",
2213
- className: styles.summaryItemAction,
2208
+ className: cx(A.plain, A.gridMid, styles.summaryItemAction),
2214
2209
  "aria-label": t("summary.remove", { index: i + 1 }),
2215
2210
  onClick: () => onItemRemove(i, item),
2216
2211
  children: (0, react_jsx_runtime.jsx)(LucideIcon, { name: "trash-2" })
@@ -2230,7 +2225,7 @@ function AnnotationSummary({ count, items, onRemoveAll, onItemSelect, onItemRemo
2230
2225
  }),
2231
2226
  (0, react_jsx_runtime.jsx)("textarea", {
2232
2227
  ref: editInputRef,
2233
- className: styles.annEditInput,
2228
+ className: cx(A.box, styles.annEditInput),
2234
2229
  placeholder: t("common.commentPlaceholder"),
2235
2230
  value: editDraft,
2236
2231
  onChange: (e) => setEditDraft(e.target.value),
@@ -2247,10 +2242,10 @@ function AnnotationSummary({ count, items, onRemoveAll, onItemSelect, onItemRemo
2247
2242
  }
2248
2243
  }),
2249
2244
  (0, react_jsx_runtime.jsxs)("div", {
2250
- className: styles.annEditActions,
2245
+ className: cx(A.row, styles.annEditActions),
2251
2246
  children: [(0, react_jsx_runtime.jsx)("button", {
2252
2247
  type: "button",
2253
- className: styles.annEditCancel,
2248
+ className: cx(A.plain, styles.annEditCancel),
2254
2249
  onClick: () => setEditing(null),
2255
2250
  children: t("common.cancel")
2256
2251
  }), (0, react_jsx_runtime.jsx)("button", {
@@ -2265,7 +2260,7 @@ function AnnotationSummary({ count, items, onRemoveAll, onItemSelect, onItemRemo
2265
2260
  className: styles.summaryItemMain,
2266
2261
  children: (0, react_jsx_runtime.jsxs)("button", {
2267
2262
  type: "button",
2268
- className: styles.summaryItemButton,
2263
+ className: cx(A.plain, styles.summaryItemButton),
2269
2264
  onClick: () => {
2270
2265
  onItemSelect?.(i, item);
2271
2266
  closePop();
@@ -2292,7 +2287,7 @@ function AnnotationSummary({ count, items, onRemoveAll, onItemSelect, onItemRemo
2292
2287
  }) : null;
2293
2288
  return (0, react_jsx_runtime.jsxs)("div", {
2294
2289
  ref: rootRef,
2295
- className: styles.summary,
2290
+ className: cx(A.row, styles.summary),
2296
2291
  tabIndex: 0,
2297
2292
  "data-selection-annotation-summary": "true",
2298
2293
  "aria-label": t("summary.aria", { count }),
@@ -2304,12 +2299,12 @@ function AnnotationSummary({ count, items, onRemoveAll, onItemSelect, onItemRemo
2304
2299
  onKeyDown,
2305
2300
  children: [
2306
2301
  (0, react_jsx_runtime.jsx)("div", {
2307
- className: styles.summaryIcon,
2302
+ className: cx(A.keep, A.gridMid, styles.summaryIcon),
2308
2303
  "aria-hidden": true,
2309
2304
  children: String(count)
2310
2305
  }),
2311
2306
  (0, react_jsx_runtime.jsxs)("div", {
2312
- className: styles.summaryBody,
2307
+ className: cx(A.grow, styles.summaryBody),
2313
2308
  children: [(0, react_jsx_runtime.jsx)("div", {
2314
2309
  className: styles.summaryTitle,
2315
2310
  children: t("summary.title", { count })
@@ -2320,7 +2315,7 @@ function AnnotationSummary({ count, items, onRemoveAll, onItemSelect, onItemRemo
2320
2315
  }),
2321
2316
  onRemoveAll ? (0, react_jsx_runtime.jsx)("button", {
2322
2317
  type: "button",
2323
- className: styles.cardClose,
2318
+ className: cx(A.plain, A.gridMid, styles.cardClose),
2324
2319
  "aria-label": t("summary.removeAll"),
2325
2320
  onClick: onRemoveAll,
2326
2321
  children: (0, react_jsx_runtime.jsx)(LucideIcon, {
@@ -2413,6 +2408,31 @@ function useSendIntercept({ itemCount, draftRef, setDraft, onFolded }) {
2413
2408
  //#region src/client/composer/AnnotationPanel.tsx
2414
2409
  const EMPTY = [];
2415
2410
  /**
2411
+ * True while an IME composition is in flight.
2412
+ *
2413
+ * Composition events bubble, so a capture listener on the document sees them
2414
+ * wherever the composer lives. Anything that writes the draft must check this:
2415
+ * replacing editor content mid-composition drops the composing text.
2416
+ */
2417
+ function useComposing() {
2418
+ const composing = (0, react.useRef)(false);
2419
+ (0, react.useEffect)(() => {
2420
+ const onStart = () => {
2421
+ composing.current = true;
2422
+ };
2423
+ const onEnd = () => {
2424
+ composing.current = false;
2425
+ };
2426
+ document.addEventListener("compositionstart", onStart, true);
2427
+ document.addEventListener("compositionend", onEnd, true);
2428
+ return () => {
2429
+ document.removeEventListener("compositionstart", onStart, true);
2430
+ document.removeEventListener("compositionend", onEnd, true);
2431
+ };
2432
+ }, []);
2433
+ return composing;
2434
+ }
2435
+ /**
2416
2436
  * Composer annotation summary (`conversation.input.overlay`).
2417
2437
  *
2418
2438
  * Pending selections never enter the editor as text: the summary card is the
@@ -2426,6 +2446,7 @@ function AnnotationPanel({ useInput, inputActions, sessionId }) {
2426
2446
  const draftRef = (0, react.useRef)(draft);
2427
2447
  const actionsRef = (0, react.useRef)(inputActions);
2428
2448
  const sessionRef = (0, react.useRef)(sessionId);
2449
+ const composing = useComposing();
2429
2450
  draftRef.current = draft;
2430
2451
  actionsRef.current = inputActions;
2431
2452
  sessionRef.current = sessionId;
@@ -2436,7 +2457,7 @@ function AnnotationPanel({ useInput, inputActions, sessionId }) {
2436
2457
  setItems(getAnnotations());
2437
2458
  }, []);
2438
2459
  (0, react.useEffect)(() => {
2439
- if (sessionId) setRuntimeSession(sessionId);
2460
+ bindAnnotationSession(sessionId);
2440
2461
  }, [sessionId]);
2441
2462
  (0, react.useEffect)(() => {
2442
2463
  ensureToolbarStyles();
@@ -2472,6 +2493,19 @@ function AnnotationPanel({ useInput, inputActions, sessionId }) {
2472
2493
  setDraft,
2473
2494
  onFolded: () => setItems(EMPTY)
2474
2495
  });
2496
+ (0, react.useEffect)(() => {
2497
+ if (composing.current) return;
2498
+ if (items.length === 0) {
2499
+ if (draft.includes("​")) setDraft(stripDraftMarker(draft));
2500
+ return;
2501
+ }
2502
+ if (!draft.includes("​") && draft.trim() === "") setDraft(withDraftMarker(draft));
2503
+ }, [
2504
+ items,
2505
+ draft,
2506
+ setDraft,
2507
+ composing
2508
+ ]);
2475
2509
  const onRemoveAll = (0, react.useCallback)(() => {
2476
2510
  clearAnnotations();
2477
2511
  setItems(EMPTY);
@@ -2501,7 +2535,7 @@ function AnnotationPanel({ useInput, inputActions, sessionId }) {
2501
2535
  if (!sessionId || items.length === 0) return null;
2502
2536
  return (0, react_jsx_runtime.jsx)("div", {
2503
2537
  ref: stackRef,
2504
- className: styles.cardStack,
2538
+ className: cx(A.col, styles.cardStack),
2505
2539
  children: (0, react_jsx_runtime.jsx)(AnnotationSummary, {
2506
2540
  count: items.length,
2507
2541
  items: items.map((item) => ({
@@ -2571,7 +2605,8 @@ function toolbarPosition(rect) {
2571
2605
  };
2572
2606
  }
2573
2607
  /**
2574
- * Read the live selection when it is a non-empty range outside the composer.
2608
+ * Read the live selection when it is a non-empty range inside a conversation
2609
+ * message and outside the composer / our own chrome.
2575
2610
  */
2576
2611
  function readSelectionSnapshot(toolbarRoot) {
2577
2612
  const selection = window.getSelection();
@@ -2580,6 +2615,7 @@ function readSelectionSnapshot(toolbarRoot) {
2580
2615
  if (!anchorNode || !focusNode) return null;
2581
2616
  if (isInsideComposerInput(anchorNode) || isInsideComposerInput(focusNode)) return null;
2582
2617
  if (toolbarRoot?.contains(anchorNode) || toolbarRoot?.contains(focusNode)) return null;
2618
+ if (!isInsideTranscript(anchorNode) || !isInsideTranscript(focusNode)) return null;
2583
2619
  const text = selection.toString().replace(/\r\n/g, "\n").trim();
2584
2620
  if (!text) return null;
2585
2621
  const rect = getRangeFirstLineRect(selection.getRangeAt(0));
@@ -2628,7 +2664,7 @@ function SelectionToolbar({ sessionId }) {
2628
2664
  activeRef.current = active;
2629
2665
  (0, react.useEffect)(() => {
2630
2666
  ensureToolbarStyles();
2631
- if (sessionId) setRuntimeSession(sessionId);
2667
+ if (sessionId) bindAnnotationSession(sessionId);
2632
2668
  }, [sessionId]);
2633
2669
  const hide = (0, react.useCallback)(() => {
2634
2670
  setActive(null);
@@ -2791,7 +2827,7 @@ function SelectionToolbar({ sessionId }) {
2791
2827
  return (0, react_dom.createPortal)((0, react_jsx_runtime.jsxs)(react.Fragment, { children: [
2792
2828
  (0, react_jsx_runtime.jsx)("div", {
2793
2829
  ref: toolbarRef,
2794
- className: styles.toolbar,
2830
+ className: cx(A.row, styles.toolbar),
2795
2831
  style: toolbarStyle,
2796
2832
  role: "toolbar",
2797
2833
  "aria-label": t("toolbar.aria"),
@@ -2820,7 +2856,7 @@ function SelectionToolbar({ sessionId }) {
2820
2856
  children: [
2821
2857
  (0, react_jsx_runtime.jsx)("button", {
2822
2858
  type: "button",
2823
- className: styles.button,
2859
+ className: cx(A.plain, A.inlineRow, styles.button),
2824
2860
  onClick: () => void handleCopy(),
2825
2861
  children: [(0, react_jsx_runtime.jsx)(LucideIcon, {
2826
2862
  name: "copy",
@@ -2833,7 +2869,7 @@ function SelectionToolbar({ sessionId }) {
2833
2869
  }),
2834
2870
  (0, react_jsx_runtime.jsx)("button", {
2835
2871
  type: "button",
2836
- className: styles.button,
2872
+ className: cx(A.plain, A.inlineRow, styles.button),
2837
2873
  onClick: openComment,
2838
2874
  children: [(0, react_jsx_runtime.jsx)(LucideIcon, {
2839
2875
  name: "plus",
@@ -2845,14 +2881,14 @@ function SelectionToolbar({ sessionId }) {
2845
2881
  }),
2846
2882
  phase === "comment" ? (0, react_jsx_runtime.jsxs)("div", {
2847
2883
  ref: commentWrapRef,
2848
- className: styles.commentPop,
2884
+ className: cx(A.col, styles.commentPop),
2849
2885
  style: void 0,
2850
2886
  role: "dialog",
2851
2887
  "aria-label": t("toolbar.commentDialogAria"),
2852
2888
  onPointerDown: handleToolbarPointerDown,
2853
2889
  children: [(0, react_jsx_runtime.jsx)("textarea", {
2854
2890
  ref: commentRef,
2855
- className: styles.commentInput,
2891
+ className: cx(A.box, styles.commentInput),
2856
2892
  placeholder: t("common.commentPlaceholder"),
2857
2893
  value: comment,
2858
2894
  onChange: (e) => setComment(e.target.value),
@@ -2871,12 +2907,12 @@ function SelectionToolbar({ sessionId }) {
2871
2907
  className: styles.commentActions,
2872
2908
  children: [(0, react_jsx_runtime.jsx)("button", {
2873
2909
  type: "button",
2874
- className: styles.button,
2910
+ className: cx(A.plain, A.inlineRow, styles.button),
2875
2911
  onClick: () => commitAnnotation(void 0),
2876
2912
  children: t("toolbar.skipComment")
2877
2913
  }), (0, react_jsx_runtime.jsx)("button", {
2878
2914
  type: "button",
2879
- className: styles.button,
2915
+ className: cx(A.plain, A.inlineRow, styles.button, styles.buttonAddComment),
2880
2916
  disabled: !comment.trim(),
2881
2917
  onClick: () => commitAnnotation(comment),
2882
2918
  children: t("toolbar.addComment")
@@ -2898,6 +2934,7 @@ function SelectionToolbar({ sessionId }) {
2898
2934
  * Display-only decoration — durable message text is never rewritten.
2899
2935
  */
2900
2936
  const DECO = "dsq-ann-directive";
2937
+ const DECO_KEY = "dsqAnnDirective";
2901
2938
  function decorateTextNode(node) {
2902
2939
  const value = node.nodeValue;
2903
2940
  if (!value || !value.includes(":dsh-annotation{")) return;
@@ -2916,9 +2953,9 @@ function decorateTextNode(node) {
2916
2953
  const index = Number(m[1]);
2917
2954
  const chip = document.createElement("button");
2918
2955
  chip.type = "button";
2919
- chip.className = "dsq_annDirective";
2920
- chip.dataset[DECO] = String(index);
2956
+ chip.className = cx(styles.annDirective, A.inlineRow);
2921
2957
  chip.setAttribute("data-dsq-deco", "true");
2958
+ chip.dataset[DECO_KEY] = String(index);
2922
2959
  chip.textContent = t("chip.annotation", { index });
2923
2960
  chip.addEventListener("click", () => {
2924
2961
  const mark = findAnnotationMarkBefore(index, chip);
@@ -2956,7 +2993,7 @@ function refreshDirectiveChips(root) {
2956
2993
  const el = root ?? (typeof document === "undefined" ? void 0 : document.body);
2957
2994
  if (!el) return;
2958
2995
  for (const chip of el.querySelectorAll(`[data-${DECO}]`)) {
2959
- const index = Number(chip.dataset[DECO]);
2996
+ const index = Number(chip.dataset[DECO_KEY]);
2960
2997
  if (Number.isFinite(index)) chip.textContent = t("chip.annotation", { index });
2961
2998
  }
2962
2999
  }
@@ -3017,7 +3054,7 @@ function TranscriptQuoteCard({ node }) {
3017
3054
  className: styles.tCardStack,
3018
3055
  "data-dsq-deco": "true",
3019
3056
  children: quotes.map((quote, index) => (0, react_jsx_runtime.jsx)("div", {
3020
- className: styles.tCardButton,
3057
+ className: cx(A.plain, styles.tCardButton),
3021
3058
  role: "button",
3022
3059
  tabIndex: 0,
3023
3060
  "aria-label": quote.title,
@@ -3054,7 +3091,8 @@ function contentImages(content) {
3054
3091
  * Replacement for the built-in `user` Chat node view.
3055
3092
  *
3056
3093
  * Strips the `<response-annotations>` protocol block from the bubble **and**
3057
- * renders one card per annotation right under it.
3094
+ * renders one card per annotation right above it — the same order the composer
3095
+ * shows them in, so the sent message reads like the draft did.
3058
3096
  *
3059
3097
  * Why the cards live here instead of on a `select-quote` Chat node:
3060
3098
  * `conversation.chat.node` is gated by the shipped `TURN_PROCESS_INDEPENDENT_KINDS`
@@ -3074,12 +3112,12 @@ function UserMessageDisplay({ node, renderMessageImages }) {
3074
3112
  const images = contentImages(node.data?.content);
3075
3113
  if (!text && images.length === 0 && quotes.length === 0) return null;
3076
3114
  return (0, react_jsx_runtime.jsx)("div", {
3077
- className: styles.userRow,
3115
+ className: cx(A.colEnd, styles.userRow),
3078
3116
  children: (0, react_jsx_runtime.jsxs)("div", {
3079
- className: styles.userStack,
3117
+ className: cx(A.colEnd, styles.userStack),
3080
3118
  children: [
3081
3119
  images.length > 0 && renderMessageImages !== void 0 ? (0, react_jsx_runtime.jsx)("div", {
3082
- className: styles.userImages,
3120
+ className: cx(A.colEnd, styles.userImages),
3083
3121
  "data-message-attachments": true,
3084
3122
  children: renderMessageImages({
3085
3123
  images,
@@ -3087,16 +3125,214 @@ function UserMessageDisplay({ node, renderMessageImages }) {
3087
3125
  compact: images.length > 1
3088
3126
  })
3089
3127
  }, "images") : null,
3128
+ quotes.length > 0 ? (0, react_jsx_runtime.jsx)(TranscriptQuoteCard, { node: { data: { quotes } } }, "quotes") : null,
3090
3129
  text ? (0, react_jsx_runtime.jsx)("div", {
3091
3130
  className: styles.userBubble,
3092
3131
  children: text
3093
- }, "bubble") : null,
3094
- quotes.length > 0 ? (0, react_jsx_runtime.jsx)(TranscriptQuoteCard, { node: { data: { quotes } } }, "quotes") : null
3132
+ }, "bubble") : null
3095
3133
  ]
3096
3134
  })
3097
3135
  });
3098
3136
  }
3099
3137
  //#endregion
3138
+ //#region src/client/state/annotation-persistence.ts
3139
+ /** localStorage prefix; a session id is appended as `.<sessionId>`. */
3140
+ const ANNOTATION_KEY_PREFIX = "dsh.select-quote.annotations";
3141
+ const PAYLOAD_VERSION = 1;
3142
+ function keyForSession(sessionId) {
3143
+ return `${ANNOTATION_KEY_PREFIX}.${sessionId}`;
3144
+ }
3145
+ function sessionIdOfKey(key) {
3146
+ const prefix = `${ANNOTATION_KEY_PREFIX}.`;
3147
+ return key.startsWith(prefix) ? key.slice(prefix.length) : null;
3148
+ }
3149
+ function isAnnotation(value) {
3150
+ if (!value || typeof value !== "object") return false;
3151
+ const item = value;
3152
+ return typeof item.id === "string" && typeof item.text === "string" && Array.isArray(item.sources);
3153
+ }
3154
+ /** Tolerant read: a corrupt or foreign payload degrades to "nothing stored". */
3155
+ function itemsOf(value) {
3156
+ if (!value || typeof value !== "object") return [];
3157
+ const payload = value;
3158
+ if (!Array.isArray(payload.items)) return [];
3159
+ return payload.items.filter(isAnnotation);
3160
+ }
3161
+ function encode(items, now) {
3162
+ return JSON.stringify({
3163
+ v: PAYLOAD_VERSION,
3164
+ updatedAt: now,
3165
+ items
3166
+ });
3167
+ }
3168
+ function decode(raw) {
3169
+ if (raw === null) return [];
3170
+ try {
3171
+ return itemsOf(JSON.parse(raw));
3172
+ } catch {
3173
+ return [];
3174
+ }
3175
+ }
3176
+ /** One warn per session, never a throw: a full quota must not break typing. */
3177
+ let quotaWarned = false;
3178
+ function warnQuota() {
3179
+ if (quotaWarned) return;
3180
+ quotaWarned = true;
3181
+ console.warn("dsh-select-quote: annotation persistence disabled (storage write failed)");
3182
+ }
3183
+ /**
3184
+ * Direct localStorage backend (the documented fallback). Writes are wrapped so
3185
+ * a quota failure degrades to memory-only instead of breaking the composer.
3186
+ */
3187
+ function createStoragePersistence(storage) {
3188
+ let degraded = false;
3189
+ return {
3190
+ load: (sessionId) => decode(storage.getItem(keyForSession(sessionId))),
3191
+ save: (sessionId, items) => {
3192
+ if (degraded) return;
3193
+ try {
3194
+ storage.setItem(keyForSession(sessionId), encode(items, Date.now()));
3195
+ } catch {
3196
+ degraded = true;
3197
+ warnQuota();
3198
+ }
3199
+ },
3200
+ clear: (sessionId) => {
3201
+ try {
3202
+ storage.removeItem(keyForSession(sessionId));
3203
+ } catch {}
3204
+ },
3205
+ prune: (now) => pruneSessions(storage, now)
3206
+ };
3207
+ }
3208
+ /**
3209
+ * Host store backend: the same `defineStore` the composer draft uses, so the
3210
+ * write path, per-session keying and failure semantics are the host's.
3211
+ */
3212
+ function createHostStorePersistence(defineStore, storage) {
3213
+ const handle = defineStore({
3214
+ init: () => ({
3215
+ v: PAYLOAD_VERSION,
3216
+ updatedAt: 0,
3217
+ items: []
3218
+ }),
3219
+ persist: ANNOTATION_KEY_PREFIX,
3220
+ actions: { replace(draft, items, now) {
3221
+ draft.items = [...items];
3222
+ draft.updatedAt = now;
3223
+ } }
3224
+ });
3225
+ const instances = /* @__PURE__ */ new Map();
3226
+ const instanceOf = (sessionId) => {
3227
+ const cached = instances.get(sessionId);
3228
+ if (cached) return cached;
3229
+ const instance = handle.create(sessionId);
3230
+ instances.set(sessionId, instance);
3231
+ return instance;
3232
+ };
3233
+ return {
3234
+ load: (sessionId) => itemsOf(instanceOf(sessionId).getSnapshot()),
3235
+ save: (sessionId, items) => instanceOf(sessionId).actions.replace(items, Date.now()),
3236
+ clear: (sessionId) => {
3237
+ instanceOf(sessionId).clearPersisted();
3238
+ instances.delete(sessionId);
3239
+ },
3240
+ prune: (now) => {
3241
+ const removed = pruneSessions(storage, now);
3242
+ for (const sessionId of removed) instances.delete(sessionId);
3243
+ return removed;
3244
+ }
3245
+ };
3246
+ }
3247
+ /** Retention: newest `maxSessions` within `maxAgeMs`, by payload timestamp. */
3248
+ function pruneSessions(storage, now = Date.now(), options = {}) {
3249
+ const maxSessions = options.maxSessions ?? 20;
3250
+ const maxAgeMs = options.maxAgeMs ?? 6048e5;
3251
+ const rows = [];
3252
+ for (const key of storage.keys()) {
3253
+ const sessionId = sessionIdOfKey(key);
3254
+ if (sessionId === null) continue;
3255
+ const raw = storage.getItem(key);
3256
+ let updatedAt = 0;
3257
+ try {
3258
+ const parsed = JSON.parse(raw ?? "null");
3259
+ if (parsed && typeof parsed.updatedAt === "number") updatedAt = parsed.updatedAt;
3260
+ } catch {
3261
+ updatedAt = 0;
3262
+ }
3263
+ rows.push({
3264
+ sessionId,
3265
+ updatedAt
3266
+ });
3267
+ }
3268
+ const removed = /* @__PURE__ */ new Set();
3269
+ for (const row of rows) if (now - row.updatedAt > maxAgeMs) removed.add(row.sessionId);
3270
+ rows.sort((a, b) => b.updatedAt - a.updatedAt);
3271
+ for (const row of rows.slice(maxSessions)) removed.add(row.sessionId);
3272
+ for (const sessionId of removed) storage.removeItem(keyForSession(sessionId));
3273
+ return [...removed];
3274
+ }
3275
+ function createNoopPersistence() {
3276
+ return {
3277
+ load: () => [],
3278
+ save: () => {},
3279
+ clear: () => {},
3280
+ prune: () => []
3281
+ };
3282
+ }
3283
+ //#endregion
3284
+ //#region src/client/state/annotation-storage.ts
3285
+ /**
3286
+ * Backend selection for annotation persistence.
3287
+ *
3288
+ * Kept apart from `annotation-persistence.ts` because reaching the host's
3289
+ * client-store needs a guarded `require`, and that is the one thing the pure
3290
+ * module cannot do — verify-bundle imports the pure module straight into Node,
3291
+ * where a `declare` statement is not valid JavaScript.
3292
+ *
3293
+ * Preference order: the host store (same engine and keying as the composer
3294
+ * draft), plain localStorage, and a no-op when the environment has no storage
3295
+ * at all.
3296
+ */
3297
+ /** The host package is a platform seed word; older hosts may not carry it. */
3298
+ const HOST_STORE_MODULE = "@deepseek-ai/dsh-client-store";
3299
+ function hostDefineStore() {
3300
+ try {
3301
+ const loaded = require(HOST_STORE_MODULE);
3302
+ return typeof loaded?.defineStore === "function" ? loaded.defineStore : null;
3303
+ } catch {
3304
+ return null;
3305
+ }
3306
+ }
3307
+ function browserStorage() {
3308
+ try {
3309
+ const storage = globalThis.localStorage;
3310
+ if (!storage) return null;
3311
+ return {
3312
+ getItem: (key) => storage.getItem(key),
3313
+ setItem: (key, value) => storage.setItem(key, value),
3314
+ removeItem: (key) => storage.removeItem(key),
3315
+ keys: () => {
3316
+ const out = [];
3317
+ for (let i = 0; i < storage.length; i += 1) {
3318
+ const key = storage.key(i);
3319
+ if (key !== null) out.push(key);
3320
+ }
3321
+ return out;
3322
+ }
3323
+ };
3324
+ } catch {
3325
+ return null;
3326
+ }
3327
+ }
3328
+ function createAnnotationPersistence() {
3329
+ const storage = browserStorage();
3330
+ const defineStore = hostDefineStore();
3331
+ if (storage && defineStore) return createHostStorePersistence(defineStore, storage);
3332
+ if (storage) return createStoragePersistence(storage);
3333
+ return createNoopPersistence();
3334
+ }
3335
+ //#endregion
3100
3336
  //#region src/client/index.tsx
3101
3337
  /**
3102
3338
  * Browser half of dsh-select-quote.
@@ -3141,6 +3377,12 @@ function apply(ctx) {
3141
3377
  syncPendingAnnotationJobs(getAnnotations());
3142
3378
  return subscribeAnnotations(() => syncPendingAnnotationJobs(getAnnotations()));
3143
3379
  }, "dsh-select-quote: pending annotation marks");
3380
+ ctx.effect(() => {
3381
+ const port = createAnnotationPersistence();
3382
+ setAnnotationPersistence(port);
3383
+ port.prune();
3384
+ return () => setAnnotationPersistence(null);
3385
+ }, "dsh-select-quote: annotation persistence");
3144
3386
  ctx.slots.inject("shell.overlay", () => {
3145
3387
  ctx.slots.register({
3146
3388
  name: "shell.overlay",
@@ -3172,7 +3414,7 @@ function apply(ctx) {
3172
3414
  }
3173
3415
  /** Invisible session-scoped bridge that publishes sessionId for the root toolbar. */
3174
3416
  function SessionBridge({ sessionId }) {
3175
- setRuntimeSession(sessionId);
3417
+ bindAnnotationSession(sessionId);
3176
3418
  return null;
3177
3419
  }
3178
3420
  //#endregion