dsh-paste-code-block 0.1.3 → 0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/client.js CHANGED
@@ -17,6 +17,20 @@ window.__ModuleLoader__.load({
17
17
  const DETAIL_NS = 'dsh-pcb-detail'
18
18
  const CHIP_X_CLASS = 'dsh-pcb-chip-x'
19
19
 
20
+ // ===== diagnostics =======================================================
21
+ // Off by default. Set `window.__PCB_DEBUG__ = true` in the console (then
22
+ // paste / send) to trace paste parsing and fold decisions. The on-screen
23
+ // badge that used to sit above the composer was scaffolding for one
24
+ // debugging session and is gone.
25
+ const PCB_DEBUG = (() => {
26
+ try { return typeof window !== 'undefined' && window.__PCB_DEBUG__ === true } catch (e) { return false }
27
+ })()
28
+ function pcbDiag(kind, info) {
29
+ if (!PCB_DEBUG) return
30
+ try { console.log('[pcb-diag]', kind + (info ? ': ' + info : '')) } catch (e) {}
31
+ }
32
+ // =========================================================================
33
+
20
34
  // ----- shared chip-DOM helpers ------------------------------------------
21
35
  // Module level on purpose: the chip sync scan and the controller's removal
22
36
  // path both need to map a chip host element back to its label (and its
@@ -109,6 +123,9 @@ window.__ModuleLoader__.load({
109
123
  'edit.aria': '编辑代码块内容',
110
124
  'error.stale': '代码块已失效,请重新粘贴',
111
125
  'error.remove': '无法删除该块,请重试',
126
+ 'fold.code': '代码块',
127
+ 'fold.text': '文本块',
128
+ 'fold.aria': '展开或折叠该块',
112
129
  },
113
130
  en: {
114
131
  'block.code': 'Code #{n}',
@@ -126,6 +143,9 @@ window.__ModuleLoader__.load({
126
143
  'edit.aria': 'Edit block content',
127
144
  'error.stale': 'This block is no longer valid — paste it again',
128
145
  'error.remove': 'Could not remove the block — please retry',
146
+ 'fold.code': 'Code',
147
+ 'fold.text': 'Text',
148
+ 'fold.aria': 'Expand or collapse this block',
129
149
  },
130
150
  }
131
151
 
@@ -138,6 +158,13 @@ window.__ModuleLoader__.load({
138
158
  }
139
159
  }
140
160
 
161
+ // Sent-message fold card title (embedded copy of src/i18n.js#foldTitle).
162
+ function foldTitle(t, type, lang, lines) {
163
+ const meta = t(lines === 1 ? 'lines.one' : 'lines.other', { count: lines })
164
+ const name = type === 'code' ? (lang || t('fold.code')) : t('fold.text')
165
+ return `${name} · ${meta}`
166
+ }
167
+
141
168
  function detectBrowserLocale() {
142
169
  try {
143
170
  const langs = [...(navigator.languages || []), navigator.language].filter(Boolean).map((l) => String(l).toLowerCase())
@@ -205,6 +232,25 @@ window.__ModuleLoader__.load({
205
232
  return ''
206
233
  }
207
234
 
235
+ /**
236
+ * The single complete fence of `text`, or null when the text is not exactly
237
+ * one fenced block (blank lines around it tolerated, real prose not).
238
+ * Keep in sync with src/parse.js#wholeFence.
239
+ */
240
+ function wholeFence(text) {
241
+ const segs = splitFencedSegments(text)
242
+ let fence = null
243
+ for (const seg of segs) {
244
+ if (seg.kind === 'fence') {
245
+ if (fence) return null
246
+ fence = seg
247
+ } else if (seg.text.trim() !== '') {
248
+ return null
249
+ }
250
+ }
251
+ return fence
252
+ }
253
+
208
254
  /**
209
255
  * Parse pasted text into a block, or null when it is ordinary short prose
210
256
  * that should paste plainly. `isCode` drives both the chip label
@@ -215,12 +261,42 @@ window.__ModuleLoader__.load({
215
261
  const text = String(raw || '').replace(/^\uFEFF/, '')
216
262
  if (!text) return null
217
263
 
218
- const fm = /^\s*```([\w+-]*)[^\n]*\n([\s\S]*?)\n?\s*```\s*$/.exec(text)
219
- if (fm) {
220
- const lang = (fm[1] || '').trim()
221
- const content = fm[2].replace(/\n+$/, '')
264
+ // (1) exactly one complete fence: this plugin's own output shape. The
265
+ // fence's info string decides the type, so an explicit ```text paste is
266
+ // a TEXT block and round-trips instead of degrading into bare code.
267
+ const whole = wholeFence(text)
268
+ if (whole) {
269
+ const content = whole.body.replace(/\n+$/, '')
222
270
  if (!content) return null
223
- return { lang, content, lines: content.split(/\r?\n/), isCode: true }
271
+ const infoLang = (whole.lang || '').toLowerCase()
272
+ return {
273
+ lang: infoLang === 'text' ? '' : whole.lang,
274
+ content,
275
+ lines: content.split(/\r?\n/),
276
+ isCode: infoLang !== 'text',
277
+ }
278
+ }
279
+
280
+ // (2) a paste that OPENS with a fence but carries trailing prose — the
281
+ // copy-a-whole-previous-message case. Keep it as ONE block and take the
282
+ // type from the leading fence's info string, so the chip and the
283
+ // transcript agree. (0.2.5 treated it as code and re-wrapped the
284
+ // already-fenced text in a bare fence: two nested fences in the bubble.)
285
+ const segs = splitFencedSegments(text)
286
+ let leadFence = -1
287
+ for (let i = 0; i < segs.length; i += 1) {
288
+ if (segs[i].kind === 'fence') { leadFence = i; break }
289
+ if (segs[i].text.trim() !== '') break
290
+ }
291
+ if (leadFence >= 0) {
292
+ const leadLang = (segs[leadFence].lang || '').toLowerCase()
293
+ const content = text.replace(/\n+$/, '')
294
+ return {
295
+ lang: leadLang === 'text' ? '' : segs[leadFence].lang,
296
+ content,
297
+ lines: content.split(/\r?\n/),
298
+ isCode: leadLang !== 'text',
299
+ }
224
300
  }
225
301
 
226
302
  const multi = /\r?\n/.test(text)
@@ -264,9 +340,225 @@ window.__ModuleLoader__.load({
264
340
  return n
265
341
  }
266
342
 
343
+ /**
344
+ * Backtick marker long enough to wrap `content` unambiguously: longer than
345
+ * the longest backtick run inside it. Keep in sync with
346
+ * src/parse.js#fenceMarkerFor.
347
+ */
348
+ function fenceMarkerFor(content) {
349
+ const src = String(content == null ? '' : content)
350
+ let longest = 0
351
+ const runs = src.match(/`+/g)
352
+ if (runs) for (const run of runs) if (run.length > longest) longest = run.length
353
+ return '`'.repeat(Math.max(3, longest + 1))
354
+ }
355
+
267
356
  function serializeBlock(block) {
268
357
  const lang = block.isCode ? (block.lang || '') : 'text'
269
- return `\n\`\`\`${lang}\n${block.content}\n\`\`\`\n`
358
+ const marker = fenceMarkerFor(block.content)
359
+ return `\n${marker}${lang}\n${block.content}\n${marker}\n`
360
+ }
361
+
362
+ // ----- fence segmentation (embedded copy of src/parse.js) ----------------
363
+ // Split a message text into ordered prose/fence segments (GFM-style ``` or
364
+ // ~~~ fences, opener indented <=3 spaces, closer >= opener length alone on
365
+ // its line). Unterminated openers stay prose. Keep in sync with parse.js.
366
+ function splitFencedSegments(text) {
367
+ const src = String(text == null ? '' : text)
368
+ if (!src) return []
369
+ const lines = src.split(/\r?\n/)
370
+ const segments = []
371
+ const OPEN = /^ {0,3}(`{3,}|~{3,})(.*)$/
372
+ let proseStart = 0
373
+ let i = 0
374
+ const flushProse = (end) => {
375
+ if (end > proseStart) segments.push({ kind: 'prose', text: lines.slice(proseStart, end).join('\n') })
376
+ }
377
+ while (i < lines.length) {
378
+ const m = OPEN.exec(lines[i])
379
+ if (!m) { i += 1; continue }
380
+ const marker = m[1]
381
+ const ch = marker[0]
382
+ const info = m[2].trim()
383
+ if (ch === '`' && info.includes('`')) { i += 1; continue }
384
+ const closeRe = new RegExp('^ {0,3}' + (ch === '`' ? '`' : '~') + '{' + marker.length + ',}[ \\t]*$')
385
+ let j = i + 1
386
+ let closed = false
387
+ while (j < lines.length) {
388
+ if (closeRe.test(lines[j])) { closed = true; break }
389
+ j += 1
390
+ }
391
+ if (!closed) { i += 1; continue }
392
+ flushProse(i)
393
+ const bodyLines = lines.slice(i + 1, j)
394
+ segments.push({
395
+ kind: 'fence',
396
+ lang: info ? info.split(/\s+/)[0] : '',
397
+ info,
398
+ body: bodyLines.join('\n'),
399
+ raw: lines.slice(i, j + 1).join('\n'),
400
+ })
401
+ i = j + 1
402
+ proseStart = i
403
+ }
404
+ flushProse(lines.length)
405
+ return segments
406
+ }
407
+
408
+ /**
409
+ * Character ranges of every complete fence in `text`, in order, as
410
+ * { start, end } (end exclusive, both fence lines included). Same pairing
411
+ * rules as splitFencedSegments. Keep in sync with src/parse.js#fenceRanges.
412
+ */
413
+ function fenceRanges(text) {
414
+ const src = String(text == null ? '' : text)
415
+ const out = []
416
+ if (!src) return out
417
+ const starts = [0]
418
+ for (let i = 0; i < src.length; i += 1) if (src.charCodeAt(i) === 10) starts.push(i + 1)
419
+ const lineEnd = (n) => (n + 1 < starts.length ? starts[n + 1] - 1 : src.length)
420
+ const lineText = (n) => src.slice(starts[n], lineEnd(n)).replace(/\r$/, '')
421
+ const OPEN = /^ {0,3}(`{3,}|~{3,})(.*)$/
422
+ let i = 0
423
+ while (i < starts.length) {
424
+ const m = OPEN.exec(lineText(i))
425
+ if (!m) { i += 1; continue }
426
+ const marker = m[1]
427
+ const ch = marker[0]
428
+ const info = m[2].trim()
429
+ if (ch === '`' && info.includes('`')) { i += 1; continue }
430
+ const closeRe = new RegExp('^ {0,3}' + (ch === '`' ? '`' : '~') + '{' + marker.length + ',}[ \\t]*$')
431
+ let j = i + 1
432
+ let closed = false
433
+ while (j < starts.length) {
434
+ if (closeRe.test(lineText(j))) { closed = true; break }
435
+ j += 1
436
+ }
437
+ if (!closed) { i += 1; continue }
438
+ out.push({ start: starts[i], end: starts[j] + lineText(j).length })
439
+ i = j + 1
440
+ }
441
+ return out
442
+ }
443
+
444
+ /**
445
+ * How the sent-message fold scan should fold `text`, given the run texts
446
+ * DSH actually rendered it as. Keep in sync with src/parse.js#planFoldRuns.
447
+ * Returns null when nothing is foldable, else
448
+ * { mode:'single', segments, runs } — every fence lies inside ONE run
449
+ * { mode:'bubble', segments, fences } — a fence straddles run boundaries
450
+ */
451
+ function planFoldRuns(text, runTexts) {
452
+ const src = String(text == null ? '' : text)
453
+ const segments = splitFencedSegments(src)
454
+ const fenceSegs = segments.filter((seg) => seg.kind === 'fence')
455
+ const spans = fenceRanges(src)
456
+ if (fenceSegs.length !== spans.length) return null
457
+ const fences = []
458
+ fenceSegs.forEach((seg, k) => { if (seg.body.trim() !== '') fences.push(spans[k]) })
459
+ if (!fences.length) return null
460
+
461
+ const bounds = []
462
+ let at = 0
463
+ for (const rt of (runTexts || [])) {
464
+ const len = String(rt == null ? '' : rt).length
465
+ bounds.push([at, at + len])
466
+ at += len
467
+ }
468
+ const runOf = (off) => {
469
+ for (let k = 0; k < bounds.length; k += 1) if (off >= bounds[k][0] && off < bounds[k][1]) return k
470
+ return -1
471
+ }
472
+ const runs = []
473
+ for (const f of fences) {
474
+ const a = runOf(f.start)
475
+ const b = runOf(Math.max(f.start, f.end - 1))
476
+ if (a < 0 || a !== b) return { mode: 'bubble', segments, fences }
477
+ if (!runs.includes(a)) runs.push(a)
478
+ }
479
+ return { mode: 'single', segments, runs }
480
+ }
481
+
482
+ // ===== Recovery mirror (draft re-seed after workspace switch / reload) ===
483
+ // DSH persists a session's composer draft as PLAIN TEXT
484
+ // (`dsh.conversation.<sid>` in localStorage) and re-seeds the editor from
485
+ // it — but Lexical chip nodes cannot round-trip through text (design.md
486
+ // §7), so switching workspaces mid-draft left every pasted block behind:
487
+ // the seeded draft keeps only one invisible U+200B per lost chip. The
488
+ // plugin therefore mirrors its block CONTENT to localStorage itself and
489
+ // re-attaches the chips when it recognizes that stray-ZWSP signature
490
+ // (BlockDock mount effect → BlockController.recover). Intentional chip
491
+ // deletion removes the ZWSPs along with the nodes, so it never looks
492
+ // like a re-seed; the mirror clears itself on send and once no blocks
493
+ // remain, and stale entries expire after two weeks.
494
+ const RECOVERY_PREFIX = 'dsh-pcb-recovery.'
495
+ const RECOVERY_TTL_MS = 14 * 24 * 60 * 60 * 1000
496
+ const RECOVERY_MAX_CHARS = 3_000_000
497
+ const RECOVERY_MAX_DRAFT = 200_000
498
+
499
+ function recoverySave(sid, blocks, draftText) {
500
+ const key = RECOVERY_PREFIX + sid
501
+ try {
502
+ if (!blocks || blocks.length === 0) { localStorage.removeItem(key); return }
503
+ const draft = typeof draftText === 'string' && draftText.length <= RECOVERY_MAX_DRAFT ? draftText : ''
504
+ const json = JSON.stringify({ at: Date.now(), draft, blocks })
505
+ if (json.length > RECOVERY_MAX_CHARS) { console.warn('[dsh-pcb] draft too large to mirror; chips will not survive a reload'); return }
506
+ localStorage.setItem(key, json)
507
+ } catch (e) { /* quota / private mode: best effort */ }
508
+ }
509
+
510
+ function recoveryLoad(sid) {
511
+ const key = RECOVERY_PREFIX + sid
512
+ try {
513
+ const raw = localStorage.getItem(key)
514
+ if (!raw) return null
515
+ let data = null
516
+ try { data = JSON.parse(raw) } catch (e) { data = null }
517
+ if (!data || typeof data.at !== 'number' || Date.now() - data.at > RECOVERY_TTL_MS) { localStorage.removeItem(key); return null }
518
+ return data
519
+ } catch (e) { return null }
520
+ }
521
+
522
+ function recoveryClear(sid) {
523
+ try { localStorage.removeItem(RECOVERY_PREFIX + sid) } catch (e) { /* noop */ }
524
+ }
525
+
526
+ /**
527
+ * Mirror-side carry-over donor: a store whose mirrored draft matches the
528
+ * re-seeded one exactly and still holds blocks (after a page reload there
529
+ * is no live memory to match against). Returns { sid, blocks } on a
530
+ * UNIQUE match, null when absent or ambiguous.
531
+ */
532
+ function recoveryScanDonor(targetSid, draft) {
533
+ try {
534
+ let hit = null
535
+ for (let i = 0; i < localStorage.length; i += 1) {
536
+ const key = localStorage.key(i)
537
+ if (!key || !key.startsWith(RECOVERY_PREFIX)) continue
538
+ const sid = key.slice(RECOVERY_PREFIX.length)
539
+ if (sid === targetSid) continue
540
+ const data = recoveryLoad(sid)
541
+ if (!data || data.draft !== draft || !Array.isArray(data.blocks)) continue
542
+ if (!data.blocks.some((b) => b && typeof b.content === 'string' && b.content !== '')) continue
543
+ if (hit) return null // ambiguous — refuse to guess
544
+ hit = { sid, blocks: data.blocks }
545
+ }
546
+ return hit
547
+ } catch (e) { return null }
548
+ }
549
+
550
+ function recoveryPruneExpired() {
551
+ try {
552
+ const doomed = []
553
+ for (let i = 0; i < localStorage.length; i += 1) {
554
+ const k = localStorage.key(i)
555
+ if (!k || !k.startsWith(RECOVERY_PREFIX)) continue
556
+ let at = 0
557
+ try { at = (JSON.parse(localStorage.getItem(k) || '{}').at) || 0 } catch (e) { /* fall through */ }
558
+ if (!at || Date.now() - at > RECOVERY_TTL_MS) doomed.push(k)
559
+ }
560
+ for (const k of doomed) localStorage.removeItem(k)
561
+ } catch (e) { /* noop */ }
270
562
  }
271
563
 
272
564
  // ===== Draft controller ==================================================
@@ -289,6 +581,8 @@ window.__ModuleLoader__.load({
289
581
  this.expiry = new Map()
290
582
  this.serializing = new Set()
291
583
  this.listeners = new Map()
584
+ this.recoverTimer = new Map() // sid -> debounce handle for the recovery mirror
585
+ this.lastInput = new Map() // sid -> draft text seen by the last recover() pass
292
586
  this.counter = 0
293
587
  }
294
588
 
@@ -372,7 +666,11 @@ window.__ModuleLoader__.load({
372
666
  insertReference(sessionId, block) {
373
667
  const { actx, shell } = this.scope(sessionId)
374
668
  const input = shell.snapshot
375
- if (!input || input.phase !== 'plain') return false
669
+ // DSH's SessionInputShell.insertReference accepts both `plain` and
670
+ // `claimed` phases; mirror that so a paste while a command draft is
671
+ // still pending (claimed) folds into a chip instead of falling back to
672
+ // raw text. Submitting/adjudicating (mid-submit) stay rejected.
673
+ if (!input || (input.phase !== 'plain' && input.phase !== 'claimed')) return false
376
674
  // Each chip contributes exactly one char to the editor's detect text
377
675
  // (the "" placeholder) and one char to the plain `draft` projection
378
676
  // (clipboardText = zero-width space, chosen because String#trim() does
@@ -445,6 +743,16 @@ window.__ModuleLoader__.load({
445
743
  this.refIndex.set(block.id, { sessionId: k, block })
446
744
  this.labelIndex.set(block.label, block.id)
447
745
  this.publish(sessionId)
746
+ // The shell just rewrote the draft (chip + placeholder char); until
747
+ // the next dock pass runs, this is the session's ground truth. Keep
748
+ // the typing-window fingerprint and the carry-over donor match fresh
749
+ // so the pass right after a paste cannot mistake the new draft for a
750
+ // rebuild.
751
+ try {
752
+ const after = this.scope(k).shell.snapshot
753
+ if (after && typeof after.draft === 'string') this.lastInput.set(k, after.draft)
754
+ } catch (e) { /* session vanished — the next pass re-observes */ }
755
+ this.mirrorNow(k) // attach is a key moment — persist synchronously
448
756
  return true
449
757
  }
450
758
 
@@ -570,6 +878,7 @@ window.__ModuleLoader__.load({
570
878
  for (const [label, r] of this.labelIndex) if (r === ref) this.labelIndex.delete(label)
571
879
  this.freeLabel(sessionId, entry.type, entry.labelNumber)
572
880
  this.publish(sessionId)
881
+ this.mirror(k)
573
882
  }
574
883
 
575
884
  /** Update a block's content while editing it in the detail card. */
@@ -578,23 +887,31 @@ window.__ModuleLoader__.load({
578
887
  if (!record) return
579
888
  record.block.content = content
580
889
  record.block.lines = content.split(/\r?\n/)
890
+ this.mirror(record.sessionId)
581
891
  }
582
892
 
583
893
  clearInFlight(sessionId) {
584
894
  const k = String(sessionId)
585
895
  const expiry = this.expiry.get(k)
586
896
  if (expiry) { clearTimeout(expiry); this.expiry.delete(k) }
897
+ const had = this.inFlight.has(k)
587
898
  for (const b of this.inFlight.get(k) || []) {
588
899
  this.refIndex.delete(b.id)
589
900
  this.freeLabel(sessionId, b.type, b.labelNumber)
590
901
  for (const [label, r] of this.labelIndex) if (r === b.id) this.labelIndex.delete(label)
591
902
  }
592
903
  this.inFlight.delete(k)
904
+ if (had) this.mirrorNow(k)
593
905
  }
594
906
 
595
907
  markSerializing(ref) {
596
908
  const record = this.refIndex.get(ref)
597
- if (record && !this.serializing.has(record.sessionId)) this.serializing.add(record.sessionId)
909
+ if (record && !this.serializing.has(record.sessionId)) {
910
+ this.serializing.add(record.sessionId)
911
+ // A send is starting: the content leaves the draft, the mirror may go.
912
+ // (A failed send re-mirrors via restoreFailed.)
913
+ recoveryClear(record.sessionId)
914
+ }
598
915
  }
599
916
 
600
917
  async serialize(ref) {
@@ -640,6 +957,12 @@ window.__ModuleLoader__.load({
640
957
  if (next.length > 0) this.list.set(k, next)
641
958
  else this.list.delete(k)
642
959
  this.publish(sessionId)
960
+ // Hand-deletion is final. Persist the pruned state synchronously so a
961
+ // later reload/carry-over cannot resurrect a deleted chip from a
962
+ // mirror the draft has already outlived — and so the recovery
963
+ // restore-path can never fire off invisible external residue once the
964
+ // list for this session is empty.
965
+ this.mirrorNow(k)
643
966
  }
644
967
 
645
968
  restoreFailed(sessionId) {
@@ -661,10 +984,168 @@ window.__ModuleLoader__.load({
661
984
  }
662
985
  if (survived.length) this.list.set(k, [...this.listFor(k), ...survived])
663
986
  this.publish(sessionId)
987
+ if (survived.length) this.mirror(k)
988
+ }
989
+
990
+ // ----- recovery mirror -------------------------------------------------
991
+ /** Debounced persistence of the session's current block payloads. */
992
+ mirror(sessionId) {
993
+ const k = String(sessionId)
994
+ const prev = this.recoverTimer.get(k)
995
+ if (prev) clearTimeout(prev)
996
+ this.recoverTimer.set(k, setTimeout(() => {
997
+ this.recoverTimer.delete(k)
998
+ this.mirrorNow(k)
999
+ }, 250))
1000
+ }
1001
+
1002
+ mirrorNow(sessionId) {
1003
+ const k = String(sessionId)
1004
+ const t = this.recoverTimer.get(k)
1005
+ if (t) { clearTimeout(t); this.recoverTimer.delete(k) }
1006
+ recoverySave(k, this.listFor(k).map((b) => ({ isCode: !!b.isCode, lang: b.lang || '', content: b.content, src: b.sourceText || '' })), this.lastInput.get(k))
1007
+ }
1008
+
1009
+ /**
1010
+ * Restore the chips a text-only draft rebuild dropped. Two DSH paths
1011
+ * strand them: (a) the persisted per-session draft re-seeds the composer
1012
+ * after a page reload, and (b) a workspace switch on the new-session
1013
+ * page CARRIES the draft plain-text to the target workspace's blank
1014
+ * session (inputHub: next.setDraft(from.snapshot.draft)) — the chips,
1015
+ * which are Lexical nodes plus controller state, never move. Both leave
1016
+ * the same signature: stray U+200Bs (every chip contributes exactly one,
1017
+ * and removing a chip removes its ZWSP too) with no live occurrence of
1018
+ * ours. Deliberate deletion therefore never looks like a rebuild; and a
1019
+ * carry-over is matched to its donor by the exact draft the donor last
1020
+ * published — unique match only, no guessing. Blocks come from memory
1021
+ * first (same tab), then this session's mirror, then the donor's memory
1022
+ * or mirror. A stray U+200B that belongs to NO restorable block is
1023
+ * ordinary invisible copy from an external app (WeChat, web pages):
1024
+ * never restorable, cosmetic — it is left exactly where the user put
1025
+ * it. Sweeping it used to rewrite the whole draft mid-typing and is
1026
+ * retired in 0.2.2 (see the typing-window guard below).
1027
+ *
1028
+ * @param sessionId - session whose composer just (re)mounted.
1029
+ * @param snapshot - live input state ({draft, occurrences, phase}).
1030
+ * @returns whether the draft was rewritten by a real restore.
1031
+ */
1032
+ recover(sessionId, snapshot) {
1033
+ const k = String(sessionId)
1034
+ if (!snapshot || typeof snapshot.draft !== 'string') return false
1035
+ const prior = this.lastInput.get(k)
1036
+ this.lastInput.set(k, snapshot.draft)
1037
+ if (this.serializing.has(k)) return false
1038
+ if (snapshot.phase && snapshot.phase !== 'plain') return false
1039
+ if ((snapshot.occurrences || []).some((o) => o.source === SOURCE)) return false
1040
+ const draft = snapshot.draft
1041
+ const strays = (draft.match(/\u200B/g) || []).length
1042
+ if (strays === 0) return false
1043
+ // Typing-window guard. Every committed keystroke re-runs this pass, so
1044
+ // a draft that MOVED since the pass before it is live typing, not a
1045
+ // text-only re-seed — and a real restore rewrites the whole document
1046
+ // via setDraft: caret to the end, chips re-appended, an in-flight IME
1047
+ // composition destroyed (0.2.2 bug: the first character typed was
1048
+ // eaten and the view bounced once, again and again). Only a draft
1049
+ // IDENTICAL to the last one observed for this session — or one this
1050
+ // page view has never seen, or only ever saw the composer hydrate
1051
+ // through while empty (fresh-boot re-seed) — can carry the rebuild
1052
+ // signature. The empty-prior escape is sealed from the other side by
1053
+ // reconcile pruning synchronously dropping the mirror: with nothing
1054
+ // restorable on record, a moved-from-empty pass restores nothing.
1055
+ if (prior && prior !== draft) return false
1056
+ let shell
1057
+ try {
1058
+ shell = this.scope(k).shell
1059
+ } catch (e) { return false } // session not ready yet — a later pass retries
1060
+ const usable = (arr) => Array.isArray(arr)
1061
+ ? arr.filter((b) => b && typeof b.content === 'string' && b.content !== '')
1062
+ : []
1063
+ let sources = usable(this.listFor(k))
1064
+ let origin = sources.length ? 'memory' : null
1065
+ let donor = null
1066
+ if (!sources.length) {
1067
+ const saved = recoveryLoad(k)
1068
+ sources = usable(saved && saved.blocks)
1069
+ if (sources.length) origin = 'mirror'
1070
+ }
1071
+ if (!sources.length) {
1072
+ donor = this.findCarryoverSource(k, draft)
1073
+ if (donor) {
1074
+ sources = usable(this.listFor(donor))
1075
+ origin = 'carry-over (live)'
1076
+ }
1077
+ }
1078
+ if (!sources.length) {
1079
+ const scanned = recoveryScanDonor(k, draft)
1080
+ if (scanned && scanned.blocks.length) {
1081
+ donor = scanned.sid
1082
+ sources = usable(scanned.blocks)
1083
+ origin = 'carry-over (mirror)'
1084
+ }
1085
+ }
1086
+ if (sources.length === 0) {
1087
+ // Nothing to restore — invisible external residue (or the residue of
1088
+ // a deliberately deleted chip whose node already carries none of our
1089
+ // state). NEVER rewrite the draft for it: rewriting drops the caret,
1090
+ // destroys an in-flight IME composition and re-seeds stale text over
1091
+ // whatever the user typed in the meantime (0.2.2).
1092
+ return false
1093
+ }
1094
+ try {
1095
+ shell.setDraft(draft.replace(/\u200B/g, ''))
1096
+ } catch (e) {
1097
+ console.warn('[dsh-pcb] recovery: draft rewrite failed', e)
1098
+ return false
1099
+ }
1100
+ // The draft — and with it the blocks — now belongs to this session:
1101
+ // forget the (possibly moved) bookkeeping; attach() re-derives ids,
1102
+ // labels and numbers, and re-mirrors under this sid.
1103
+ this.forgetSession(k)
1104
+ if (donor) {
1105
+ this.forgetSession(donor)
1106
+ recoveryClear(donor)
1107
+ this.lastInput.delete(donor)
1108
+ }
1109
+ let restored = 0
1110
+ for (const b of sources) {
1111
+ const block = { lang: b.lang || '', content: b.content, lines: b.content.split(/\r?\n/), isCode: !!b.isCode, sourceText: b.sourceText || (typeof b.src === 'string' && b.src ? b.src : undefined) }
1112
+ if (this.attach(k, block)) restored += 1
1113
+ }
1114
+ console.log('[dsh-pcb] recovery: re-attached', restored, 'of', sources.length, 'block(s) via', origin, donor ? `(donor ${donor})` : '')
1115
+ return restored > 0
1116
+ }
1117
+
1118
+ /** Unmounted session that still lists blocks and last published EXACTLY this draft. */
1119
+ findCarryoverSource(targetSid, draft) {
1120
+ let found = null
1121
+ for (const [x, entries] of this.list) {
1122
+ if (x === targetSid || entries.length === 0) continue
1123
+ if (this.serializing.has(x)) continue
1124
+ const listeners = this.listeners.get(x)
1125
+ if (listeners && listeners.size > 0) continue // still mounted — draft did not move
1126
+ if (this.lastInput.get(x) !== draft) continue
1127
+ if (found) return null // ambiguous
1128
+ found = x
1129
+ }
1130
+ return found
1131
+ }
1132
+
1133
+ /** Drop a session's block bookkeeping (its chips are gone or moving). */
1134
+ forgetSession(sessionId) {
1135
+ const k = String(sessionId)
1136
+ for (const b of this.listFor(k)) {
1137
+ this.freeLabel(k, b.type, b.labelNumber)
1138
+ this.refIndex.delete(b.id)
1139
+ for (const [label, r] of this.labelIndex) if (r === b.id) this.labelIndex.delete(label)
1140
+ }
1141
+ this.list.delete(k)
1142
+ this.selected.delete(k)
664
1143
  }
665
1144
 
666
1145
  dispose() {
667
1146
  for (const t of this.expiry.values()) clearTimeout(t)
1147
+ for (const t of this.recoverTimer.values()) clearTimeout(t)
1148
+ this.recoverTimer.clear()
668
1149
  this.expiry.clear(); this.list.clear(); this.selected.clear()
669
1150
  this.used.clear(); this.refIndex.clear(); this.labelIndex.clear()
670
1151
  this.inFlight.clear(); this.serializing.clear(); this.listeners.clear()
@@ -727,6 +1208,12 @@ window.__ModuleLoader__.load({
727
1208
  useComposerMark(railRef, props.sessionId, input.draftRev, blocks.length > 0)
728
1209
 
729
1210
  React.useEffect(() => {
1211
+ // Recognize a text-only draft re-seed (workspace switch / reload)
1212
+ // FIRST: re-attach lost chips before reconciliation prunes them. A
1213
+ // successful recovery mutates the draft, so this pass must NOT
1214
+ // reconcile against its now-stale snapshot — the next draftRev pass
1215
+ // does that with fresh occurrences.
1216
+ if (props.controller.recover(props.sessionId, input)) return
730
1217
  props.controller.reconcile(props.sessionId, input.occurrences || [], input.phase)
731
1218
  }, [props.controller, props.sessionId, input.draftRev, input.phase])
732
1219
 
@@ -834,6 +1321,163 @@ window.__ModuleLoader__.load({
834
1321
  )
835
1322
  }
836
1323
 
1324
+ // ===== Sent-message fold =================================================
1325
+ // A block that folded in the composer comes back *after send* as fenced
1326
+ // text inside the user bubble (DSH renders user messages as plain
1327
+ // pre-wrap runs), so the wall of text this plugin exists to prevent
1328
+ // reappears in the transcript. scanFolds() (see apply) walks sent
1329
+ // bubbles and folds every complete fenced block into a collapsed card:
1330
+ // [</>] python · 128 行 [⎘] ▾ (header click toggles)
1331
+ // React owns the bubble's own children, so nothing is removed or
1332
+ // reordered: the plainRun span being folded is hidden (display:none) and
1333
+ // a host element we fully own is inserted directly after it. A content
1334
+ // stamp on the run keeps the scan idempotent and self-refreshing — text
1335
+ // or locale revision changes rebuild the host, and a fold that becomes
1336
+ // unnecessary is undone. The copy button copies the RAW fenced text
1337
+ // (exactly what was sent); the bubble's own copy/edit/delete bar is
1338
+ // React-side and never sees this DOM.
1339
+ const FOLD_HOST_CLASS = 'dsh-pcb-fold-host'
1340
+ const FOLD_CARD_CLASS = 'dsh-pcb-fold'
1341
+ const FOLD_STAMP_ATTR = 'data-dsh-pcb-stamp' // on a host: the content stamp it was built from
1342
+ const FOLD_HIDE_ATTR = 'data-dsh-pcb-hide' // on every run the scan hid: the same stamp
1343
+ const FOLD_SIG_ATTR = 'data-dsh-pcb-sig' // on the bubble: '<mode>|<stamp>' it was folded for
1344
+ const FOLD_BUBBLE_SEL = [
1345
+ '[data-chat-flow-kind="user"]',
1346
+ '[data-chat-flow-kind="steering"]',
1347
+ '[data-submission-echo]',
1348
+ '[data-pending-steering]',
1349
+ ].map((s) => `${s} [class*="bubble"]`).join(',')
1350
+ const foldOpenKeys = new Set() // user-toggled-open cards, stable across rebuilds
1351
+ let foldRev = 0 // bumped on locale switch so cards re-title
1352
+
1353
+ function hash32(str) {
1354
+ let h = 0x811c9dc5
1355
+ for (let i = 0; i < str.length; i += 1) {
1356
+ h ^= str.charCodeAt(i)
1357
+ h = Math.imul(h, 0x01000193)
1358
+ }
1359
+ return (h >>> 0).toString(36)
1360
+ }
1361
+
1362
+ function foldStamp(text) {
1363
+ return `${foldRev}:${text.length}:${hash32(text)}`
1364
+ }
1365
+
1366
+ // Cheap pre-filter so the per-frame scan stays trivial on unfenced runs.
1367
+ const FENCE_HINT = /(^|\n) {0,3}(```|~~~)/
1368
+
1369
+ function isFoldRun(el) {
1370
+ return el.tagName === 'SPAN' && (el.getAttribute('class') || '').includes('plainRun')
1371
+ }
1372
+
1373
+ /** Segments when the text holds at least one non-empty complete fence, else null. */
1374
+ function foldPlanFor(text) {
1375
+ if (!FENCE_HINT.test(text)) return null
1376
+ const segments = splitFencedSegments(text)
1377
+ let folds = 0
1378
+ for (const seg of segments) if (seg.kind === 'fence' && seg.body.trim() !== '') folds += 1
1379
+ return folds > 0 ? segments : null
1380
+ }
1381
+
1382
+ function buildFoldCard(seg, openKey, t) {
1383
+ const infoLang = (seg.lang || '').toLowerCase()
1384
+ // Only an EXPLICIT ```text fence is a text block. The composer never
1385
+ // emits a bare fence for prose (plain blocks always serialize as
1386
+ // ```text), so a bare ``` fence is code whose language the composer
1387
+ // could not name — fold it as a code card and re-guess the language
1388
+ // for display. (0.2.0 bug: bare fences folded as 文本块, mismatching
1389
+ // the 代码块 chip the message was drafted from.)
1390
+ const isText = infoLang === 'text'
1391
+ const lang = isText ? '' : (infoLang || detectLang(seg.body) || '')
1392
+ const type = isText ? 'text' : 'code'
1393
+ const lines = seg.body.split('\n').length
1394
+ const open = foldOpenKeys.has(openKey)
1395
+
1396
+ const card = document.createElement('div')
1397
+ card.className = FOLD_CARD_CLASS
1398
+ card.setAttribute('data-pcb-type', type)
1399
+ card.setAttribute('data-open', open ? '1' : '0')
1400
+
1401
+ const head = document.createElement('div')
1402
+ head.className = 'dsh-pcb-fold-head'
1403
+
1404
+ const toggle = document.createElement('button')
1405
+ toggle.type = 'button'
1406
+ toggle.className = 'dsh-pcb-fold-toggle'
1407
+ toggle.setAttribute('aria-expanded', open ? 'true' : 'false')
1408
+ toggle.setAttribute('aria-label', t('fold.aria'))
1409
+ toggle.title = t(open ? 'collapse' : 'expand')
1410
+ const glyph = document.createElement('span')
1411
+ glyph.className = 'dsh-pcb-fold-glyph'
1412
+ glyph.setAttribute('aria-hidden', 'true')
1413
+ glyph.textContent = type === 'code' ? '</>' : 'Aa'
1414
+ const title = document.createElement('span')
1415
+ title.className = 'dsh-pcb-fold-title'
1416
+ title.textContent = foldTitle(t, type, lang, lines)
1417
+ const chevron = document.createElement('span')
1418
+ chevron.className = 'dsh-pcb-fold-chevron'
1419
+ chevron.setAttribute('aria-hidden', 'true')
1420
+ chevron.textContent = '▾'
1421
+ toggle.append(glyph, title, chevron)
1422
+
1423
+ const copy = document.createElement('button')
1424
+ copy.type = 'button'
1425
+ copy.className = 'dsh-pcb-btn dsh-pcb-fold-copy'
1426
+ copy.title = t('copy')
1427
+ copy.setAttribute('aria-label', t('copy'))
1428
+ copy.textContent = '⎘'
1429
+ let copyTimer = 0
1430
+ copy.addEventListener('click', (ev) => {
1431
+ ev.preventDefault()
1432
+ ev.stopPropagation()
1433
+ try {
1434
+ const done = navigator.clipboard?.writeText(seg.raw)
1435
+ if (done && typeof done.then === 'function') {
1436
+ done.then(() => {
1437
+ copy.textContent = '✓'
1438
+ clearTimeout(copyTimer)
1439
+ copyTimer = setTimeout(() => { copy.textContent = '⎘' }, 1200)
1440
+ }, () => {})
1441
+ }
1442
+ } catch (e) { /* noop */ }
1443
+ })
1444
+
1445
+ head.append(toggle, copy)
1446
+ const pre = document.createElement('pre')
1447
+ pre.className = 'dsh-pcb-fold-body'
1448
+ pre.textContent = seg.body
1449
+ card.append(head, pre)
1450
+
1451
+ toggle.addEventListener('click', (ev) => {
1452
+ ev.preventDefault()
1453
+ ev.stopPropagation()
1454
+ const nowOpen = card.getAttribute('data-open') !== '1'
1455
+ card.setAttribute('data-open', nowOpen ? '1' : '0')
1456
+ toggle.setAttribute('aria-expanded', nowOpen ? 'true' : 'false')
1457
+ toggle.title = t(nowOpen ? 'collapse' : 'expand')
1458
+ if (nowOpen) foldOpenKeys.add(openKey)
1459
+ else foldOpenKeys.delete(openKey)
1460
+ })
1461
+ return card
1462
+ }
1463
+
1464
+ function buildFoldHost(segments, keyBase, t) {
1465
+ const host = document.createElement('div')
1466
+ host.className = FOLD_HOST_CLASS
1467
+ segments.forEach((seg, i) => {
1468
+ if (seg.kind === 'fence' && seg.body.trim() !== '') {
1469
+ host.appendChild(buildFoldCard(seg, `${keyBase}:${i}`, t))
1470
+ } else {
1471
+ const prose = document.createElement('span')
1472
+ prose.className = 'dsh-pcb-fold-prose'
1473
+ // An un-foldable fence (empty body) keeps its raw text, fences and all.
1474
+ prose.textContent = seg.kind === 'fence' ? seg.raw : seg.text
1475
+ host.appendChild(prose)
1476
+ }
1477
+ })
1478
+ return host
1479
+ }
1480
+
837
1481
  // ===== Styles ============================================================
838
1482
  const CSS = `
839
1483
  /* --- restyle DSH's inline reference chip into a boxed badge --- */
@@ -905,9 +1549,26 @@ window.__ModuleLoader__.load({
905
1549
  /* --- hide the composer's own placeholder while chips/cards are active --- */
906
1550
  [data-dsh-pcb-active="1"] [data-composer-placeholder]{visibility:hidden}
907
1551
 
1552
+ /* --- sent-message fold cards (conversation bubbles) --- */
1553
+ .dsh-pcb-fold-host{box-sizing:border-box;display:flex;flex-direction:column;gap:8px;width:100%;min-width:0}
1554
+ .dsh-pcb-fold-prose{white-space:pre-wrap;word-break:break-word}
1555
+ .dsh-pcb-fold{box-sizing:border-box;width:100%;min-width:0;display:flex;flex-direction:column;border:1px solid var(--dsw-alias-border-l2);border-radius:12px;background:var(--dsw-specific-input-major,var(--dsw-alias-bg-layer-1));box-shadow:var(--dsw-shadow-lv1);color:var(--dsw-alias-label-primary);overflow:hidden}
1556
+ .dsh-pcb-fold-head{box-sizing:border-box;min-height:34px;flex:none;display:flex;align-items:center;gap:4px;padding:0 6px 0 10px}
1557
+ .dsh-pcb-fold-toggle{flex:1;min-width:0;display:flex;align-items:center;gap:7px;padding:0;border:0;background:transparent;color:inherit;font:inherit;text-align:left;cursor:pointer}
1558
+ .dsh-pcb-fold-glyph{flex:none;font-family:var(--dsw-font-mono,ui-monospace,Menlo,Consolas,monospace);font-size:11px;font-weight:700;line-height:16px;opacity:.9}
1559
+ .dsh-pcb-fold[data-pcb-type="code"] .dsh-pcb-fold-glyph{color:var(--dsw-alias-state-business-primary,#4c6ef5)}
1560
+ .dsh-pcb-fold[data-pcb-type="text"] .dsh-pcb-fold-glyph{color:var(--dsw-alias-label-tertiary)}
1561
+ .dsh-pcb-fold-title{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:var(--dsw-font-mono,ui-monospace,Menlo,Consolas,monospace);font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary)}
1562
+ .dsh-pcb-fold[data-pcb-type="text"] .dsh-pcb-fold-title{font-family:var(--dsw-font-family)}
1563
+ .dsh-pcb-fold-chevron{flex:none;width:14px;text-align:center;font-size:11px;line-height:1;color:var(--dsw-alias-label-tertiary);transition:transform .14s ease}
1564
+ .dsh-pcb-fold[data-open="1"] .dsh-pcb-fold-chevron{transform:rotate(180deg)}
1565
+ .dsh-pcb-fold[data-open="0"] .dsh-pcb-fold-body{display:none}
1566
+ .dsh-pcb-fold-body{box-sizing:border-box;margin:0;max-height:360px;overflow:auto;padding:8px 12px;border-top:1px solid var(--dsw-alias-border-l2);font-family:var(--dsw-font-mono,ui-monospace,Menlo,Consolas,monospace);font-size:12px;line-height:1.5;white-space:pre;word-break:break-all;background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-primary);--dsh-scrollbar-thumb:var(--dsw-alias-scrollbar-bg-l2)}
1567
+
908
1568
  @media (max-width:640px){
909
1569
  .dsh-pcb-head{height:34px}
910
1570
  .dsh-pcb-lang{max-width:55%}
1571
+ .dsh-pcb-fold-body{max-height:240px}
911
1572
  }
912
1573
  @media (pointer:coarse){
913
1574
  .${CHIP_X_CLASS}{width:22px;height:22px;font-size:15px}
@@ -918,7 +1579,10 @@ window.__ModuleLoader__.load({
918
1579
  const inject = ['slots', 'sessions', 'conversation', 'inputTriggers', 'locale']
919
1580
 
920
1581
  function apply(ctx) {
921
- try { console.log('[dsh-paste-code-block] client loaded') } catch (e) { /* noop */ }
1582
+ try { pcbDiag('LOADED', 'v0.2.5-diag') } catch (e) {}
1583
+ // Bump alongside package.json on every release — the load log is the
1584
+ // only proof of WHICH bundle generation the browser actually loaded.
1585
+ try { console.log('[dsh-paste-code-block] client loaded v0.2.5') } catch (e) { /* noop */ }
922
1586
 
923
1587
  // Publish our dictionaries under the plugin namespace, then bind a
924
1588
  // translator that always reflects the ACTIVE DSH language (Settings →
@@ -932,7 +1596,10 @@ window.__ModuleLoader__.load({
932
1596
  : createT(detectBrowserLocale())
933
1597
 
934
1598
  const controller = new BlockController(ctx, t)
1599
+ // Test seam (tests/fold-smoke.test.js drives recover() directly).
1600
+ exports.__controllerForTests = controller
935
1601
  ctx.effect(() => () => controller.dispose(), 'paste-code-block: state')
1602
+ try { recoveryPruneExpired() } catch (e) { /* private mode: recovery simply off */ }
936
1603
 
937
1604
  // ----- chip DOM sync: localized labels + per-chip ✕ delete button -----
938
1605
  function scanChips() {
@@ -983,10 +1650,112 @@ window.__ModuleLoader__.load({
983
1650
  }
984
1651
  }
985
1652
 
1653
+ // ----- sent-message fold: the scan ------------------------------------
1654
+ // DSH renders a sent message as plain pre-wrap runs and SPLITS those runs
1655
+ // at every @mention chip, so one serialized block can arrive as several
1656
+ // sibling runs with the fence opener in one and its closer in another.
1657
+ // A per-run scan then sees only an unterminated half (nothing to fold) —
1658
+ // or, worse, a fragment that happens to look like a complete fence, which
1659
+ // is how a bogus card built from the tail of the message used to appear.
1660
+ // So fold at BUBBLE scope: rebuild the text from the runs, plan the
1661
+ // fences over that, and keep the per-run path only when every fence sits
1662
+ // inside a single run (which leaves DSH's mention chips clickable).
1663
+ const foldedBubbles = new WeakSet()
1664
+
1665
+ /** Hand a bubble back to DSH's own rendering. */
1666
+ function clearFolds(bubble) {
1667
+ for (const el of [...bubble.children]) {
1668
+ if (el.classList.contains(FOLD_HOST_CLASS)) { el.remove(); continue }
1669
+ if (el.getAttribute(FOLD_HIDE_ATTR) !== null) {
1670
+ el.style.display = ''
1671
+ el.removeAttribute(FOLD_HIDE_ATTR)
1672
+ }
1673
+ }
1674
+ bubble.removeAttribute(FOLD_SIG_ATTR)
1675
+ foldedBubbles.delete(bubble)
1676
+ }
1677
+
1678
+ /** True when the DOM still matches what `sig` claims (React remounts runs). */
1679
+ function foldsIntact(bubble, mode, stamp, wanted) {
1680
+ const kids = [...bubble.children]
1681
+ const hosts = kids.filter((el) => el.classList.contains(FOLD_HOST_CLASS))
1682
+ if (hosts.length !== (mode === 'bubble' ? 1 : wanted)) return false
1683
+ for (const host of hosts) if (host.getAttribute(FOLD_STAMP_ATTR) !== stamp) return false
1684
+ const hidden = kids.filter((el) => el.getAttribute(FOLD_HIDE_ATTR) === stamp)
1685
+ if (hidden.length !== wanted) return false
1686
+ for (const el of hidden) if (el.style.display !== 'none') return false
1687
+ return true
1688
+ }
1689
+
1690
+ function foldBubble(bubble) {
1691
+ const holder = bubble.closest('[data-chat-flow-key]')
1692
+ const flowKey = holder ? holder.getAttribute('data-chat-flow-key') || '' : ''
1693
+ const runs = [...bubble.children].filter((el) => !el.classList.contains(FOLD_HOST_CLASS))
1694
+ const texts = runs.map((el) => el.textContent || '')
1695
+ const full = texts.join('')
1696
+ const stamp = foldStamp(full)
1697
+ // Cheap guard first: most messages carry no fence at all.
1698
+ const plan = runs.length && FENCE_HINT.test(full) ? planFoldRuns(full, texts) : null
1699
+ let mode = plan ? plan.mode : ''
1700
+ let subs = []
1701
+ if (plan && mode === 'single') {
1702
+ for (const k of plan.runs) {
1703
+ const sub = foldPlanFor(texts[k])
1704
+ if (sub) subs.push([k, sub])
1705
+ }
1706
+ // A run we cannot plan for (a mention chip, a wrapper element) is not
1707
+ // ours to hide — rebuild the bubble instead.
1708
+ if (!subs.length || plan.runs.some((k) => !isFoldRun(runs[k]))) mode = 'bubble'
1709
+ }
1710
+ const sig = plan ? `${mode}|${stamp}` : ''
1711
+ const wanted = plan ? (mode === 'bubble' ? runs.length : subs.length) : 0
1712
+ if (plan && bubble.getAttribute(FOLD_SIG_ATTR) === sig && foldsIntact(bubble, mode, stamp, wanted)) return
1713
+ if (!plan && !foldedBubbles.has(bubble)) return
1714
+ clearFolds(bubble)
1715
+ if (!plan) return
1716
+ const keyBase = flowKey || 'h' + hash32(full)
1717
+ const fenceCount = plan.fences ? plan.fences.length : (plan.runs || []).length
1718
+ pcbDiag('FOLD', 'mode=' + mode + ' runs=' + runs.length + ' fences=' + fenceCount +
1719
+ ' hide=' + (mode === 'bubble' ? 'all' : subs.map((s) => s[0]).join(',')) + ' len=' + full.length)
1720
+ if (mode === 'bubble') {
1721
+ // Hide every run and render the whole message from the plan, so the
1722
+ // fence that straddled them is paired again. Mention chips inside the
1723
+ // folded text lose their click target — they are collapsed content.
1724
+ const host = buildFoldHost(plan.segments, `${keyBase}#b`, t)
1725
+ host.setAttribute(FOLD_STAMP_ATTR, stamp)
1726
+ for (const el of runs) {
1727
+ el.style.display = 'none'
1728
+ el.setAttribute(FOLD_HIDE_ATTR, stamp)
1729
+ }
1730
+ bubble.appendChild(host)
1731
+ } else {
1732
+ for (const [k, sub] of subs.slice().sort((a, b) => a[0] - b[0])) {
1733
+ const el = runs[k]
1734
+ el.style.display = 'none'
1735
+ el.setAttribute(FOLD_HIDE_ATTR, stamp)
1736
+ const host = buildFoldHost(sub, `${keyBase}#${k}`, t)
1737
+ host.setAttribute(FOLD_STAMP_ATTR, stamp)
1738
+ el.insertAdjacentElement('afterend', host)
1739
+ }
1740
+ }
1741
+ bubble.setAttribute(FOLD_SIG_ATTR, sig)
1742
+ foldedBubbles.add(bubble)
1743
+ }
1744
+
1745
+ function scanFolds() {
1746
+ if (typeof document === 'undefined') return
1747
+ let bubbles
1748
+ try { bubbles = document.querySelectorAll(FOLD_BUBBLE_SEL) } catch (e) { return }
1749
+ for (const bubble of bubbles) {
1750
+ if (!bubble.isConnected) continue
1751
+ try { foldBubble(bubble) } catch (e) { /* never break DSH's render loop */ }
1752
+ }
1753
+ }
1754
+
986
1755
  let scanScheduled = 0
987
1756
  function scheduleScan() {
988
1757
  if (scanScheduled) return
989
- const run = () => { scanScheduled = 0; scanChips() }
1758
+ const run = () => { scanScheduled = 0; scanChips(); scanFolds() }
990
1759
  scanScheduled = typeof requestAnimationFrame === 'function' ? requestAnimationFrame(run) : setTimeout(run, 16)
991
1760
  }
992
1761
 
@@ -998,13 +1767,29 @@ window.__ModuleLoader__.load({
998
1767
  }, 'paste-code-block: chip sync observer')
999
1768
 
1000
1769
  // A locale switch (or a late dictionary registration bumps the revision
1001
- // too) re-derives labels: controller state first, chip DOM second.
1770
+ // too) re-derives labels: controller state first, chip DOM second. Sent
1771
+ // fold cards are rebuilt the same way — bumping foldRev invalidates
1772
+ // every run stamp, so the next scan re-titles the cards in the new
1773
+ // language while foldOpenKeys keeps who was expanded.
1002
1774
  ctx.effect(() => {
1003
1775
  const face = ctx.locale
1004
1776
  if (!face || typeof face.subscribe !== 'function') return undefined
1005
- return face.subscribe(() => { controller.retitleAll(); scheduleScan() })
1777
+ return face.subscribe(() => { controller.retitleAll(); foldRev += 1; scheduleScan() })
1006
1778
  }, 'paste-code-block: locale sync')
1007
- scheduleScan() // late-mount safety for chips rendered before plugin boot
1779
+ scheduleScan() // late-mount safety for chips and sent bubbles rendered before plugin boot
1780
+
1781
+ // On dispose (plugin removed / HMR swap) hand the transcript back to DSH.
1782
+ ctx.effect(() => () => {
1783
+ try {
1784
+ for (const host of document.querySelectorAll(`.${FOLD_HOST_CLASS}`)) host.remove()
1785
+ for (const run of document.querySelectorAll(`[${FOLD_HIDE_ATTR}]`)) {
1786
+ run.style.display = ''
1787
+ run.removeAttribute(FOLD_HIDE_ATTR)
1788
+ }
1789
+ for (const bubble of document.querySelectorAll(`[${FOLD_SIG_ATTR}]`)) bubble.removeAttribute(FOLD_SIG_ATTR)
1790
+ } catch (e) { /* noop */ }
1791
+ foldOpenKeys.clear()
1792
+ }, 'paste-code-block: fold restore')
1008
1793
 
1009
1794
  ctx.effect(() => ctx.inputTriggers.registerSource({
1010
1795
  trigger: '@',
@@ -1026,6 +1811,34 @@ window.__ModuleLoader__.load({
1026
1811
  return () => style.remove()
1027
1812
  }, 'paste-code-block: styles')
1028
1813
 
1814
+ // Paste-storm guard. External automation — a Tampermonkey AutoClicker,
1815
+ // a clipboard bridge, IME glue, or a held-down Ctrl+V — can re-dispatch
1816
+ // the SAME clipboard text dozens of times a second. Each accepted paste
1817
+ // mints a new block, so a storm stuffs the composer with duplicate
1818
+ // chips. Collapse runs of an identical clipboard text that arrive faster
1819
+ // than a human can act into a single paste (a 1200 ms sliding window,
1820
+ // refreshed on every matching event, so a held repeat stays suppressed
1821
+ // while it continues). 0.2.4 added a content-liveness dedup that ALSO
1822
+ // suppressed a re-paste of the same text while its block was still
1823
+ // attached — that made "paste the same snippet twice" impossible, which
1824
+ // users hit immediately. 0.2.5 reverts that: identical text pasted after
1825
+ // the burst window is a fresh intent and gets its own block. Only
1826
+ // machine-paced repeats within the window are collapsed.
1827
+ const STORM_WINDOW_MS = 1200
1828
+ const pasteStorm = {
1829
+ lastText: '',
1830
+ lastAt: 0,
1831
+ // True when this text is the tail of a storm; every call refreshes
1832
+ // the window, so a held repeat stays suppressed while it continues.
1833
+ seen(text, now) {
1834
+ const dup = text === this.lastText && now - this.lastAt < STORM_WINDOW_MS
1835
+ this.lastText = text
1836
+ this.lastAt = now
1837
+ return dup
1838
+ },
1839
+ }
1840
+ exports.__pasteStormForTests = pasteStorm
1841
+
1029
1842
  // Turn qualifying pastes into boxed inline chips.
1030
1843
  const onPaste = (ev) => {
1031
1844
  const clip = ev.clipboardData
@@ -1035,14 +1848,30 @@ window.__ModuleLoader__.load({
1035
1848
  const target = document.activeElement
1036
1849
  if (!target || !(target instanceof Element)) return
1037
1850
  const sid = sessionFor(target)
1038
- if (!sid) return
1851
+ if (!sid) { pcbDiag("EXIT-SID-NULL", "activeEl=" + target.tagName + "." + (target.className || "") + " mark=" + !!document.querySelector("[data-dsh-pcb]")); return }
1039
1852
  const block = parseBlock(text)
1040
- if (!block) { console.log('[dsh-paste-code-block] paste NOT block', text.length); return }
1041
- console.log('[dsh-paste-code-block] intercept', block.isCode ? 'code' : 'text', block.lines.length, 'lang=', block.lang)
1853
+ if (!block) { pcbDiag("EXIT-NOT-BLOCK", "len=" + text.length); console.log('[dsh-paste-code-block] paste NOT block', text.length); return }
1854
+ if (pasteStorm.seen(text, Date.now())) {
1855
+ // Consume the event (never let the raw text fall through to the
1856
+ // composer) but create nothing.
1857
+ ev.preventDefault()
1858
+ ev.stopPropagation()
1859
+ pcbDiag("EXIT-STORM", "len=" + text.length);
1860
+ console.log('[dsh-paste-code-block] storm paste ignored (<' + STORM_WINDOW_MS + 'ms repeat)')
1861
+ return
1862
+ }
1863
+ block.sourceText = text
1864
+ // trusted=false means a page script dispatched this paste (userscript,
1865
+ // clipboard bridge, replay glue) rather than a real keystroke — the
1866
+ // load-log line that names the source when a storm report comes in.
1867
+ pcbDiag("ENTER-INTERCEPT", "sid=" + sid + " lines=" + block.lines.length + " code=" + block.isCode + " trusted=" + ev.isTrusted);
1868
+ console.log('[dsh-paste-code-block] intercept', block.isCode ? 'code' : 'text', block.lines.length, 'lang=', block.lang, 'trusted=', ev.isTrusted)
1042
1869
  ev.preventDefault()
1043
1870
  ev.stopPropagation()
1044
1871
  try {
1045
1872
  if (!controller.attach(sid, block)) {
1873
+ const snapX = (() => { try { const s = controller.scope(sid).shell.snapshot; return "phase=" + (s && s.phase) + " draftLen=" + (s && s.draft ? s.draft.length : -1) + " rev=" + (s && s.draftRev) } catch (e) { return "scope-throw:" + e.message } })();
1874
+ pcbDiag("EXIT-ATTACH-FAIL", snapX);
1046
1875
  console.log('[dsh-paste-code-block] attach failed; plain insert instead')
1047
1876
  const { actx, shell } = controller.scope(sid)
1048
1877
  const input = shell.snapshot
@@ -1057,6 +1886,17 @@ window.__ModuleLoader__.load({
1057
1886
  console.warn('[dsh-paste-code-block] could not insert block text')
1058
1887
  }
1059
1888
  }
1889
+ pcbDiag("OK-ATTACHED", "id=" + block.id);
1890
+ setTimeout(() => { try {
1891
+ const sc = controller.scope(sid).shell.snapshot || {};
1892
+ const occ = (sc.occurrences || []).filter((o) => o.source === 'paste-code-block').length;
1893
+ var __chip = document.querySelector('[data-composer-chip="paste-code-block"]'); var __r = __chip && __chip.getBoundingClientRect(); var __cs = __chip && getComputedStyle(__chip); var __csi = __chip && __chip.firstElementChild && getComputedStyle(__chip.firstElementChild); pcbDiag("POST-ATTACH", "phase=" + sc.phase + " draftLen=" + (sc.draft ? sc.draft.length : -1) + " occ=" + occ + " chips=" + document.querySelectorAll('[data-composer-chip="paste-code-block"]').length);
1894
+ var __hosts = []; var __seen = new Set();
1895
+ document.querySelectorAll('[data-composer-chip]').forEach(function (c) { var k = c.getAttribute('data-composer-chip') + '|' + (c.closest('[data-composer-seat]') ? 'seat' : (c.closest('[data-message-id],[data-message-id] *,.dsh-msg,.message') ? 'msg' : '?')); if (!__seen.has(k)) { __seen.add(k); __hosts.push(k) } });
1896
+ pcbDiag("CHIP-HOSTS", __hosts.join(',') || 'none');
1897
+ var __seats = [].map.call(document.querySelectorAll('[data-composer-seat]'), function (s) { return s.getBoundingClientRect().width + 'x' + Math.round(s.getBoundingClientRect().height) }).join(',');
1898
+ pcbDiag("SEATS", "n=" + document.querySelectorAll('[data-composer-seat]').length + " sizes=" + (__seats || '-') + " ce=" + document.querySelectorAll('[contenteditable="true"]').length); pcbDiag("CHIP-DOM", __chip ? ("rect=" + Math.round(__r.width) + "x" + Math.round(__r.height) + "@" + Math.round(__r.x) + "," + Math.round(__r.y) + " disp=" + __cs.display + " vis=" + __cs.visibility + " op=" + __cs.opacity + " innerDisp=" + (__csi ? __csi.display : "?") + " txt=(" + __chip.textContent.slice(0, 24) + ") parent=" + (__chip.parentElement ? __chip.parentElement.tagName + "." + String(__chip.parentElement.className).slice(0, 40) : "?")) : "NO-CHIP");
1899
+ } catch (e) { pcbDiag("POST-ATTACH-THROW", e.message) } }, 350);
1060
1900
  } catch (err) {
1061
1901
  console.error('[paste-code-block] attach failed:', err)
1062
1902
  }