dsh-paste-code-block 0.1.2 → 0.2.5

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
@@ -94,8 +94,8 @@ window.__ModuleLoader__.load({
94
94
  const NS = 'paste-code-block'
95
95
  const L10N = {
96
96
  zh: {
97
- 'block.code': '复制代码块{n}',
98
- 'block.text': '复制文本块{n}',
97
+ 'block.code': '代码块 {n}',
98
+ 'block.text': '文本块 {n}',
99
99
  'detail.aria': '代码块详情',
100
100
  'slot.label': '粘贴代码块详情',
101
101
  'lines.one': '{count} 行',
@@ -109,10 +109,13 @@ window.__ModuleLoader__.load({
109
109
  'edit.aria': '编辑代码块内容',
110
110
  'error.stale': '代码块已失效,请重新粘贴',
111
111
  'error.remove': '无法删除该块,请重试',
112
+ 'fold.code': '代码块',
113
+ 'fold.text': '文本块',
114
+ 'fold.aria': '展开或折叠该块',
112
115
  },
113
116
  en: {
114
- 'block.code': 'Code block #{n}',
115
- 'block.text': 'Text block #{n}',
117
+ 'block.code': 'Code #{n}',
118
+ 'block.text': 'Text #{n}',
116
119
  'detail.aria': 'Block details',
117
120
  'slot.label': 'Pasted block details',
118
121
  'lines.one': '{count} line',
@@ -126,6 +129,9 @@ window.__ModuleLoader__.load({
126
129
  'edit.aria': 'Edit block content',
127
130
  'error.stale': 'This block is no longer valid — paste it again',
128
131
  'error.remove': 'Could not remove the block — please retry',
132
+ 'fold.code': 'Code',
133
+ 'fold.text': 'Text',
134
+ 'fold.aria': 'Expand or collapse this block',
129
135
  },
130
136
  }
131
137
 
@@ -138,6 +144,13 @@ window.__ModuleLoader__.load({
138
144
  }
139
145
  }
140
146
 
147
+ // Sent-message fold card title (embedded copy of src/i18n.js#foldTitle).
148
+ function foldTitle(t, type, lang, lines) {
149
+ const meta = t(lines === 1 ? 'lines.one' : 'lines.other', { count: lines })
150
+ const name = type === 'code' ? (lang || t('fold.code')) : t('fold.text')
151
+ return `${name} · ${meta}`
152
+ }
153
+
141
154
  function detectBrowserLocale() {
142
155
  try {
143
156
  const langs = [...(navigator.languages || []), navigator.language].filter(Boolean).map((l) => String(l).toLowerCase())
@@ -147,15 +160,22 @@ window.__ModuleLoader__.load({
147
160
  }
148
161
  }
149
162
 
163
+ // Pre-0.1.3 chip labels: no longer rendered, still recognized (see below).
164
+ const LEGACY_L10N = {
165
+ zh: { 'block.code': '复制代码块{n}', 'block.text': '复制文本块{n}' },
166
+ en: { 'block.code': 'Code block #{n}', 'block.text': 'Text block #{n}' },
167
+ }
168
+
150
169
  /**
151
- * Recognize a rendered block label in ANY shipped locale and recover its
152
- * `{ type, n }` identity — this keeps chip clicks, ✕ deletes, and the
153
- * locale-switch retitling correct even for chips inserted under a
154
- * different language than the one currently active. Null otherwise.
170
+ * Recognize a rendered block label in ANY shipped locale (or a pre-0.1.3
171
+ * legacy locale) and recover its `{ type, n }` identity — this keeps chip
172
+ * clicks, ✕ deletes, and the locale-switch retitling correct even for
173
+ * chips inserted under a different language than the one currently
174
+ * active. Null otherwise.
155
175
  */
156
176
  function parseBlockLabel(text) {
157
177
  if (!text) return null
158
- for (const dict of [L10N.zh, L10N.en]) {
178
+ for (const dict of [L10N.zh, L10N.en, LEGACY_L10N.zh, LEGACY_L10N.en]) {
159
179
  for (const key of ['block.code', 'block.text']) {
160
180
  const pattern = new RegExp(
161
181
  '^' +
@@ -201,8 +221,8 @@ window.__ModuleLoader__.load({
201
221
  /**
202
222
  * Parse pasted text into a block, or null when it is ordinary short prose
203
223
  * that should paste plainly. `isCode` drives both the chip label
204
- * (i18n key `block.code` vs `block.text`, e.g. "复制代码块N" / "Code block
205
- * #N") and the fenced language on send.
224
+ * (i18n key `block.code` vs `block.text`, e.g. "代码块 N" / "Code #N")
225
+ * and the fenced language on send.
206
226
  */
207
227
  function parseBlock(raw) {
208
228
  const text = String(raw || '').replace(/^\uFEFF/, '')
@@ -262,6 +282,134 @@ window.__ModuleLoader__.load({
262
282
  return `\n\`\`\`${lang}\n${block.content}\n\`\`\`\n`
263
283
  }
264
284
 
285
+ // ----- fence segmentation (embedded copy of src/parse.js) ----------------
286
+ // Split a message text into ordered prose/fence segments (GFM-style ``` or
287
+ // ~~~ fences, opener indented <=3 spaces, closer >= opener length alone on
288
+ // its line). Unterminated openers stay prose. Keep in sync with parse.js.
289
+ function splitFencedSegments(text) {
290
+ const src = String(text == null ? '' : text)
291
+ if (!src) return []
292
+ const lines = src.split(/\r?\n/)
293
+ const segments = []
294
+ const OPEN = /^ {0,3}(`{3,}|~{3,})(.*)$/
295
+ let proseStart = 0
296
+ let i = 0
297
+ const flushProse = (end) => {
298
+ if (end > proseStart) segments.push({ kind: 'prose', text: lines.slice(proseStart, end).join('\n') })
299
+ }
300
+ while (i < lines.length) {
301
+ const m = OPEN.exec(lines[i])
302
+ if (!m) { i += 1; continue }
303
+ const marker = m[1]
304
+ const ch = marker[0]
305
+ const info = m[2].trim()
306
+ if (ch === '`' && info.includes('`')) { i += 1; continue }
307
+ const closeRe = new RegExp('^ {0,3}' + (ch === '`' ? '`' : '~') + '{' + marker.length + ',}[ \\t]*$')
308
+ let j = i + 1
309
+ let closed = false
310
+ while (j < lines.length) {
311
+ if (closeRe.test(lines[j])) { closed = true; break }
312
+ j += 1
313
+ }
314
+ if (!closed) { i += 1; continue }
315
+ flushProse(i)
316
+ const bodyLines = lines.slice(i + 1, j)
317
+ segments.push({
318
+ kind: 'fence',
319
+ lang: info ? info.split(/\s+/)[0] : '',
320
+ info,
321
+ body: bodyLines.join('\n'),
322
+ raw: lines.slice(i, j + 1).join('\n'),
323
+ })
324
+ i = j + 1
325
+ proseStart = i
326
+ }
327
+ flushProse(lines.length)
328
+ return segments
329
+ }
330
+
331
+ // ===== Recovery mirror (draft re-seed after workspace switch / reload) ===
332
+ // DSH persists a session's composer draft as PLAIN TEXT
333
+ // (`dsh.conversation.<sid>` in localStorage) and re-seeds the editor from
334
+ // it — but Lexical chip nodes cannot round-trip through text (design.md
335
+ // §7), so switching workspaces mid-draft left every pasted block behind:
336
+ // the seeded draft keeps only one invisible U+200B per lost chip. The
337
+ // plugin therefore mirrors its block CONTENT to localStorage itself and
338
+ // re-attaches the chips when it recognizes that stray-ZWSP signature
339
+ // (BlockDock mount effect → BlockController.recover). Intentional chip
340
+ // deletion removes the ZWSPs along with the nodes, so it never looks
341
+ // like a re-seed; the mirror clears itself on send and once no blocks
342
+ // remain, and stale entries expire after two weeks.
343
+ const RECOVERY_PREFIX = 'dsh-pcb-recovery.'
344
+ const RECOVERY_TTL_MS = 14 * 24 * 60 * 60 * 1000
345
+ const RECOVERY_MAX_CHARS = 3_000_000
346
+ const RECOVERY_MAX_DRAFT = 200_000
347
+
348
+ function recoverySave(sid, blocks, draftText) {
349
+ const key = RECOVERY_PREFIX + sid
350
+ try {
351
+ if (!blocks || blocks.length === 0) { localStorage.removeItem(key); return }
352
+ const draft = typeof draftText === 'string' && draftText.length <= RECOVERY_MAX_DRAFT ? draftText : ''
353
+ const json = JSON.stringify({ at: Date.now(), draft, blocks })
354
+ if (json.length > RECOVERY_MAX_CHARS) { console.warn('[dsh-pcb] draft too large to mirror; chips will not survive a reload'); return }
355
+ localStorage.setItem(key, json)
356
+ } catch (e) { /* quota / private mode: best effort */ }
357
+ }
358
+
359
+ function recoveryLoad(sid) {
360
+ const key = RECOVERY_PREFIX + sid
361
+ try {
362
+ const raw = localStorage.getItem(key)
363
+ if (!raw) return null
364
+ let data = null
365
+ try { data = JSON.parse(raw) } catch (e) { data = null }
366
+ if (!data || typeof data.at !== 'number' || Date.now() - data.at > RECOVERY_TTL_MS) { localStorage.removeItem(key); return null }
367
+ return data
368
+ } catch (e) { return null }
369
+ }
370
+
371
+ function recoveryClear(sid) {
372
+ try { localStorage.removeItem(RECOVERY_PREFIX + sid) } catch (e) { /* noop */ }
373
+ }
374
+
375
+ /**
376
+ * Mirror-side carry-over donor: a store whose mirrored draft matches the
377
+ * re-seeded one exactly and still holds blocks (after a page reload there
378
+ * is no live memory to match against). Returns { sid, blocks } on a
379
+ * UNIQUE match, null when absent or ambiguous.
380
+ */
381
+ function recoveryScanDonor(targetSid, draft) {
382
+ try {
383
+ let hit = null
384
+ for (let i = 0; i < localStorage.length; i += 1) {
385
+ const key = localStorage.key(i)
386
+ if (!key || !key.startsWith(RECOVERY_PREFIX)) continue
387
+ const sid = key.slice(RECOVERY_PREFIX.length)
388
+ if (sid === targetSid) continue
389
+ const data = recoveryLoad(sid)
390
+ if (!data || data.draft !== draft || !Array.isArray(data.blocks)) continue
391
+ if (!data.blocks.some((b) => b && typeof b.content === 'string' && b.content !== '')) continue
392
+ if (hit) return null // ambiguous — refuse to guess
393
+ hit = { sid, blocks: data.blocks }
394
+ }
395
+ return hit
396
+ } catch (e) { return null }
397
+ }
398
+
399
+ function recoveryPruneExpired() {
400
+ try {
401
+ const doomed = []
402
+ for (let i = 0; i < localStorage.length; i += 1) {
403
+ const k = localStorage.key(i)
404
+ if (!k || !k.startsWith(RECOVERY_PREFIX)) continue
405
+ let at = 0
406
+ try { at = (JSON.parse(localStorage.getItem(k) || '{}').at) || 0 } catch (e) { /* fall through */ }
407
+ if (!at || Date.now() - at > RECOVERY_TTL_MS) doomed.push(k)
408
+ }
409
+ for (const k of doomed) localStorage.removeItem(k)
410
+ } catch (e) { /* noop */ }
411
+ }
412
+
265
413
  // ===== Draft controller ==================================================
266
414
  class BlockController {
267
415
  /**
@@ -282,6 +430,8 @@ window.__ModuleLoader__.load({
282
430
  this.expiry = new Map()
283
431
  this.serializing = new Set()
284
432
  this.listeners = new Map()
433
+ this.recoverTimer = new Map() // sid -> debounce handle for the recovery mirror
434
+ this.lastInput = new Map() // sid -> draft text seen by the last recover() pass
285
435
  this.counter = 0
286
436
  }
287
437
 
@@ -438,6 +588,16 @@ window.__ModuleLoader__.load({
438
588
  this.refIndex.set(block.id, { sessionId: k, block })
439
589
  this.labelIndex.set(block.label, block.id)
440
590
  this.publish(sessionId)
591
+ // The shell just rewrote the draft (chip + placeholder char); until
592
+ // the next dock pass runs, this is the session's ground truth. Keep
593
+ // the typing-window fingerprint and the carry-over donor match fresh
594
+ // so the pass right after a paste cannot mistake the new draft for a
595
+ // rebuild.
596
+ try {
597
+ const after = this.scope(k).shell.snapshot
598
+ if (after && typeof after.draft === 'string') this.lastInput.set(k, after.draft)
599
+ } catch (e) { /* session vanished — the next pass re-observes */ }
600
+ this.mirrorNow(k) // attach is a key moment — persist synchronously
441
601
  return true
442
602
  }
443
603
 
@@ -563,6 +723,7 @@ window.__ModuleLoader__.load({
563
723
  for (const [label, r] of this.labelIndex) if (r === ref) this.labelIndex.delete(label)
564
724
  this.freeLabel(sessionId, entry.type, entry.labelNumber)
565
725
  this.publish(sessionId)
726
+ this.mirror(k)
566
727
  }
567
728
 
568
729
  /** Update a block's content while editing it in the detail card. */
@@ -571,23 +732,31 @@ window.__ModuleLoader__.load({
571
732
  if (!record) return
572
733
  record.block.content = content
573
734
  record.block.lines = content.split(/\r?\n/)
735
+ this.mirror(record.sessionId)
574
736
  }
575
737
 
576
738
  clearInFlight(sessionId) {
577
739
  const k = String(sessionId)
578
740
  const expiry = this.expiry.get(k)
579
741
  if (expiry) { clearTimeout(expiry); this.expiry.delete(k) }
742
+ const had = this.inFlight.has(k)
580
743
  for (const b of this.inFlight.get(k) || []) {
581
744
  this.refIndex.delete(b.id)
582
745
  this.freeLabel(sessionId, b.type, b.labelNumber)
583
746
  for (const [label, r] of this.labelIndex) if (r === b.id) this.labelIndex.delete(label)
584
747
  }
585
748
  this.inFlight.delete(k)
749
+ if (had) this.mirrorNow(k)
586
750
  }
587
751
 
588
752
  markSerializing(ref) {
589
753
  const record = this.refIndex.get(ref)
590
- if (record && !this.serializing.has(record.sessionId)) this.serializing.add(record.sessionId)
754
+ if (record && !this.serializing.has(record.sessionId)) {
755
+ this.serializing.add(record.sessionId)
756
+ // A send is starting: the content leaves the draft, the mirror may go.
757
+ // (A failed send re-mirrors via restoreFailed.)
758
+ recoveryClear(record.sessionId)
759
+ }
591
760
  }
592
761
 
593
762
  async serialize(ref) {
@@ -633,6 +802,12 @@ window.__ModuleLoader__.load({
633
802
  if (next.length > 0) this.list.set(k, next)
634
803
  else this.list.delete(k)
635
804
  this.publish(sessionId)
805
+ // Hand-deletion is final. Persist the pruned state synchronously so a
806
+ // later reload/carry-over cannot resurrect a deleted chip from a
807
+ // mirror the draft has already outlived — and so the recovery
808
+ // restore-path can never fire off invisible external residue once the
809
+ // list for this session is empty.
810
+ this.mirrorNow(k)
636
811
  }
637
812
 
638
813
  restoreFailed(sessionId) {
@@ -654,10 +829,168 @@ window.__ModuleLoader__.load({
654
829
  }
655
830
  if (survived.length) this.list.set(k, [...this.listFor(k), ...survived])
656
831
  this.publish(sessionId)
832
+ if (survived.length) this.mirror(k)
833
+ }
834
+
835
+ // ----- recovery mirror -------------------------------------------------
836
+ /** Debounced persistence of the session's current block payloads. */
837
+ mirror(sessionId) {
838
+ const k = String(sessionId)
839
+ const prev = this.recoverTimer.get(k)
840
+ if (prev) clearTimeout(prev)
841
+ this.recoverTimer.set(k, setTimeout(() => {
842
+ this.recoverTimer.delete(k)
843
+ this.mirrorNow(k)
844
+ }, 250))
845
+ }
846
+
847
+ mirrorNow(sessionId) {
848
+ const k = String(sessionId)
849
+ const t = this.recoverTimer.get(k)
850
+ if (t) { clearTimeout(t); this.recoverTimer.delete(k) }
851
+ recoverySave(k, this.listFor(k).map((b) => ({ isCode: !!b.isCode, lang: b.lang || '', content: b.content, src: b.sourceText || '' })), this.lastInput.get(k))
852
+ }
853
+
854
+ /**
855
+ * Restore the chips a text-only draft rebuild dropped. Two DSH paths
856
+ * strand them: (a) the persisted per-session draft re-seeds the composer
857
+ * after a page reload, and (b) a workspace switch on the new-session
858
+ * page CARRIES the draft plain-text to the target workspace's blank
859
+ * session (inputHub: next.setDraft(from.snapshot.draft)) — the chips,
860
+ * which are Lexical nodes plus controller state, never move. Both leave
861
+ * the same signature: stray U+200Bs (every chip contributes exactly one,
862
+ * and removing a chip removes its ZWSP too) with no live occurrence of
863
+ * ours. Deliberate deletion therefore never looks like a rebuild; and a
864
+ * carry-over is matched to its donor by the exact draft the donor last
865
+ * published — unique match only, no guessing. Blocks come from memory
866
+ * first (same tab), then this session's mirror, then the donor's memory
867
+ * or mirror. A stray U+200B that belongs to NO restorable block is
868
+ * ordinary invisible copy from an external app (WeChat, web pages):
869
+ * never restorable, cosmetic — it is left exactly where the user put
870
+ * it. Sweeping it used to rewrite the whole draft mid-typing and is
871
+ * retired in 0.2.2 (see the typing-window guard below).
872
+ *
873
+ * @param sessionId - session whose composer just (re)mounted.
874
+ * @param snapshot - live input state ({draft, occurrences, phase}).
875
+ * @returns whether the draft was rewritten by a real restore.
876
+ */
877
+ recover(sessionId, snapshot) {
878
+ const k = String(sessionId)
879
+ if (!snapshot || typeof snapshot.draft !== 'string') return false
880
+ const prior = this.lastInput.get(k)
881
+ this.lastInput.set(k, snapshot.draft)
882
+ if (this.serializing.has(k)) return false
883
+ if (snapshot.phase && snapshot.phase !== 'plain') return false
884
+ if ((snapshot.occurrences || []).some((o) => o.source === SOURCE)) return false
885
+ const draft = snapshot.draft
886
+ const strays = (draft.match(/\u200B/g) || []).length
887
+ if (strays === 0) return false
888
+ // Typing-window guard. Every committed keystroke re-runs this pass, so
889
+ // a draft that MOVED since the pass before it is live typing, not a
890
+ // text-only re-seed — and a real restore rewrites the whole document
891
+ // via setDraft: caret to the end, chips re-appended, an in-flight IME
892
+ // composition destroyed (0.2.2 bug: the first character typed was
893
+ // eaten and the view bounced once, again and again). Only a draft
894
+ // IDENTICAL to the last one observed for this session — or one this
895
+ // page view has never seen, or only ever saw the composer hydrate
896
+ // through while empty (fresh-boot re-seed) — can carry the rebuild
897
+ // signature. The empty-prior escape is sealed from the other side by
898
+ // reconcile pruning synchronously dropping the mirror: with nothing
899
+ // restorable on record, a moved-from-empty pass restores nothing.
900
+ if (prior && prior !== draft) return false
901
+ let shell
902
+ try {
903
+ shell = this.scope(k).shell
904
+ } catch (e) { return false } // session not ready yet — a later pass retries
905
+ const usable = (arr) => Array.isArray(arr)
906
+ ? arr.filter((b) => b && typeof b.content === 'string' && b.content !== '')
907
+ : []
908
+ let sources = usable(this.listFor(k))
909
+ let origin = sources.length ? 'memory' : null
910
+ let donor = null
911
+ if (!sources.length) {
912
+ const saved = recoveryLoad(k)
913
+ sources = usable(saved && saved.blocks)
914
+ if (sources.length) origin = 'mirror'
915
+ }
916
+ if (!sources.length) {
917
+ donor = this.findCarryoverSource(k, draft)
918
+ if (donor) {
919
+ sources = usable(this.listFor(donor))
920
+ origin = 'carry-over (live)'
921
+ }
922
+ }
923
+ if (!sources.length) {
924
+ const scanned = recoveryScanDonor(k, draft)
925
+ if (scanned && scanned.blocks.length) {
926
+ donor = scanned.sid
927
+ sources = usable(scanned.blocks)
928
+ origin = 'carry-over (mirror)'
929
+ }
930
+ }
931
+ if (sources.length === 0) {
932
+ // Nothing to restore — invisible external residue (or the residue of
933
+ // a deliberately deleted chip whose node already carries none of our
934
+ // state). NEVER rewrite the draft for it: rewriting drops the caret,
935
+ // destroys an in-flight IME composition and re-seeds stale text over
936
+ // whatever the user typed in the meantime (0.2.2).
937
+ return false
938
+ }
939
+ try {
940
+ shell.setDraft(draft.replace(/\u200B/g, ''))
941
+ } catch (e) {
942
+ console.warn('[dsh-pcb] recovery: draft rewrite failed', e)
943
+ return false
944
+ }
945
+ // The draft — and with it the blocks — now belongs to this session:
946
+ // forget the (possibly moved) bookkeeping; attach() re-derives ids,
947
+ // labels and numbers, and re-mirrors under this sid.
948
+ this.forgetSession(k)
949
+ if (donor) {
950
+ this.forgetSession(donor)
951
+ recoveryClear(donor)
952
+ this.lastInput.delete(donor)
953
+ }
954
+ let restored = 0
955
+ for (const b of sources) {
956
+ 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) }
957
+ if (this.attach(k, block)) restored += 1
958
+ }
959
+ console.log('[dsh-pcb] recovery: re-attached', restored, 'of', sources.length, 'block(s) via', origin, donor ? `(donor ${donor})` : '')
960
+ return restored > 0
961
+ }
962
+
963
+ /** Unmounted session that still lists blocks and last published EXACTLY this draft. */
964
+ findCarryoverSource(targetSid, draft) {
965
+ let found = null
966
+ for (const [x, entries] of this.list) {
967
+ if (x === targetSid || entries.length === 0) continue
968
+ if (this.serializing.has(x)) continue
969
+ const listeners = this.listeners.get(x)
970
+ if (listeners && listeners.size > 0) continue // still mounted — draft did not move
971
+ if (this.lastInput.get(x) !== draft) continue
972
+ if (found) return null // ambiguous
973
+ found = x
974
+ }
975
+ return found
976
+ }
977
+
978
+ /** Drop a session's block bookkeeping (its chips are gone or moving). */
979
+ forgetSession(sessionId) {
980
+ const k = String(sessionId)
981
+ for (const b of this.listFor(k)) {
982
+ this.freeLabel(k, b.type, b.labelNumber)
983
+ this.refIndex.delete(b.id)
984
+ for (const [label, r] of this.labelIndex) if (r === b.id) this.labelIndex.delete(label)
985
+ }
986
+ this.list.delete(k)
987
+ this.selected.delete(k)
657
988
  }
658
989
 
659
990
  dispose() {
660
991
  for (const t of this.expiry.values()) clearTimeout(t)
992
+ for (const t of this.recoverTimer.values()) clearTimeout(t)
993
+ this.recoverTimer.clear()
661
994
  this.expiry.clear(); this.list.clear(); this.selected.clear()
662
995
  this.used.clear(); this.refIndex.clear(); this.labelIndex.clear()
663
996
  this.inFlight.clear(); this.serializing.clear(); this.listeners.clear()
@@ -720,6 +1053,12 @@ window.__ModuleLoader__.load({
720
1053
  useComposerMark(railRef, props.sessionId, input.draftRev, blocks.length > 0)
721
1054
 
722
1055
  React.useEffect(() => {
1056
+ // Recognize a text-only draft re-seed (workspace switch / reload)
1057
+ // FIRST: re-attach lost chips before reconciliation prunes them. A
1058
+ // successful recovery mutates the draft, so this pass must NOT
1059
+ // reconcile against its now-stale snapshot — the next draftRev pass
1060
+ // does that with fresh occurrences.
1061
+ if (props.controller.recover(props.sessionId, input)) return
723
1062
  props.controller.reconcile(props.sessionId, input.occurrences || [], input.phase)
724
1063
  }, [props.controller, props.sessionId, input.draftRev, input.phase])
725
1064
 
@@ -827,6 +1166,162 @@ window.__ModuleLoader__.load({
827
1166
  )
828
1167
  }
829
1168
 
1169
+ // ===== Sent-message fold =================================================
1170
+ // A block that folded in the composer comes back *after send* as fenced
1171
+ // text inside the user bubble (DSH renders user messages as plain
1172
+ // pre-wrap runs), so the wall of text this plugin exists to prevent
1173
+ // reappears in the transcript. scanFolds() (see apply) walks sent
1174
+ // bubbles and folds every complete fenced block into a collapsed card:
1175
+ // [</>] python · 128 行 [⎘] ▾ (header click toggles)
1176
+ // React owns the bubble's own children, so nothing is removed or
1177
+ // reordered: the plainRun span being folded is hidden (display:none) and
1178
+ // a host element we fully own is inserted directly after it. A content
1179
+ // stamp on the run keeps the scan idempotent and self-refreshing — text
1180
+ // or locale revision changes rebuild the host, and a fold that becomes
1181
+ // unnecessary is undone. The copy button copies the RAW fenced text
1182
+ // (exactly what was sent); the bubble's own copy/edit/delete bar is
1183
+ // React-side and never sees this DOM.
1184
+ const FOLD_HOST_CLASS = 'dsh-pcb-fold-host'
1185
+ const FOLD_CARD_CLASS = 'dsh-pcb-fold'
1186
+ const FOLD_RUN_ATTR = 'data-dsh-pcb-run' // '1|<stamp>' folded · '0|<stamp>' checked, plain
1187
+ const FOLD_STAMP_ATTR = 'data-dsh-pcb-stamp' // on the host, matches the run's stamp
1188
+ const FOLD_BUBBLE_SEL = [
1189
+ '[data-chat-flow-kind="user"]',
1190
+ '[data-chat-flow-kind="steering"]',
1191
+ '[data-submission-echo]',
1192
+ '[data-pending-steering]',
1193
+ ].map((s) => `${s} [class*="bubble"]`).join(',')
1194
+ const foldOpenKeys = new Set() // user-toggled-open cards, stable across rebuilds
1195
+ let foldRev = 0 // bumped on locale switch so cards re-title
1196
+
1197
+ function hash32(str) {
1198
+ let h = 0x811c9dc5
1199
+ for (let i = 0; i < str.length; i += 1) {
1200
+ h ^= str.charCodeAt(i)
1201
+ h = Math.imul(h, 0x01000193)
1202
+ }
1203
+ return (h >>> 0).toString(36)
1204
+ }
1205
+
1206
+ function foldStamp(text) {
1207
+ return `${foldRev}:${text.length}:${hash32(text)}`
1208
+ }
1209
+
1210
+ // Cheap pre-filter so the per-frame scan stays trivial on unfenced runs.
1211
+ const FENCE_HINT = /(^|\n) {0,3}(```|~~~)/
1212
+
1213
+ function isFoldRun(el) {
1214
+ return el.tagName === 'SPAN' && (el.getAttribute('class') || '').includes('plainRun')
1215
+ }
1216
+
1217
+ /** Segments when the text holds at least one non-empty complete fence, else null. */
1218
+ function foldPlanFor(text) {
1219
+ if (!FENCE_HINT.test(text)) return null
1220
+ const segments = splitFencedSegments(text)
1221
+ let folds = 0
1222
+ for (const seg of segments) if (seg.kind === 'fence' && seg.body.trim() !== '') folds += 1
1223
+ return folds > 0 ? segments : null
1224
+ }
1225
+
1226
+ function buildFoldCard(seg, openKey, t) {
1227
+ const infoLang = (seg.lang || '').toLowerCase()
1228
+ // Only an EXPLICIT ```text fence is a text block. The composer never
1229
+ // emits a bare fence for prose (plain blocks always serialize as
1230
+ // ```text), so a bare ``` fence is code whose language the composer
1231
+ // could not name — fold it as a code card and re-guess the language
1232
+ // for display. (0.2.0 bug: bare fences folded as 文本块, mismatching
1233
+ // the 代码块 chip the message was drafted from.)
1234
+ const isText = infoLang === 'text'
1235
+ const lang = isText ? '' : (infoLang || detectLang(seg.body) || '')
1236
+ const type = isText ? 'text' : 'code'
1237
+ const lines = seg.body.split('\n').length
1238
+ const open = foldOpenKeys.has(openKey)
1239
+
1240
+ const card = document.createElement('div')
1241
+ card.className = FOLD_CARD_CLASS
1242
+ card.setAttribute('data-pcb-type', type)
1243
+ card.setAttribute('data-open', open ? '1' : '0')
1244
+
1245
+ const head = document.createElement('div')
1246
+ head.className = 'dsh-pcb-fold-head'
1247
+
1248
+ const toggle = document.createElement('button')
1249
+ toggle.type = 'button'
1250
+ toggle.className = 'dsh-pcb-fold-toggle'
1251
+ toggle.setAttribute('aria-expanded', open ? 'true' : 'false')
1252
+ toggle.setAttribute('aria-label', t('fold.aria'))
1253
+ toggle.title = t(open ? 'collapse' : 'expand')
1254
+ const glyph = document.createElement('span')
1255
+ glyph.className = 'dsh-pcb-fold-glyph'
1256
+ glyph.setAttribute('aria-hidden', 'true')
1257
+ glyph.textContent = type === 'code' ? '</>' : 'Aa'
1258
+ const title = document.createElement('span')
1259
+ title.className = 'dsh-pcb-fold-title'
1260
+ title.textContent = foldTitle(t, type, lang, lines)
1261
+ const chevron = document.createElement('span')
1262
+ chevron.className = 'dsh-pcb-fold-chevron'
1263
+ chevron.setAttribute('aria-hidden', 'true')
1264
+ chevron.textContent = '▾'
1265
+ toggle.append(glyph, title, chevron)
1266
+
1267
+ const copy = document.createElement('button')
1268
+ copy.type = 'button'
1269
+ copy.className = 'dsh-pcb-btn dsh-pcb-fold-copy'
1270
+ copy.title = t('copy')
1271
+ copy.setAttribute('aria-label', t('copy'))
1272
+ copy.textContent = '⎘'
1273
+ let copyTimer = 0
1274
+ copy.addEventListener('click', (ev) => {
1275
+ ev.preventDefault()
1276
+ ev.stopPropagation()
1277
+ try {
1278
+ const done = navigator.clipboard?.writeText(seg.raw)
1279
+ if (done && typeof done.then === 'function') {
1280
+ done.then(() => {
1281
+ copy.textContent = '✓'
1282
+ clearTimeout(copyTimer)
1283
+ copyTimer = setTimeout(() => { copy.textContent = '⎘' }, 1200)
1284
+ }, () => {})
1285
+ }
1286
+ } catch (e) { /* noop */ }
1287
+ })
1288
+
1289
+ head.append(toggle, copy)
1290
+ const pre = document.createElement('pre')
1291
+ pre.className = 'dsh-pcb-fold-body'
1292
+ pre.textContent = seg.body
1293
+ card.append(head, pre)
1294
+
1295
+ toggle.addEventListener('click', (ev) => {
1296
+ ev.preventDefault()
1297
+ ev.stopPropagation()
1298
+ const nowOpen = card.getAttribute('data-open') !== '1'
1299
+ card.setAttribute('data-open', nowOpen ? '1' : '0')
1300
+ toggle.setAttribute('aria-expanded', nowOpen ? 'true' : 'false')
1301
+ toggle.title = t(nowOpen ? 'collapse' : 'expand')
1302
+ if (nowOpen) foldOpenKeys.add(openKey)
1303
+ else foldOpenKeys.delete(openKey)
1304
+ })
1305
+ return card
1306
+ }
1307
+
1308
+ function buildFoldHost(segments, keyBase, t) {
1309
+ const host = document.createElement('div')
1310
+ host.className = FOLD_HOST_CLASS
1311
+ segments.forEach((seg, i) => {
1312
+ if (seg.kind === 'fence' && seg.body.trim() !== '') {
1313
+ host.appendChild(buildFoldCard(seg, `${keyBase}:${i}`, t))
1314
+ } else {
1315
+ const prose = document.createElement('span')
1316
+ prose.className = 'dsh-pcb-fold-prose'
1317
+ // An un-foldable fence (empty body) keeps its raw text, fences and all.
1318
+ prose.textContent = seg.kind === 'fence' ? seg.raw : seg.text
1319
+ host.appendChild(prose)
1320
+ }
1321
+ })
1322
+ return host
1323
+ }
1324
+
830
1325
  // ===== Styles ============================================================
831
1326
  const CSS = `
832
1327
  /* --- restyle DSH's inline reference chip into a boxed badge --- */
@@ -849,6 +1344,25 @@ window.__ModuleLoader__.load({
849
1344
  /* the label text (the trailing ✕ element is ours — never ellipsis it) */
850
1345
  [data-composer-chip="${SOURCE}"] > span > span:not(.${CHIP_X_CLASS}){font-weight:500!important;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
851
1346
 
1347
+ /* --- type identity: leading glyph + tint (data-pcb-type set by scanChips) --- */
1348
+ [data-composer-chip="${SOURCE}"][data-pcb-type="code"] > span{
1349
+ border-color:var(--dsh-pcb-code-border,rgba(76,110,245,.38))!important;
1350
+ background:var(--dsh-pcb-code-bg,rgba(76,110,245,.09))!important;
1351
+ color:var(--dsw-alias-state-business-primary,#4c6ef5)!important
1352
+ }
1353
+ [data-composer-chip="${SOURCE}"][data-pcb-type="code"]:hover > span{
1354
+ border-color:var(--dsw-alias-state-business-primary,#4c6ef5)!important;
1355
+ background:var(--dsh-pcb-code-bg-hover,rgba(76,110,245,.16))!important
1356
+ }
1357
+ [data-composer-chip="${SOURCE}"][data-pcb-type="code"] > span::before{
1358
+ content:'</>';flex:none;font-family:var(--dsw-font-mono,ui-monospace,Menlo,Consolas,monospace);
1359
+ font-size:10px;font-weight:700;line-height:16px;opacity:.9
1360
+ }
1361
+ [data-composer-chip="${SOURCE}"][data-pcb-type="text"] > span::before{
1362
+ content:'Aa';flex:none;font-size:10px;font-weight:700;line-height:16px;
1363
+ letter-spacing:.2px;color:var(--dsw-alias-label-tertiary)
1364
+ }
1365
+
852
1366
  /* --- per-chip ✕ delete button (element we own, appended inside the chip) --- */
853
1367
  .${CHIP_X_CLASS}{
854
1368
  box-sizing:border-box;flex:none;width:16px;height:16px;display:inline-grid;place-items:center;
@@ -879,9 +1393,26 @@ window.__ModuleLoader__.load({
879
1393
  /* --- hide the composer's own placeholder while chips/cards are active --- */
880
1394
  [data-dsh-pcb-active="1"] [data-composer-placeholder]{visibility:hidden}
881
1395
 
1396
+ /* --- sent-message fold cards (conversation bubbles) --- */
1397
+ .dsh-pcb-fold-host{box-sizing:border-box;display:flex;flex-direction:column;gap:8px;width:100%;min-width:0}
1398
+ .dsh-pcb-fold-prose{white-space:pre-wrap;word-break:break-word}
1399
+ .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}
1400
+ .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}
1401
+ .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}
1402
+ .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}
1403
+ .dsh-pcb-fold[data-pcb-type="code"] .dsh-pcb-fold-glyph{color:var(--dsw-alias-state-business-primary,#4c6ef5)}
1404
+ .dsh-pcb-fold[data-pcb-type="text"] .dsh-pcb-fold-glyph{color:var(--dsw-alias-label-tertiary)}
1405
+ .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)}
1406
+ .dsh-pcb-fold[data-pcb-type="text"] .dsh-pcb-fold-title{font-family:var(--dsw-font-family)}
1407
+ .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}
1408
+ .dsh-pcb-fold[data-open="1"] .dsh-pcb-fold-chevron{transform:rotate(180deg)}
1409
+ .dsh-pcb-fold[data-open="0"] .dsh-pcb-fold-body{display:none}
1410
+ .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)}
1411
+
882
1412
  @media (max-width:640px){
883
1413
  .dsh-pcb-head{height:34px}
884
1414
  .dsh-pcb-lang{max-width:55%}
1415
+ .dsh-pcb-fold-body{max-height:240px}
885
1416
  }
886
1417
  @media (pointer:coarse){
887
1418
  .${CHIP_X_CLASS}{width:22px;height:22px;font-size:15px}
@@ -892,7 +1423,9 @@ window.__ModuleLoader__.load({
892
1423
  const inject = ['slots', 'sessions', 'conversation', 'inputTriggers', 'locale']
893
1424
 
894
1425
  function apply(ctx) {
895
- try { console.log('[dsh-paste-code-block] client loaded') } catch (e) { /* noop */ }
1426
+ // Bump alongside package.json on every release — the load log is the
1427
+ // only proof of WHICH bundle generation the browser actually loaded.
1428
+ try { console.log('[dsh-paste-code-block] client loaded v0.2.5') } catch (e) { /* noop */ }
896
1429
 
897
1430
  // Publish our dictionaries under the plugin namespace, then bind a
898
1431
  // translator that always reflects the ACTIVE DSH language (Settings →
@@ -906,7 +1439,10 @@ window.__ModuleLoader__.load({
906
1439
  : createT(detectBrowserLocale())
907
1440
 
908
1441
  const controller = new BlockController(ctx, t)
1442
+ // Test seam (tests/fold-smoke.test.js drives recover() directly).
1443
+ exports.__controllerForTests = controller
909
1444
  ctx.effect(() => () => controller.dispose(), 'paste-code-block: state')
1445
+ try { recoveryPruneExpired() } catch (e) { /* private mode: recovery simply off */ }
910
1446
 
911
1447
  // ----- chip DOM sync: localized labels + per-chip ✕ delete button -----
912
1448
  function scanChips() {
@@ -923,6 +1459,11 @@ window.__ModuleLoader__.load({
923
1459
  if (!sid) continue
924
1460
  const block = controller.listFor(sid).find((b) => b.type === parsed.type && b.labelNumber === parsed.n)
925
1461
  if (!block) continue
1462
+ // Tag the chip host with its type so CSS can render the leading
1463
+ // glyph and tint (code = blue `</>`, text = neutral `Aa`).
1464
+ if (host.getAttribute('data-pcb-type') !== parsed.type) {
1465
+ host.setAttribute('data-pcb-type', parsed.type)
1466
+ }
926
1467
  // Retitle the chip to the current language. Lexical caches the
927
1468
  // insert-time label inside the node, so on a Settings → Language
928
1469
  // switch (or a chip that remounted with the old label) the DOM is
@@ -952,10 +1493,73 @@ window.__ModuleLoader__.load({
952
1493
  }
953
1494
  }
954
1495
 
1496
+ // ----- sent-message fold: the scan ------------------------------------
1497
+ function foldRun(run, plan, keyBase, stamp) {
1498
+ run.setAttribute(FOLD_RUN_ATTR, `1|${stamp}`)
1499
+ run.style.display = 'none'
1500
+ const next = run.nextElementSibling
1501
+ if (next && next.classList.contains(FOLD_HOST_CLASS)) {
1502
+ if (next.getAttribute(FOLD_STAMP_ATTR) === stamp) return // already current
1503
+ next.remove()
1504
+ }
1505
+ const host = buildFoldHost(plan, keyBase, t)
1506
+ host.setAttribute(FOLD_STAMP_ATTR, stamp)
1507
+ run.insertAdjacentElement('afterend', host)
1508
+ }
1509
+
1510
+ /** Restore a previously folded run to DSH's own rendering. */
1511
+ function unfoldRun(run, stamp) {
1512
+ const next = run.nextElementSibling
1513
+ if (next && next.classList.contains(FOLD_HOST_CLASS)) next.remove()
1514
+ run.style.display = ''
1515
+ run.setAttribute(FOLD_RUN_ATTR, `0|${stamp}`)
1516
+ }
1517
+
1518
+ function scanFolds() {
1519
+ if (typeof document === 'undefined') return
1520
+ let bubbles
1521
+ try { bubbles = document.querySelectorAll(FOLD_BUBBLE_SEL) } catch (e) { return }
1522
+ for (const bubble of bubbles) {
1523
+ if (!bubble.isConnected) continue
1524
+ const holder = bubble.closest('[data-chat-flow-key]')
1525
+ const flowKey = holder ? holder.getAttribute('data-chat-flow-key') || '' : ''
1526
+ const kids = [...bubble.children]
1527
+ for (let i = 0; i < kids.length; i += 1) {
1528
+ const el = kids[i]
1529
+ if (el.classList.contains(FOLD_HOST_CLASS)) continue
1530
+ if (!isFoldRun(el)) continue
1531
+ const text = el.textContent || ''
1532
+ const stamp = foldStamp(text)
1533
+ const marked = el.getAttribute(FOLD_RUN_ATTR) || ''
1534
+ if (marked === `1|${stamp}` || marked === `0|${stamp}`) continue // current
1535
+ const plan = foldPlanFor(text)
1536
+ if (!plan) {
1537
+ if (marked.startsWith('1|')) unfoldRun(el, stamp)
1538
+ else el.setAttribute(FOLD_RUN_ATTR, `0|${stamp}`)
1539
+ continue
1540
+ }
1541
+ // Echo/pending rows have no flow key — fall back to a content hash so
1542
+ // two simultaneous unkeyed rows never share one open-state slot.
1543
+ const keyBase = `${flowKey || 'h' + hash32(text)}#${i}`
1544
+ foldRun(el, plan, keyBase, stamp)
1545
+ }
1546
+ // Drop orphan hosts (e.g. React remounted the run span underneath us).
1547
+ for (const el of [...bubble.children]) {
1548
+ if (!el.classList.contains(FOLD_HOST_CLASS)) continue
1549
+ const prev = el.previousElementSibling
1550
+ const mark = prev && prev.getAttribute ? prev.getAttribute(FOLD_RUN_ATTR) : null
1551
+ const keep = !!mark && mark.startsWith('1|') &&
1552
+ prev.style.display === 'none' &&
1553
+ el.getAttribute(FOLD_STAMP_ATTR) === mark.slice(2)
1554
+ if (!keep) el.remove()
1555
+ }
1556
+ }
1557
+ }
1558
+
955
1559
  let scanScheduled = 0
956
1560
  function scheduleScan() {
957
1561
  if (scanScheduled) return
958
- const run = () => { scanScheduled = 0; scanChips() }
1562
+ const run = () => { scanScheduled = 0; scanChips(); scanFolds() }
959
1563
  scanScheduled = typeof requestAnimationFrame === 'function' ? requestAnimationFrame(run) : setTimeout(run, 16)
960
1564
  }
961
1565
 
@@ -967,13 +1571,28 @@ window.__ModuleLoader__.load({
967
1571
  }, 'paste-code-block: chip sync observer')
968
1572
 
969
1573
  // A locale switch (or a late dictionary registration bumps the revision
970
- // too) re-derives labels: controller state first, chip DOM second.
1574
+ // too) re-derives labels: controller state first, chip DOM second. Sent
1575
+ // fold cards are rebuilt the same way — bumping foldRev invalidates
1576
+ // every run stamp, so the next scan re-titles the cards in the new
1577
+ // language while foldOpenKeys keeps who was expanded.
971
1578
  ctx.effect(() => {
972
1579
  const face = ctx.locale
973
1580
  if (!face || typeof face.subscribe !== 'function') return undefined
974
- return face.subscribe(() => { controller.retitleAll(); scheduleScan() })
1581
+ return face.subscribe(() => { controller.retitleAll(); foldRev += 1; scheduleScan() })
975
1582
  }, 'paste-code-block: locale sync')
976
- scheduleScan() // late-mount safety for chips rendered before plugin boot
1583
+ scheduleScan() // late-mount safety for chips and sent bubbles rendered before plugin boot
1584
+
1585
+ // On dispose (plugin removed / HMR swap) hand the transcript back to DSH.
1586
+ ctx.effect(() => () => {
1587
+ try {
1588
+ for (const host of document.querySelectorAll(`.${FOLD_HOST_CLASS}`)) host.remove()
1589
+ for (const run of document.querySelectorAll(`[${FOLD_RUN_ATTR}]`)) {
1590
+ run.style.display = ''
1591
+ run.removeAttribute(FOLD_RUN_ATTR)
1592
+ }
1593
+ } catch (e) { /* noop */ }
1594
+ foldOpenKeys.clear()
1595
+ }, 'paste-code-block: fold restore')
977
1596
 
978
1597
  ctx.effect(() => ctx.inputTriggers.registerSource({
979
1598
  trigger: '@',
@@ -995,6 +1614,34 @@ window.__ModuleLoader__.load({
995
1614
  return () => style.remove()
996
1615
  }, 'paste-code-block: styles')
997
1616
 
1617
+ // Paste-storm guard. External automation — a Tampermonkey AutoClicker,
1618
+ // a clipboard bridge, IME glue, or a held-down Ctrl+V — can re-dispatch
1619
+ // the SAME clipboard text dozens of times a second. Each accepted paste
1620
+ // mints a new block, so a storm stuffs the composer with duplicate
1621
+ // chips. Collapse runs of an identical clipboard text that arrive faster
1622
+ // than a human can act into a single paste (a 1200 ms sliding window,
1623
+ // refreshed on every matching event, so a held repeat stays suppressed
1624
+ // while it continues). 0.2.4 added a content-liveness dedup that ALSO
1625
+ // suppressed a re-paste of the same text while its block was still
1626
+ // attached — that made "paste the same snippet twice" impossible, which
1627
+ // users hit immediately. 0.2.5 reverts that: identical text pasted after
1628
+ // the burst window is a fresh intent and gets its own block. Only
1629
+ // machine-paced repeats within the window are collapsed.
1630
+ const STORM_WINDOW_MS = 1200
1631
+ const pasteStorm = {
1632
+ lastText: '',
1633
+ lastAt: 0,
1634
+ // True when this text is the tail of a storm; every call refreshes
1635
+ // the window, so a held repeat stays suppressed while it continues.
1636
+ seen(text, now) {
1637
+ const dup = text === this.lastText && now - this.lastAt < STORM_WINDOW_MS
1638
+ this.lastText = text
1639
+ this.lastAt = now
1640
+ return dup
1641
+ },
1642
+ }
1643
+ exports.__pasteStormForTests = pasteStorm
1644
+
998
1645
  // Turn qualifying pastes into boxed inline chips.
999
1646
  const onPaste = (ev) => {
1000
1647
  const clip = ev.clipboardData
@@ -1007,7 +1654,19 @@ window.__ModuleLoader__.load({
1007
1654
  if (!sid) return
1008
1655
  const block = parseBlock(text)
1009
1656
  if (!block) { console.log('[dsh-paste-code-block] paste NOT block', text.length); return }
1010
- console.log('[dsh-paste-code-block] intercept', block.isCode ? 'code' : 'text', block.lines.length, 'lang=', block.lang)
1657
+ if (pasteStorm.seen(text, Date.now())) {
1658
+ // Consume the event (never let the raw text fall through to the
1659
+ // composer) but create nothing.
1660
+ ev.preventDefault()
1661
+ ev.stopPropagation()
1662
+ console.log('[dsh-paste-code-block] storm paste ignored (<' + STORM_WINDOW_MS + 'ms repeat)')
1663
+ return
1664
+ }
1665
+ block.sourceText = text
1666
+ // trusted=false means a page script dispatched this paste (userscript,
1667
+ // clipboard bridge, replay glue) rather than a real keystroke — the
1668
+ // load-log line that names the source when a storm report comes in.
1669
+ console.log('[dsh-paste-code-block] intercept', block.isCode ? 'code' : 'text', block.lines.length, 'lang=', block.lang, 'trusted=', ev.isTrusted)
1011
1670
  ev.preventDefault()
1012
1671
  ev.stopPropagation()
1013
1672
  try {