dsh-as-aistudio 0.4.6 → 0.4.8

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/CHANGELOG.md CHANGED
@@ -4,6 +4,18 @@
4
4
 
5
5
  ## 中文
6
6
 
7
+ **0.4.8** —— 跟进内嵌组件:delete 0.2.0、rerun 0.2.7。
8
+
9
+ - **dsh-delete-turn 0.2.0**:多选删除;载体写上 `op`/`turn`,整轮删除额外写 `deletedTurn`/`deleteIndex`。
10
+ - **dsh-rerun-turn 0.2.7**:遮蔽窗口按表面节点、重放按槽位顺序、生成期间冻结旧轮渲染(不再闪回/空白/丢提问)。
11
+ - 内置版本记录同步更新;`vendor --check` 干净,115/115 全绿。
12
+
13
+ **0.4.7** —— 打开着的会话不再重新解码整份日志(跟进 `dsh-edit-turn` 0.5.13)。
14
+
15
+ - **组件跟进**:`dsh-edit-turn` **0.5.13**、`dsh-rerun-turn` 0.2.6、`dsh-delete-turn` 0.1.15、`dsh-markdown-bubble` 0.1.5。
16
+
17
+ - 单独计时后:插件的全部计算只有几十毫秒,**读日志 + 解压 1,408 ms** 才是瓶颈。现在宿主已经打开的会话直接用内存快照,不再从磁盘重解一遍 —— 编辑笔的出现从"秒级"降到"几十毫秒级"。
18
+
7
19
  **0.4.6** —— 复制已修好,临时诊断撤掉;`/state` 再快一档。
8
20
 
9
21
  - **组件跟进**:`dsh-edit-turn` **0.5.12**、`dsh-rerun-turn` 0.2.6、`dsh-delete-turn` 0.1.15、`dsh-markdown-bubble` 0.1.5。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-as-aistudio",
3
- "version": "0.4.6",
3
+ "version": "0.4.8",
4
4
  "description": "Google-AI-Studio-style turn controls for DeepSeek Harness, in one install: edit a prompt and re-run it, re-run a reply in place, delete a turn from the model context, and render sent prompts as Markdown. Self-contained: the plugin list shows this one row, and the four components are vendored inside it - installing the studio installs nothing else.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/client.js CHANGED
@@ -43,7 +43,7 @@ window.__ModuleLoader__.load({
43
43
  const RERUN_PREFIX = '/dsh-rerun-turn'
44
44
 
45
45
  /** Keep in sync with package.json and lib/index.js. */
46
- const PLUGIN_VERSION = '0.5.12'
46
+ const PLUGIN_VERSION = '0.5.13'
47
47
 
48
48
  // --- copy -----------------------------------------------------------------
49
49
 
@@ -5527,7 +5527,7 @@ window.__ModuleLoader__.load({
5527
5527
  const ROUTE_PREFIX = '/dsh-rerun-turn'
5528
5528
 
5529
5529
  /** Keep in sync with package.json and lib/index.js. */
5530
- const PLUGIN_VERSION = '0.2.6'
5530
+ const PLUGIN_VERSION = '0.2.7'
5531
5531
 
5532
5532
  // --- copy -----------------------------------------------------------------
5533
5533
 
@@ -5846,6 +5846,9 @@ window.__ModuleLoader__.load({
5846
5846
  // button still carries the previous instance's click handler) must not
5847
5847
  // open a confirm panel on a controller nobody renders any more.
5848
5848
  this.disposed = false
5849
+ // Rows cloned from the rerun turn so the old prompt stays on screen while
5850
+ // the host has already shadowed it (see freezeTurn).
5851
+ this.frozen = []
5849
5852
  this.view = Object.freeze({
5850
5853
  hidden: new Map(),
5851
5854
  reruns: [],
@@ -5938,6 +5941,14 @@ window.__ModuleLoader__.load({
5938
5941
  if (typeof reply.messageId === 'string' && reply.messageId !== '') repliesByMessage.set(reply.messageId, reply)
5939
5942
  }
5940
5943
  const rerunning = data.rerunning === true
5944
+ // Merge the optimistic hide from the click that started this rerun:
5945
+ // /state only reports a rerun's rows once it is COMPLETE, and the
5946
+ // retired prompt must stay hidden while the replay streams in.
5947
+ if (this.pendingHidden instanceof Map) {
5948
+ for (const [seq] of this.pendingHidden) if (!hidden.has(seq)) hidden.set(seq, null)
5949
+ if (!rerunning) this.pendingHidden = null
5950
+ }
5951
+ if (!rerunning) this.releaseFrozen()
5941
5952
  const markerTurns = new Set(Array.isArray(data.markerTurns) ? data.markerTurns.filter((turn) => typeof turn === 'number') : [])
5942
5953
  // Turns whose content a landed rerun fully retired: their
5943
5954
  // process/tail strips hold nothing and must vanish with it.
@@ -6061,9 +6072,45 @@ window.__ModuleLoader__.load({
6061
6072
  this.publish({ notice: null, noticeError: false, dismissedKey: key })
6062
6073
  }
6063
6074
 
6075
+ /**
6076
+ * 冻结目标轮的渲染:宿主在 apply 的那一刻就把这一轮的表面节点遮蔽了,重放的提问
6077
+ * 要等下一次渲染才出现,中间那一帧会没有提问气泡。这里在点击时把这一轮的行
6078
+ * 克隆一份,apply 成功后插回去,重跑结束再撤掉——只影响视觉,不碰日志。
6079
+ */
6080
+ freezeTurn(turn) {
6081
+ this.releaseFrozen()
6082
+ if (!Number.isFinite(turn)) return
6083
+ for (const row of document.querySelectorAll('[data-chat-flow-key][data-chat-turn]')) {
6084
+ if (Number(row.getAttribute('data-chat-turn')) !== turn) continue
6085
+ const parent = row.parentElement
6086
+ if (parent === null) continue
6087
+ const clone = row.cloneNode(true)
6088
+ clone.setAttribute('data-dsrr-frozen', '1')
6089
+ // 别让平台/本插件把它当真实行:去掉流键,摘掉自己注入的动作条。
6090
+ clone.removeAttribute('data-chat-flow-key')
6091
+ for (const junk of clone.querySelectorAll('[data-dsrrh], [class*="_actions"]')) junk.remove()
6092
+ this.frozen.push({ clone, parent, before: row.nextSibling })
6093
+ }
6094
+ }
6095
+
6096
+ applyFrozen() {
6097
+ for (const item of this.frozen) {
6098
+ if (item.clone.parentElement !== null || !item.parent.isConnected) continue
6099
+ const anchor = item.before !== null && item.before.parentElement === item.parent ? item.before : null
6100
+ item.parent.insertBefore(item.clone, anchor)
6101
+ }
6102
+ }
6103
+
6104
+ releaseFrozen() {
6105
+ for (const item of this.frozen) if (item.clone.parentElement !== null) item.clone.remove()
6106
+ this.frozen = []
6107
+ }
6108
+
6064
6109
  async send(entry) {
6065
6110
  if (this.view.pending) return
6066
6111
  this.publish({ pending: true, failure: null, notice: null, noticeError: false })
6112
+ const turn = Number.isFinite(entry.turn) ? entry.turn : entry.reply && Number.isFinite(entry.reply.turn) ? entry.reply.turn : null
6113
+ this.freezeTurn(turn)
6067
6114
  try {
6068
6115
  const res = await fetch(`${ROUTE_PREFIX}/apply`, {
6069
6116
  method: 'POST',
@@ -6071,6 +6118,7 @@ window.__ModuleLoader__.load({
6071
6118
  body: JSON.stringify({ sessionId: this.sessionId, seq: entry.seq, messageId: entry.messageId }),
6072
6119
  })
6073
6120
  const data = await res.json().catch(() => ({}))
6121
+ this.releaseFrozen()
6074
6122
  if (!res.ok || !data.ok) {
6075
6123
  const code = data && data.code ? String(data.code) : 'generic'
6076
6124
  if (code === 'already-retired') {
@@ -6085,14 +6133,22 @@ window.__ModuleLoader__.load({
6085
6133
  this.publish({ pending: false, failure: code, failureDetail: typeof data.error === 'string' && data.error !== '' ? data.error : null })
6086
6134
  return
6087
6135
  }
6088
- // Do NOT hide the retired rows here: the rerun's replacement (fresh
6089
- // answer + replay) is not on screen yet, and /state only reports the
6090
- // retired set for COMPLETED reruns. The rows switch over when the
6091
- // replay lands.
6136
+ // 宿主已经把这几行遮蔽了(shadow 是同步写下的),所以立刻隐藏它们:
6137
+ // 否则窗口盖掉 edit 载体的一瞬,它压住的原文会重新渲染出来(闪回)。
6138
+ // /state 在重跑完成前不会报告这些行(只报已完成),所以这份乐观集合要
6139
+ // 一直保留到 rerunning 结束,见 load() 里的合并。
6140
+ // 只藏“窗口之外的原文”(被 edit 载体替换掉的那些)。窗口自身的行由
6141
+ // 重跑中的分组逻辑处理,整段藏起来会把界面清空。
6142
+ const immediate = Array.isArray(data.retired) ? data.retired : []
6143
+ const pendingHidden = new Map()
6144
+ for (const seq of immediate) if (typeof seq === 'number') pendingHidden.set(seq, null)
6145
+ this.pendingHidden = pendingHidden
6146
+ this.applyFrozen()
6092
6147
  this.publish({ pending: false, failure: null, rerunning: true })
6093
6148
  this.ensurePolling()
6094
6149
  this.load(true)
6095
6150
  } catch {
6151
+ this.releaseFrozen()
6096
6152
  this.publish({ pending: false, failure: 'generic' })
6097
6153
  }
6098
6154
  }
@@ -7084,6 +7140,14 @@ window.__ModuleLoader__.load({
7084
7140
  'dialog.confirm': '删除',
7085
7141
  'dialog.pending': '删除中…',
7086
7142
  'dialog.retry': '重试',
7143
+ 'dialog.title.batch': '删除选中的内容?',
7144
+ 'dialog.desc.batch': '选中的消息、步骤与回复将从模型上下文中一并移除;没有被选中的内容保持原样。',
7145
+ 'action.tooltip.select': '多选删除',
7146
+ 'action.tooltip.selected': '已选中,再点一次取消',
7147
+ 'select.count': '已选',
7148
+ 'select.unit': '项',
7149
+ 'select.delete': '删除选中',
7150
+ 'select.cancel': '退出多选',
7087
7151
  'config.ask': '删除时询问',
7088
7152
  'config.hint.ask': '每次删除前都会弹出确认框。',
7089
7153
  'config.hint.auto': '删除会直接执行,不再询问。',
@@ -7124,6 +7188,14 @@ window.__ModuleLoader__.load({
7124
7188
  'dialog.confirm': 'Delete',
7125
7189
  'dialog.pending': 'Deleting...',
7126
7190
  'dialog.retry': 'Retry',
7191
+ 'dialog.title.batch': 'Delete the selected items?',
7192
+ 'dialog.desc.batch': 'The selected messages, steps and replies leave the model context together; everything not selected stays exactly as it is.',
7193
+ 'action.tooltip.select': 'Select for multi-delete',
7194
+ 'action.tooltip.selected': 'Selected - click again to unselect',
7195
+ 'select.count': 'Selected',
7196
+ 'select.unit': '',
7197
+ 'select.delete': 'Delete selected',
7198
+ 'select.cancel': 'Exit multi-select',
7127
7199
  'config.ask': 'Ask before deleting',
7128
7200
  'config.hint.ask': 'Show a confirmation dialog before every deletion.',
7129
7201
  'config.hint.auto': 'Delete right away without asking.',
@@ -7155,6 +7227,10 @@ window.__ModuleLoader__.load({
7155
7227
  '.dshdt-row{position:relative}',
7156
7228
  '.dshdt-floating{position:absolute;top:2px;right:6px;z-index:2;opacity:0;transition:opacity .12s}',
7157
7229
  '.dshdt-row:hover .dshdt-floating,.dshdt-floating:focus-within{opacity:1}',
7230
+ // While rows are being picked, every toggle has to be reachable without a hover pass over each row.
7231
+ // In the mode the hover host would only sit as an empty box over the
7232
+ // badge, so it steps out entirely instead of fading in.
7233
+ '.dshdt-select-on .dshdt-floating{display:none}',
7158
7234
  '[data-variant="think"]{position:relative}',
7159
7235
  '.dshdt-think-action{position:absolute;top:0;right:4px;opacity:0;transition:opacity .12s}',
7160
7236
  '[data-variant="think"]:hover .dshdt-think-action,.dshdt-think-action:focus-within{opacity:1}',
@@ -7171,6 +7247,31 @@ window.__ModuleLoader__.load({
7171
7247
  '.dshdt-turn:disabled{cursor:default;opacity:.4}',
7172
7248
  '.dshdt-turn svg{width:15px;height:15px}',
7173
7249
  '[data-dshdt-no-target="1"] .dshdt-action{display:none!important}',
7250
+ // In multi-select the toggle is a badge pinned to the row itself: the
7251
+ // action strip of a message row only exists while the pointer is on it,
7252
+ // so a toggle parked inside that strip is invisible exactly when the
7253
+ // mode needs it.
7254
+ '.dshdt-check{width:22px;height:22px;padding:0;display:none;align-items:center;justify-content:center;border:1px solid var(--dsw-alias-border-l2,rgba(127,127,127,.35));border-radius:999px;background:var(--dsw-alias-bg-elevated,#fff);color:var(--dsw-alias-label-tertiary,#8a8f98);cursor:pointer;box-shadow:0 1px 4px rgba(0,0,0,.14);transition:background-color .12s,color .12s}',
7255
+ '.dshdt-select-on{position:relative;opacity:1!important}',
7256
+ '.dshdt-select-on>.dshdt-check{display:inline-flex;position:absolute;top:6px;right:6px;z-index:5}',
7257
+ '.dshdt-check:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.12));color:var(--dsw-alias-button-primary-fill,#4d6bfe)}',
7258
+ '.dshdt-check:focus-visible{outline:2px solid var(--dsw-alias-button-primary-fill,#4d6bfe);outline-offset:2px}',
7259
+ '.dshdt-check svg{width:13px;height:13px}',
7260
+ '.dshdt-checked .dshdt-check{color:var(--dsw-alias-button-primary-fill,#4d6bfe)}',
7261
+ '.dshdt-checked{outline:2px solid var(--dsw-alias-button-primary-fill,#4d6bfe);outline-offset:-2px;border-radius:14px}',
7262
+ '.dshdt-action-host[data-dshdt-badge-only="1"] .dshdt-action,.dshdt-action-host[data-dshdt-badge-only="1"] .dshdt-turn{display:none!important}',
7263
+ '.dshdt-action-host[data-dshdt-badge-only="1"]{pointer-events:none}',
7264
+ '.dshdt-action-host[data-dshdt-badge-only="1"]>.dshdt-check{pointer-events:auto}',
7265
+ '.dshdt-select-on .dshdt-action,.dshdt-select-on .dshdt-turn{display:none!important}',
7266
+ // The header entry sits among the Session utilities: the same 32px pill
7267
+ // the official utilities use, icon-only to stay compact.
7268
+ '.dshdt-header-select{display:inline-flex;align-items:center;justify-content:center;gap:4px;min-width:32px;height:32px;padding:0 8px;border:1px solid var(--dsw-alias-border-l2,rgba(127,127,127,.24));border-radius:18px;background:transparent;color:var(--dsw-alias-label-primary,inherit);font-family:var(--dsw-font-family,inherit);font-size:13px;line-height:20px;cursor:pointer;transition:background-color .12s,color .12s,border-color .12s}',
7269
+ '.dshdt-header-select:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.12))}',
7270
+ '.dshdt-header-select:disabled{color:var(--dsw-alias-label-dimmed,#9aa0aa);cursor:wait}',
7271
+ '.dshdt-header-select svg{flex:none}',
7272
+ '.dshdt-header-select-on{color:var(--dsw-alias-button-primary-fill,#4d6bfe);border-color:var(--dsw-alias-button-primary-fill,#4d6bfe);background:var(--dsw-alias-button-primary-ghost-fill,rgba(77,107,254,.08))}',
7273
+ '.dshdt-selectbar{position:fixed;left:50%;bottom:104px;transform:translateX(-50%);z-index:60;display:flex;align-items:center;gap:10px;padding:8px 12px;border-radius:999px;background:var(--dsw-alias-bg-elevated,#fff);color:var(--dsw-alias-label-primary,inherit);border:1px solid var(--dsw-alias-border-secondary,rgba(127,127,127,.2));box-shadow:0 8px 28px rgba(0,0,0,.18);font-size:13px}',
7274
+ '.dshdt-selectbar-count{min-width:56px;text-align:center;color:var(--dsw-alias-label-secondary,#5c6270)}',
7174
7275
  '.dshdt-dialog-text{margin:0;color:var(--dsw-alias-label-primary,inherit);font-size:14px;line-height:22px}',
7175
7276
  '.dshdt-dialog-note{margin:10px 0 0;color:var(--dsw-alias-label-tertiary,#8a8f98);font-size:13px;line-height:20px}',
7176
7277
  '.dshdt-dialog-warn{margin:10px 0 0;color:var(--dsw-alias-state-warn-primary,#a15c00);font-size:13px;line-height:20px}',
@@ -7225,6 +7326,16 @@ window.__ModuleLoader__.load({
7225
7326
  ).join('') +
7226
7327
  '</svg>'
7227
7328
 
7329
+ // Multi-select glyph: a checkbox square with a tick.
7330
+ const CHECK_ICON_PATHS = ['M2.7 2.7h10.6v10.6H2.7z', 'M5.3 8.15l1.95 2 3.5-4.05']
7331
+ const CHECK_ICON_MARKUP =
7332
+ '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">' +
7333
+ CHECK_ICON_PATHS.map(
7334
+ (d) =>
7335
+ '<path d="' + d + '" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>',
7336
+ ).join('') +
7337
+ '</svg>'
7338
+
7228
7339
  function TrashIcon() {
7229
7340
  return jsx('svg', {
7230
7341
  width: 16,
@@ -7238,6 +7349,21 @@ window.__ModuleLoader__.load({
7238
7349
  })
7239
7350
  }
7240
7351
 
7352
+ // The multi-select mark: a checkbox square with a tick, so the header entry
7353
+ // reads as "select rows" rather than "delete".
7354
+ function CheckIcon() {
7355
+ return jsx('svg', {
7356
+ width: 16,
7357
+ height: 16,
7358
+ viewBox: '0 0 16 16',
7359
+ fill: 'none',
7360
+ 'aria-hidden': true,
7361
+ children: CHECK_ICON_PATHS.map((d, index) =>
7362
+ jsx('path', { d, stroke: 'currentColor', strokeWidth: 1.2, strokeLinecap: 'round', strokeLinejoin: 'round' }, index),
7363
+ ),
7364
+ })
7365
+ }
7366
+
7241
7367
  // --- controller -----------------------------------------------------------
7242
7368
 
7243
7369
  class DeletionController {
@@ -7265,6 +7391,11 @@ window.__ModuleLoader__.load({
7265
7391
  loaded: false,
7266
7392
  loadError: false,
7267
7393
  dialog: null,
7394
+ // Multi-select: while `selecting` is on every deletable row offers a
7395
+ // checkbox, and one request lands every checked window at once.
7396
+ selecting: false,
7397
+ // selectionKey -> target, so a row keeps its mark across refreshes.
7398
+ selected: new Map(),
7268
7399
  pending: false,
7269
7400
  failure: null,
7270
7401
  // How many unrelated nodes the last attempt reported it would also
@@ -7351,6 +7482,95 @@ window.__ModuleLoader__.load({
7351
7482
  return pending
7352
7483
  }
7353
7484
 
7485
+ // Entering/leaving multi-select. Leaving drops the selection, so a later
7486
+ // entry never starts with rows the user no longer sees as marked.
7487
+ setSelecting(on) {
7488
+ if (this.view.pending) return
7489
+ const selecting = on === true
7490
+ if (selecting === this.view.selecting) return
7491
+ this.publish({ selecting, selected: selecting ? this.view.selected : new Map(), dialog: null, failure: null, foreignCount: 0 })
7492
+ }
7493
+
7494
+ toggleSelected(key, target) {
7495
+ if (this.view.pending || typeof key !== 'string' || key === '' || target === null || target === undefined) return
7496
+ const selected = new Map(this.view.selected)
7497
+ if (selected.has(key)) selected.delete(key)
7498
+ else selected.set(key, target)
7499
+ this.publish({ selected })
7500
+ }
7501
+
7502
+ // The batch entry: with "ask" on it opens the confirmation dialog, with it
7503
+ // off it deletes straight away and only surfaces a dialog when the attempt
7504
+ // failed (a silent failure would have nowhere to go).
7505
+ requestBatch() {
7506
+ const count = this.view.selected.size
7507
+ if (count === 0 || this.view.pending) return
7508
+ if (getPrefs().ask) {
7509
+ this.publish({ dialog: { mode: 'batch', count }, failure: null, foreignCount: 0 })
7510
+ return
7511
+ }
7512
+ void this.performBatch().then((result) => {
7513
+ if (result.failure !== null) this.publish({ dialog: { mode: 'batch', count }, pending: false, failure: result.failure })
7514
+ })
7515
+ }
7516
+
7517
+ // One request carries every selected target. The host fuses the windows
7518
+ // and lands them back to back, so a batch can never land half of itself.
7519
+ async performBatch(options) {
7520
+ if (this.view.pending) return { failure: null }
7521
+ const force = options !== undefined && options.force === true
7522
+ const requested = [...this.view.selected.values()]
7523
+ if (requested.length === 0) return { failure: null }
7524
+ this.publish({ pending: true, failure: null, foreignCount: 0 })
7525
+ try {
7526
+ await this.load(true)
7527
+ // A target the fresh surface no longer holds means the log moved under
7528
+ // the selection: refuse the whole batch instead of landing the part
7529
+ // that still resolves.
7530
+ const resolved = requested.map((target) => this.resolveTarget(target))
7531
+ if (resolved.some((target) => target === null || target === undefined)) {
7532
+ this.publish({ pending: false, failure: 'stale' })
7533
+ return { failure: 'stale' }
7534
+ }
7535
+ const targets = resolved.map((target) => {
7536
+ const out = { mode: target.mode }
7537
+ if (typeof target.seq === 'number') out.seq = target.seq
7538
+ if (typeof target.turn === 'number') out.turn = target.turn
7539
+ if (typeof target.messageId === 'string') out.messageId = target.messageId
7540
+ return out
7541
+ })
7542
+ const res = await fetch(`${ROUTE_PREFIX}/delete`, {
7543
+ method: 'POST',
7544
+ headers: { 'content-type': 'application/json' },
7545
+ body: JSON.stringify({ sessionId: this.sessionId, targets, ...(force ? { force: true } : {}) }),
7546
+ })
7547
+ const data = await res.json().catch(() => ({}))
7548
+ if (!res.ok || !data.ok) {
7549
+ const code = data && data.code ? String(data.code) : 'generic'
7550
+ const foreignCount = data && data.details && typeof data.details.foreign === 'number' ? data.details.foreign : 0
7551
+ this.publish({ pending: false, failure: code, foreignCount })
7552
+ // `partial` means the log moved between two appends: re-read so the
7553
+ // transcript shows what actually landed, not the pre-batch picture.
7554
+ if (code === 'partial') await this.load(true)
7555
+ return { failure: code }
7556
+ }
7557
+ const hidden = new Map(this.view.hidden)
7558
+ for (const item of Array.isArray(data.hidden) ? data.hidden : []) {
7559
+ if (item && typeof item.seq === 'number') hidden.set(item.seq, typeof item.mode === 'string' ? item.mode : 'message')
7560
+ }
7561
+ const hiddenVia = new Map(this.view.hiddenVia)
7562
+ for (const [via, seq] of hiddenViaOf(data.hidden)) hiddenVia.set(via, seq)
7563
+ this.animateOnce = true
7564
+ // Leaving the mode after a landed batch keeps the next click an
7565
+ // ordinary delete: the marks are gone, so staying would only widen it.
7566
+ this.publish({ pending: false, selecting: false, dialog: null, hidden, hiddenVia, selected: new Map(), loaded: true, loadError: false })
7567
+ return { failure: null }
7568
+ } catch {
7569
+ this.publish({ pending: false, failure: 'generic' })
7570
+ return { failure: 'generic' }
7571
+ }
7572
+ }
7573
+
7354
7574
  open(target) {
7355
7575
  this.load()
7356
7576
  this.publish({ dialog: target, failure: null })
@@ -7365,7 +7585,13 @@ window.__ModuleLoader__.load({
7365
7585
  // nodes" report, which is the only refusal the dialog turns into a second
7366
7586
  // question instead of a dead end.
7367
7587
  async confirm(options) {
7368
- await this.perform(this.view.dialog, { force: options && options.force === true })
7588
+ const force = options !== undefined && options.force === true
7589
+ const target = this.view.dialog
7590
+ if (target !== null && typeof target === 'object' && target.mode === 'batch') {
7591
+ await this.performBatch({ force })
7592
+ return
7593
+ }
7594
+ await this.perform(target, { force })
7369
7595
  }
7370
7596
 
7371
7597
  // The entry every action uses. With "ask" on, it opens the confirmation
@@ -7503,6 +7729,18 @@ window.__ModuleLoader__.load({
7503
7729
  return typeof seq === 'number' ? { mode: 'turn', seq, label: 'turn' } : null
7504
7730
  }
7505
7731
 
7732
+ // Stable identity of one selectable target across snapshot refreshes: a
7733
+ // message/step addresses a durable seq, a reply/turn a turn number. The key
7734
+ // is what the controller keys its selection map by, so a re-read that moves
7735
+ // a row's live seq cannot silently keep a stale mark.
7736
+ function selectionKey(target) {
7737
+ if (!target || typeof target !== 'object') return ''
7738
+ if (typeof target.seq === 'number') return target.mode + ':' + String(target.seq)
7739
+ if (typeof target.turn === 'number') return target.mode + ':t' + String(target.turn)
7740
+ if (typeof target.messageId === 'string') return target.mode + ':m' + target.messageId
7741
+ return ''
7742
+ }
7743
+
7506
7744
  function seqsFor(node) {
7507
7745
  const data = node.data || {}
7508
7746
  const out = []
@@ -7660,7 +7898,7 @@ window.__ModuleLoader__.load({
7660
7898
  host.className = kind === 'think' ? HOST_CLASS + ' ' + THINK_CLASS : HOST_CLASS
7661
7899
  host.setAttribute(HOST_MARK, '1')
7662
7900
  if (kind === 'think') host.setAttribute(THINK_MARK, '1')
7663
- let button = host.querySelector('button')
7901
+ let button = host.querySelector('button.dshdt-action')
7664
7902
  if (button === null) {
7665
7903
  button = document.createElement('button')
7666
7904
  button.type = 'button'
@@ -7684,7 +7922,20 @@ window.__ModuleLoader__.load({
7684
7922
  host.appendChild(turn)
7685
7923
  }
7686
7924
  }
7687
- return { host, button, turn }
7925
+ // The multi-select checkbox rides in the same host (same namespace, same
7926
+ // adoption rule); it is only ever VISIBLE while the mode is on.
7927
+ let check = null
7928
+ if (kind !== 'think') {
7929
+ check = host.querySelector('.dshdt-check')
7930
+ if (check === null) {
7931
+ check = document.createElement('button')
7932
+ check.type = 'button'
7933
+ check.className = 'dshdt-check dshdt-row-action'
7934
+ check.innerHTML = CHECK_ICON_MARKUP
7935
+ host.appendChild(check)
7936
+ }
7937
+ }
7938
+ return { host, button, turn, check }
7688
7939
  }
7689
7940
 
7690
7941
  // Drop every node this plugin injected. Matched through this plugin's own
@@ -7697,6 +7948,8 @@ window.__ModuleLoader__.load({
7697
7948
  function sweepInjectedNodes() {
7698
7949
  for (const host of document.querySelectorAll('[' + HOST_MARK + '="1"]')) host.remove()
7699
7950
  for (const host of document.querySelectorAll('.' + HOST_CLASS)) host.remove()
7951
+ // A toggle parked on a row is not inside any host, so it is swept by name.
7952
+ for (const node of document.querySelectorAll('.dshdt-check')) node.remove()
7700
7953
  // These entries now point at detached nodes; the next injection re-adopts
7701
7954
  // from the DOM instead of trusting them.
7702
7955
  rowActions = new WeakMap()
@@ -7770,11 +8023,23 @@ window.__ModuleLoader__.load({
7770
8023
  const entry = rowActions.get(row)
7771
8024
  if (!entry) return
7772
8025
  entry.host.remove()
8026
+ // The toggle may be on the row rather than in the host while the mode is
8027
+ // on, so the row is swept of direct children that belong to the host.
8028
+ for (const node of row.querySelectorAll('.dshdt-check')) {
8029
+ if (node.parentElement === row) node.remove()
8030
+ }
7773
8031
  rowActions.delete(row)
8032
+ delete row.dataset.dshdtSelect
8033
+ row.classList.remove('dshdt-select-on', 'dshdt-checked')
7774
8034
  }
7775
8035
 
7776
- function injectRowAction(row, node, target, turnTarget, controller, t) {
7777
- const label = t(`action.tooltip.${target.label}`)
8036
+ function injectRowAction(row, node, target, turnTarget, view, controller, t, pickTarget) {
8037
+ // A row can carry the multi-select badge without carrying a hover action:
8038
+ // a turn's answer text has no row-level scope of its own, only a reply
8039
+ // scope, and that scope already lives in the reply action strip.
8040
+ const badgeOnly = target === null
8041
+ const pick = pickTarget === undefined || pickTarget === null ? target : pickTarget
8042
+ const label = badgeOnly ? '' : t(`action.tooltip.${target.label}`)
7778
8043
  let entry = rowActions.get(row)
7779
8044
  if (!entry) {
7780
8045
  // Never a second host on a row that already carries one: an earlier
@@ -7782,20 +8047,24 @@ window.__ModuleLoader__.load({
7782
8047
  entry = adoptHost(findOwnedHost(row, false), 'row')
7783
8048
  rowActions.set(row, entry)
7784
8049
  }
7785
- const { host, button, turn } = entry
8050
+ const { host, button, turn, check } = entry
8051
+ if (badgeOnly) host.dataset.dshdtBadgeOnly = '1'
8052
+ else delete host.dataset.dshdtBadgeOnly
7786
8053
  if (button.getAttribute('aria-label') !== label) {
7787
8054
  button.setAttribute('aria-label', label)
7788
8055
  button.setAttribute('title', label)
7789
8056
  }
7790
- button.onclick = (event) => {
7791
- event.preventDefault()
7792
- event.stopPropagation()
7793
- controller.trigger(target)
7794
- }
8057
+ button.onclick = badgeOnly
8058
+ ? null
8059
+ : (event) => {
8060
+ event.preventDefault()
8061
+ event.stopPropagation()
8062
+ controller.trigger(target)
8063
+ }
7795
8064
  // The second action is wired only where a turn target exists (and stays
7796
8065
  // hidden by the row attribute otherwise), so a row can never offer an
7797
8066
  // operation the host would refuse for the wrong reason.
7798
- if (turn !== null && turn !== undefined) {
8067
+ if (!badgeOnly && turn !== null && turn !== undefined) {
7799
8068
  const turnLabel = t('action.tooltip.turn')
7800
8069
  if (turn.getAttribute('aria-label') !== turnLabel) {
7801
8070
  turn.setAttribute('aria-label', turnLabel)
@@ -7810,6 +8079,39 @@ window.__ModuleLoader__.load({
7810
8079
  controller.trigger(turnTarget)
7811
8080
  }
7812
8081
  }
8082
+ // Multi-select: the same host carries the toggle. Outside selection mode
8083
+ // it enters the mode AND marks this row, so one click both opens the bar
8084
+ // and shows what the next click would do.
8085
+ if (check !== null && check !== undefined) {
8086
+ const key = selectionKey(pick)
8087
+ const selecting = view.selecting === true
8088
+ const selected = selecting && key !== '' && view.selected.has(key)
8089
+ if (selecting) {
8090
+ row.dataset.dshdtSelect = '1'
8091
+ row.classList.add('dshdt-select-on')
8092
+ // Physically move the node onto the row for the duration of the
8093
+ // mode; the same element and handler go back to the host after.
8094
+ if (check.parentElement !== row) row.appendChild(check)
8095
+ } else {
8096
+ delete row.dataset.dshdtSelect
8097
+ row.classList.remove('dshdt-select-on')
8098
+ if (check.parentElement !== host) host.appendChild(check)
8099
+ }
8100
+ if (selected) row.classList.add('dshdt-checked')
8101
+ else row.classList.remove('dshdt-checked')
8102
+ const checkLabel = selected ? t('action.tooltip.selected') : t('action.tooltip.select')
8103
+ if (check.getAttribute('aria-label') !== checkLabel) {
8104
+ check.setAttribute('aria-label', checkLabel)
8105
+ check.setAttribute('title', checkLabel)
8106
+ }
8107
+ check.setAttribute('aria-pressed', selected ? 'true' : 'false')
8108
+ check.onclick = (event) => {
8109
+ event.preventDefault()
8110
+ event.stopPropagation()
8111
+ if (!controller.view.selecting) controller.setSelecting(true)
8112
+ controller.toggleSelected(key, pick)
8113
+ }
8114
+ }
7813
8115
  const anchor = node.kind === 'user' || node.kind === 'steering' ? row.querySelector('[class*="_actions"]') : null
7814
8116
  if (anchor) {
7815
8117
  host.classList.remove('dshdt-floating')
@@ -7947,13 +8249,22 @@ window.__ModuleLoader__.load({
7947
8249
  const hidden = orphaned || isRowHidden(view.hidden, view.hiddenVia, seqs)
7948
8250
  setRowHidden(row, hidden, animate)
7949
8251
  const target = hidden ? null : liveTarget(targetFor(node), view.edits)
7950
- const covered = target !== null || slotCoversRow(node)
8252
+ // A row with no scope of its own (the answer text of a turn) still earns
8253
+ // the badge when its turn's reply is deletable: ticking it deletes that
8254
+ // answer and keeps the prompt, which is the only scope the row owns.
8255
+ const turnNumber = turn === null || turn === '' ? null : Number(turn)
8256
+ const replyPick =
8257
+ !hidden && turnNumber !== null && Number.isFinite(turnNumber) && view.replyTurns.has(turnNumber)
8258
+ ? { mode: 'reply', turn: turnNumber, label: 'reply' }
8259
+ : null
8260
+ const covered = target !== null || slotCoversRow(node) || replyPick !== null
7951
8261
  const deletable = !hidden && covered && rowDeletable(node, seqs, view)
7952
8262
  // The turn-delete entry rides on the same gates as the delete entry: a
7953
8263
  // row whose content already left the context offers neither.
7954
8264
  const turnTarget =
7955
8265
  deletable && turnTargetFor(node) !== null && view.turnSeqs.has(target.seq) ? liveTarget(turnTargetFor(node), view.edits) : null
7956
- if (deletable && target !== null) injectRowAction(row, node, target, turnTarget, controller, t)
8266
+ const pickTarget = target !== null ? target : deletable ? replyPick : null
8267
+ if (deletable && pickTarget !== null) injectRowAction(row, node, target, turnTarget, view, controller, t, pickTarget)
7957
8268
  else removeRowAction(row)
7958
8269
  if (deletable && target !== null && turnTarget !== null) row.dataset.dshdtTurn = '1'
7959
8270
  else delete row.dataset.dshdtTurn
@@ -8056,6 +8367,63 @@ window.__ModuleLoader__.load({
8056
8367
  })
8057
8368
  }
8058
8369
 
8370
+ // The multi-select bar. Fixed placement keeps it out of the slot's layout:
8371
+ // `conversation.input.overlay` is drawn over the composer, so an in-flow bar
8372
+ // would push the input area around (and re-measure it) on every toggle.
8373
+ // Multi-select is a MODE: entering it has to be possible without hovering a
8374
+ // row first, so the entry lives in the Session header - always visible,
8375
+ // next to the other Session utilities. The per-row toggles only appear once
8376
+ // the mode is on (their rows carry `data-dshdt-select` then).
8377
+ function SelectToggle({ useDeletion, controller, t }) {
8378
+ const view = useDeletion((state) => state)
8379
+ const selecting = view.selecting === true
8380
+ const label = selecting ? t('select.cancel') : t('action.tooltip.select')
8381
+ return jsx('button', {
8382
+ type: 'button',
8383
+ className: selecting ? 'dshdt-header-select dshdt-header-select-on' : 'dshdt-header-select',
8384
+ 'aria-label': label,
8385
+ 'aria-pressed': selecting ? 'true' : 'false',
8386
+ title: label,
8387
+ disabled: view.pending,
8388
+ onClick: (event) => {
8389
+ event.preventDefault()
8390
+ event.stopPropagation()
8391
+ controller.setSelecting(!selecting)
8392
+ },
8393
+ children: jsx(CheckIcon, {}),
8394
+ })
8395
+ }
8396
+
8397
+ function SelectionBar({ view, controller, t }) {
8398
+ if (view.selecting !== true) return null
8399
+ const count = view.selected.size
8400
+ return jsxs('div', {
8401
+ className: 'dshdt-selectbar',
8402
+ role: 'toolbar',
8403
+ children: [
8404
+ jsx('span', {
8405
+ className: 'dshdt-selectbar-count',
8406
+ children: `${t('select.count')} ${count}${t('select.unit')}`,
8407
+ }),
8408
+ jsx(primitives.Button, {
8409
+ variant: 'ghost',
8410
+ size: 'md',
8411
+ disabled: view.pending,
8412
+ onClick: () => controller.setSelecting(false),
8413
+ children: t('select.cancel'),
8414
+ }),
8415
+ jsx(primitives.Button, {
8416
+ variant: 'primary',
8417
+ size: 'md',
8418
+ className: 'dshdt-danger',
8419
+ disabled: view.pending || count === 0,
8420
+ onClick: () => controller.requestBatch(),
8421
+ children: view.pending ? t('dialog.pending') : t('select.delete'),
8422
+ }),
8423
+ ],
8424
+ })
8425
+ }
8426
+
8059
8427
  function ConfirmDialog({ view, controller, t }) {
8060
8428
  const target = view.dialog
8061
8429
  const mode = target && typeof target.mode === 'string' ? target.mode : 'message'
@@ -8165,7 +8533,11 @@ window.__ModuleLoader__.load({
8165
8533
  return jsxs(Fragment, {
8166
8534
  // The slot root carries this plugin's namespace as well (I3). It is
8167
8535
  // React's node: the dispose sweep must not touch it.
8168
- children: [jsx('span', { hidden: true, 'data-dshdt-overlay': '1' }), jsx(ConfirmDialog, { view, controller, t })],
8536
+ children: [
8537
+ jsx('span', { hidden: true, 'data-dshdt-overlay': '1' }),
8538
+ jsx(SelectionBar, { view, controller, t }),
8539
+ jsx(ConfirmDialog, { view, controller, t }),
8540
+ ],
8169
8541
  })
8170
8542
  }
8171
8543
 
@@ -8216,6 +8588,20 @@ window.__ModuleLoader__.load({
8216
8588
  ),
8217
8589
  )
8218
8590
 
8591
+ // The always-visible door into multi-select, beside the Session utilities.
8592
+ ctx.slots.inject('conversation.session.header.utilities', () =>
8593
+ ctx.slots.register(
8594
+ {
8595
+ name: 'conversation.session.header.utilities',
8596
+ id: 'delete-turn',
8597
+ order: 10,
8598
+ locale: NS,
8599
+ inject: (sessionId) => ({ hooks: { deletion: controllerFor(sessionId) }, controller: controllerFor(sessionId) }),
8600
+ },
8601
+ SelectToggle,
8602
+ ),
8603
+ )
8604
+
8219
8605
  ctx.slots.inject('conversation.input.overlay', () =>
8220
8606
  ctx.slots.register(
8221
8607
  {
@@ -9197,7 +9583,7 @@ window.__ModuleLoader__.load({
9197
9583
  const NS = 'dsh-as-aistudio'
9198
9584
 
9199
9585
  /** Keep in sync with package.json and src/components.js. */
9200
- const PLUGIN_VERSION = '0.4.6'
9586
+ const PLUGIN_VERSION = '0.4.8'
9201
9587
 
9202
9588
  /** Presence marker the live verifier reads. */
9203
9589
  const DEBUG_KEY = '__DSH_AS_AISTUDIO__'
package/src/components.js CHANGED
@@ -26,7 +26,7 @@
26
26
  export const STUDIO_ID = 'dsh-as-aistudio'
27
27
 
28
28
  /** Keep in sync with package.json and src/client.js. */
29
- export const STUDIO_VERSION = '0.4.6'
29
+ export const STUDIO_VERSION = '0.4.8'
30
30
 
31
31
  /**
32
32
  * The AI Studio action strip this studio composes, in the order the user meets
@@ -1,6 +1,6 @@
1
1
  // dsh-delete-turn host half (dsh-delete-turn.js), VENDORED by tools/vendor.mjs - DO NOT EDIT.
2
2
  //
3
- // Source: ../dsh-delete-turn/src/index.js (v0.1.15, sha256 75377700ba2a6436)
3
+ // Source: ../dsh-delete-turn/src/index.js (v0.2.0, sha256 5dfc53e5c47750f5)
4
4
  // Regenerate with `npm run vendor`; test/sync.test.js fails when this is stale.
5
5
  //
6
6
  // src/shell.js imports the copy named after the component, which is why the
@@ -11,6 +11,7 @@
11
11
  //
12
12
  // GET /dsh-delete-turn/state?sessionId=<id>
13
13
  // POST /dsh-delete-turn/delete { sessionId, mode, seq?, messageId?, turn? }
14
+ // { sessionId, targets: [{ mode, seq?, turn? }] }
14
15
  //
15
16
  // A deletion appends ONE turn-less `user/message` carrier carrying the official
16
17
  // surface intent `{ surfaceOp: { op: 'replace', startSeq, endSeq } }` and the
@@ -44,6 +45,7 @@ import {
44
45
  hiddenEntriesOfFold,
45
46
  isBusy,
46
47
  openTurn,
48
+ planBatch,
47
49
  planRange,
48
50
  turnableSeqs,
49
51
  } from './dsh-delete-turn.logic.js'
@@ -420,8 +422,13 @@ async function stopActiveTurn(ctx, sessionId, events) {
420
422
  }
421
423
 
422
424
  async function deleteTarget(ctx, sessionId, body) {
425
+ // Two shapes: one target (`mode` + address) or a multi-select batch
426
+ // (`targets`). Both end in the SAME append path - a batch is just several
427
+ // disjoint range replaces landed back to back.
428
+ const batch = Array.isArray(body.targets)
429
+ if (batch && body.targets.length === 0) throw new HttpError(400, 'invalid', 'targets must not be empty')
423
430
  const mode = typeof body.mode === 'string' ? body.mode : ''
424
- if (!MODES.has(mode)) throw new HttpError(400, 'invalid', 'mode must be message, step, reply or turn')
431
+ if (!batch && !MODES.has(mode)) throw new HttpError(400, 'invalid', 'mode must be message, step, reply or turn')
425
432
  const session = await resolveSession(ctx, sessionId)
426
433
  if (!session || typeof session.append !== 'function') {
427
434
  throw new HttpError(409, 'session-not-active', 'the session is not open in DSH')
@@ -438,17 +445,21 @@ async function deleteTarget(ctx, sessionId, body) {
438
445
  }
439
446
 
440
447
  const surfaceNodes = surfaceOf(ctx, sessionId, events)
441
- let plan
448
+ let plans
442
449
  try {
443
- plan = planRange(events, surfaceNodes, {
444
- mode,
445
- seq: typeof body.seq === 'number' ? body.seq : undefined,
446
- messageId: typeof body.messageId === 'string' ? body.messageId : undefined,
447
- turn: typeof body.turn === 'number' ? body.turn : undefined,
448
- })
450
+ plans = batch
451
+ ? planBatch(events, surfaceNodes, body.targets)
452
+ : [
453
+ planRange(events, surfaceNodes, {
454
+ mode,
455
+ seq: typeof body.seq === 'number' ? body.seq : undefined,
456
+ messageId: typeof body.messageId === 'string' ? body.messageId : undefined,
457
+ turn: typeof body.turn === 'number' ? body.turn : undefined,
458
+ }),
459
+ ]
449
460
  } catch (error) {
450
461
  if (error instanceof PlanError) {
451
- const status = error.code === 'not-deletable' ? 400 : 409
462
+ const status = error.code === 'not-deletable' || error.code === 'invalid' ? 400 : 409
452
463
  throw new HttpError(status, error.code, error.message)
453
464
  }
454
465
  throw error
@@ -458,14 +469,18 @@ async function deleteTarget(ctx, sessionId, body) {
458
469
  // foreign injected row, say). Deleting them is not refused - it is a decision
459
470
  // the user must make explicitly: without `force` the route reports how many
460
471
  // there are, and the client turns that into a second, forced confirmation.
461
- if (plan.foreign.length > 0 && body.force !== true) {
462
- throw new HttpError(409, 'range-not-clean', `the window would also remove ${plan.foreign.length} unrelated node(s)`, { foreign: plan.foreign.length })
472
+ const foreignCount = plans.reduce((sum, plan) => sum + plan.foreign.length, 0)
473
+ if (foreignCount > 0 && body.force !== true) {
474
+ throw new HttpError(409, 'range-not-clean', `the window would also remove ${foreignCount} unrelated node(s)`, { foreign: foreignCount })
463
475
  }
464
476
 
465
477
  // The live surface is the append authority; a node that vanished between the
466
- // read and this check means another writer landed first.
467
- for (const seq of plan.shadowed) {
468
- if (!surfaceNodes.includes(seq)) throw new HttpError(409, 'stale', 'the session changed, retry')
478
+ // read and this check means another writer landed first. Every window is
479
+ // checked before the first append, so a batch can never land half of itself.
480
+ for (const plan of plans) {
481
+ for (const seq of plan.shadowed) {
482
+ if (!surfaceNodes.includes(seq)) throw new HttpError(409, 'stale', 'the session changed, retry')
483
+ }
469
484
  }
470
485
 
471
486
  // The carrier must be a turn-less `user/message`.
@@ -484,31 +499,69 @@ async function deleteTarget(ctx, sessionId, body) {
484
499
  // safest accepted content is a single ZERO-WIDTH SPACE (a readable marker
485
500
  // like "[deleted]" is quoted back by the model, and empty content is refused
486
501
  // by gateways whose adapter predates that skip).
487
- let replacement
488
- try {
489
- replacement = session.append(
490
- 'user/message',
491
- {
492
- id: randomUUID(),
493
- role: 'user',
494
- content: silentCarrierFor(ctx, events) ? [] : [{ type: 'text', text: '\u200b' }],
495
- // v4 format: plugin wrappers are retired; the producer kind carries the id.
496
- source: { kind: `plugin:${PLUGIN_ID}` },
497
- },
498
- {
499
- surfaceOp: { op: 'replace', startSeq: plan.startSeq, endSeq: plan.endSeq },
500
- sourceEventSeqs: plan.shadowed,
501
- },
502
- )
503
- } catch (error) {
504
- throw new HttpError(409, 'stale', `the surface refused the replacement: ${String((error && error.message) || error)}`)
502
+ const replacements = []
503
+ const hidden = []
504
+ const silent = silentCarrierFor(ctx, events)
505
+ // 标注(给日志读者用的“注释”,不是替换语义):整轮删除才会让可见轮号与日志轮号
506
+ // 分叉,所以只有 `mode: 'turn'` 成为墓碑(tombstone)。`deleteIndex` 是这条记录的
507
+ // 序号,配合 `deletedTurn` 足以复原“可见轮 ↔ 日志轮”的映射:
508
+ // 可见轮(L) = L − #{ 已删日志轮 < L }
509
+ let deleteOrdinal = 0
510
+ for (const event of events) {
511
+ if (event?.data?.source?.kind === `plugin:${PLUGIN_ID}` && Number.isSafeInteger(event?.data?.source?.deletedTurn)) deleteOrdinal += 1
512
+ }
513
+ // Ascending surface order, so each append is validated against a log the
514
+ // previous one already changed - the windows are disjoint, so a later
515
+ // window's anchors are untouched by an earlier append.
516
+ for (const plan of plans) {
517
+ // 一条载体一条注释;整轮删除才递增墓碑序号(同一批里的多轮删除必须各占一个号)。
518
+ const carrierSource = { kind: `plugin:${PLUGIN_ID}`, op: 'delete', turn: plan.turn }
519
+ if (plan.mode === 'turn') {
520
+ deleteOrdinal += 1
521
+ carrierSource.deletedTurn = plan.turn
522
+ carrierSource.deleteIndex = deleteOrdinal
523
+ }
524
+ let replacement
525
+ try {
526
+ replacement = session.append(
527
+ 'user/message',
528
+ {
529
+ id: randomUUID(),
530
+ role: 'user',
531
+ content: silent ? [] : [{ type: 'text', text: '\u200b' }],
532
+ // v4 format: plugin wrappers are retired; the producer kind carries the id.
533
+ // `op` / `turn` 让任何读者一眼看出“谁、第几轮、删了什么”;整轮删除额外写
534
+ // `deletedTurn` + `deleteIndex`,它是可见轮号与日志轮号之间的唯一桥梁。
535
+ source: carrierSource,
536
+ },
537
+ {
538
+ surfaceOp: { op: 'replace', startSeq: plan.startSeq, endSeq: plan.endSeq },
539
+ sourceEventSeqs: plan.shadowed,
540
+ },
541
+ )
542
+ } catch (error) {
543
+ // A window refused after an earlier one already landed: report it as a
544
+ // partial batch instead of pretending nothing happened, so the client can
545
+ // re-read the log and show the truth.
546
+ if (replacements.length > 0) {
547
+ await flushSession(ctx, session).catch(() => undefined)
548
+ throw new HttpError(409, 'partial', `only ${replacements.length} of ${plans.length} window(s) landed: ${String((error && error.message) || error)}`, {
549
+ landed: replacements.length,
550
+ total: plans.length,
551
+ })
552
+ }
553
+ throw new HttpError(409, 'stale', `the surface refused the replacement: ${String((error && error.message) || error)}`)
554
+ }
555
+ replacements.push(replacement.seq)
556
+ for (const seq of plan.shadowed) hidden.push({ seq, mode: plan.mode })
505
557
  }
506
558
  const flush = await flushSession(ctx, session)
507
559
 
508
560
  return {
509
- replacementSeq: replacement.seq,
561
+ replacementSeq: replacements[replacements.length - 1],
562
+ ...(replacements.length > 1 ? { replacements } : {}),
510
563
  ...flush,
511
- hidden: plan.shadowed.map((seq) => ({ seq, mode: plan.mode })),
564
+ hidden,
512
565
  }
513
566
  }
514
567
 
@@ -1,6 +1,6 @@
1
1
  // dsh-delete-turn host half (dsh-delete-turn.logic.js), VENDORED by tools/vendor.mjs - DO NOT EDIT.
2
2
  //
3
- // Source: ../dsh-delete-turn/src/logic.js (v0.1.15, sha256 900c08468355aff3)
3
+ // Source: ../dsh-delete-turn/src/logic.js (v0.2.0, sha256 6da16435446356ca)
4
4
  // Regenerate with `npm run vendor`; test/sync.test.js fails when this is stale.
5
5
  //
6
6
  // src/shell.js imports the copy named after the component, which is why the
@@ -543,6 +543,77 @@ export function planRange(events, surfaceNodes, request) {
543
543
 
544
544
  throw new PlanError('not-deletable', `unsupported mode ${String(mode)}`)
545
545
  }
546
+
547
+ /**
548
+ * Plan several UI targets into the fewest disjoint surface windows.
549
+ *
550
+ * A multi-select delete is not N independent deletes. Two selected rows can
551
+ * share one window (a step inside a reply, a message inside its turn), and
552
+ * deleting the first would make the second's anchors stale before it is even
553
+ * attempted. Every target is therefore planned against the SAME pre-delete
554
+ * surface, the windows are sorted by surface position, and neighbours —
555
+ * overlapping or directly adjacent — are fused into one replacement. Fusing
556
+ * only neighbours keeps the union exact: no node is swallowed that was not
557
+ * already inside one of the parts, so the foreign report stays the one the
558
+ * single-target path would have produced.
559
+ *
560
+ * @param events - complete contiguous raw event log.
561
+ * @param surfaceNodes - current surface seqs in model order.
562
+ * @param targets - array of `{ mode, seq?, messageId?, turn? }`.
563
+ * @returns one plan per fused window, in surface order. `mode` is the single
564
+ * part's mode when nothing was fused and `batch` otherwise; `parts` names the
565
+ * targets the window covers.
566
+ * @throws {PlanError} when any target cannot be planned.
567
+ */
568
+ export function planBatch(events, surfaceNodes, targets) {
569
+ if (!Array.isArray(targets) || targets.length === 0) {
570
+ throw new PlanError('invalid', 'targets must be a non-empty array')
571
+ }
572
+ const nodeIndex = new Map(surfaceNodes.map((seq, index) => [seq, index]))
573
+ const bySeq = new Map(events.map((event) => [event.seq, event]))
574
+ const plans = []
575
+ const seen = new Set()
576
+ for (const request of targets) {
577
+ if (!request || typeof request !== 'object') throw new PlanError('invalid', 'every target must be an object')
578
+ const key = [request.mode, request.seq, request.turn, request.messageId].join('|')
579
+ if (seen.has(key)) continue
580
+ seen.add(key)
581
+ const plan = planRange(events, surfaceNodes, request)
582
+ plans.push({ plan, startIdx: nodeIndex.get(plan.startSeq), endIdx: nodeIndex.get(plan.endSeq) })
583
+ }
584
+ plans.sort((a, b) => a.startIdx - b.startIdx || a.endIdx - b.endIdx)
585
+ const groups = []
586
+ for (const item of plans) {
587
+ const last = groups[groups.length - 1]
588
+ if (last !== undefined && item.startIdx <= last.endIdx + 1) {
589
+ last.endIdx = Math.max(last.endIdx, item.endIdx)
590
+ last.parts.push(item.plan)
591
+ continue
592
+ }
593
+ groups.push({ startIdx: item.startIdx, endIdx: item.endIdx, parts: [item.plan] })
594
+ }
595
+ return groups.map((group) => {
596
+ const shadowed = surfaceNodes.slice(group.startIdx, group.endIdx + 1)
597
+ const members = []
598
+ for (const plan of group.parts) {
599
+ for (const seq of plan.shadowed) if (!members.includes(seq)) members.push(seq)
600
+ }
601
+ const foreign = foreignNodes(shadowed, members, bySeq)
602
+ const first = group.parts[0]
603
+ return {
604
+ mode: group.parts.length === 1 ? first.mode : 'batch',
605
+ targetSeq: first.targetSeq,
606
+ startSeq: shadowed[0],
607
+ endSeq: shadowed[shadowed.length - 1],
608
+ shadowed,
609
+ foreign,
610
+ turn: first.turn,
611
+ step: first.step,
612
+ parts: group.parts.map((plan) => ({ mode: plan.mode, targetSeq: plan.targetSeq })),
613
+ }
614
+ })
615
+ }
616
+
546
617
  // --- target resolution -------------------------------------------------------
547
618
 
548
619
  /**
@@ -1,6 +1,6 @@
1
1
  // dsh-edit-turn host half (dsh-edit-turn.js), VENDORED by tools/vendor.mjs - DO NOT EDIT.
2
2
  //
3
- // Source: ../dsh-edit-turn/lib/index.js (v0.5.12, sha256 6adfd5b9699657d5)
3
+ // Source: ../dsh-edit-turn/lib/index.js (v0.5.13, sha256 85d229824536a84b)
4
4
  // Regenerate with `npm run vendor`; test/sync.test.js fails when this is stale.
5
5
  //
6
6
  // src/shell.js imports the copy named after the component, which is why the
@@ -85,7 +85,7 @@ export const PLUGIN_ID = 'dsh-edit-turn'
85
85
  export const name = PLUGIN_ID
86
86
 
87
87
  /** Keep in sync with package.json and lib/client.js. */
88
- export const PLUGIN_VERSION = '0.5.12'
88
+ export const PLUGIN_VERSION = '0.5.13'
89
89
 
90
90
  // A ring of the last few requests, answering "did the browser half even ask, and
91
91
  // what did it get?" without needing its console. Read-only diagnostics: the
@@ -1491,6 +1491,13 @@ function eventsFromLive(session) {
1491
1491
  // Live-preferred read through the public query service; the live session's own
1492
1492
  // snapshot is the fallback when the service is absent.
1493
1493
  async function readEvents(ctx, sessionId) {
1494
+ // A session the host has open answers from memory. Going through the query
1495
+ // service instead makes the route re-decode the whole log from disk first:
1496
+ // measured 1.4 s for a 15 MB log, while every computation this route does on
1497
+ // top of it adds up to a few tens of milliseconds. That read was the entire
1498
+ // reason the edit entry took seconds to appear.
1499
+ const live = eventsFromLive(findLiveSession(ctx, sessionId))
1500
+ if (live !== undefined && live.length > 0) return live
1494
1501
  const query = ctx.get('sessionQuery')
1495
1502
  if (query && typeof query.readSession === 'function') {
1496
1503
  try {
@@ -1,6 +1,6 @@
1
1
  // dsh-edit-turn host half (dsh-edit-turn.patch.js), VENDORED by tools/vendor.mjs - DO NOT EDIT.
2
2
  //
3
- // Source: ../dsh-edit-turn/lib/patch.js (v0.5.12, sha256 5e1e8cce1d168f6c)
3
+ // Source: ../dsh-edit-turn/lib/patch.js (v0.5.13, sha256 5e1e8cce1d168f6c)
4
4
  // Regenerate with `npm run vendor`; test/sync.test.js fails when this is stale.
5
5
  //
6
6
  // src/shell.js imports the copy named after the component, which is why the
@@ -1,6 +1,6 @@
1
1
  // dsh-rerun-turn host half (dsh-rerun-turn.js), VENDORED by tools/vendor.mjs - DO NOT EDIT.
2
2
  //
3
- // Source: ../dsh-rerun-turn/lib/index.js (v0.2.6, sha256 3e20b1a0a07cf213)
3
+ // Source: ../dsh-rerun-turn/lib/index.js (v0.2.7, sha256 9f163488f55fb398)
4
4
  // Regenerate with `npm run vendor`; test/sync.test.js fails when this is stale.
5
5
  //
6
6
  // src/shell.js imports the copy named after the component, which is why the
@@ -90,7 +90,7 @@ export const PLUGIN_ID = 'dsh-rerun-turn'
90
90
  export const name = PLUGIN_ID
91
91
 
92
92
  /** Keep in sync with package.json and lib/client.js. */
93
- export const PLUGIN_VERSION = '0.2.6'
93
+ export const PLUGIN_VERSION = '0.2.7'
94
94
 
95
95
  const ROUTE_PREFIX = '/dsh-rerun-turn'
96
96
  // A session id is an OPAQUE string to DSH: the host's own schema is
@@ -952,10 +952,34 @@ export function planRerun(events, surfaceNodes, request) {
952
952
  }
953
953
  const span = resolved.span
954
954
  const promptEvent = bySeq.get(livePromptSeq)
955
- const shadowed = surfaceNodes.slice(nodeIndex.get(livePromptSeq))
956
- if (shadowed.length === 0 || shadowed[0] !== livePromptSeq) {
955
+ const slotWindow = surfaceNodes.slice(nodeIndex.get(livePromptSeq))
956
+ if (slotWindow.length === 0 || slotWindow[0] !== livePromptSeq) {
957
957
  throw new RerunPlanError('not-rerunnable', 'the prompt does not open a rerun window')
958
958
  }
959
+ // 单一来源:窗口就是「提示词槽位到表面末尾」这一段**表面**节点,按表面顺序。
960
+ // 宿主的 replace 按表面位置解析(`indexOf(startSeq)`..`indexOf(endSeq)`),所以
961
+ // 区间端点取这段的首尾即可;区间覆盖的正是这整段,不会多也不会少。
962
+ const shadowed = slotWindow
963
+ // 窗口里若站着一条 edit 载体(它占着提示词的槽位,正文是新文本),把它顶掉的原消息
964
+ // 也一并退役:否则界面在 rerun 期间会把原文重新渲染出来(“回复数字1”闪回)。
965
+ // 只加进 `retired`(写进 sourceEventSeqs 让客户端一起隐藏),不加进 `shadowed`
966
+ // (重放不复制它,否则提示词会出现两份)。
967
+ const replacements = foldSurface(events).replacements
968
+ const carrierTargets = new Map()
969
+ for (const replacement of replacements) carrierTargets.set(replacement.seq, replacement.shadowed)
970
+ const retired = new Set(shadowed)
971
+ for (const seq of shadowed) {
972
+ const targets = carrierTargets.get(seq)
973
+ if (targets === undefined) continue
974
+ for (const target of targets) retired.add(target)
975
+ }
976
+ // 只有**提示词槽位上那条载体**被遮蔽时会让别人重新露出来:它就是 edit 载体,
977
+ // 它的替换目标(原文)会在窗口盖掉它的一瞬回到表面。只把这**一跳**的目标交出去
978
+ // 给客户端做乐观隐藏;多跳链上的目标属于别的轮次,隐藏它们会把界面清空。
979
+ const restored = carrierTargets.get(shadowed[0]) ?? []
980
+ const retiredList = [...retired].sort((left, right) => left - right)
981
+ const windowLo = retiredList[0]
982
+ const windowHi = retiredList[retiredList.length - 1]
959
983
  if (promptEvent.data && Array.isArray(promptEvent.data.content)) {
960
984
  const files = promptEvent.data.content.filter((block) => block && block.type === 'file')
961
985
  if (files.length > 0) {
@@ -968,9 +992,8 @@ export function planRerun(events, surfaceNodes, request) {
968
992
  // back. When the prompt never lands (admission failed), the walk falls back
969
993
  // to the rerun turn's own `turn/start`, so the copies reproduce the whole
970
994
  // window and the context keeps its content.
971
- const lastShadowed = shadowed[shadowed.length - 1]
972
- let logTo = lastShadowed
973
- for (let seq = lastShadowed + 1; ; seq += 1) {
995
+ let logTo = windowHi
996
+ for (let seq = windowHi + 1; ; seq += 1) {
974
997
  const event = bySeq.get(seq)
975
998
  if (event === undefined) break
976
999
  if (isSurfaceEvent(event)) break
@@ -986,9 +1009,15 @@ export function planRerun(events, surfaceNodes, request) {
986
1009
  turn,
987
1010
  targetSeq: target.seq,
988
1011
  promptSeq: livePromptSeq,
1012
+ // 宿主按**表面位置**遮蔽:range 的两个端点必须本身在表面上,区间内(表面顺序)
1013
+ // 的节点被一起替换。所以端点取窗口的首尾表面节点,而不是 seq 最小/最大——edit
1014
+ // 载体 seq 靠后但槽位在前,拿 seq 当端点会被宿主拒绝(`start seq 13 not found`)。
989
1015
  startSeq: shadowed[0],
990
1016
  endSeq: shadowed[shadowed.length - 1],
991
1017
  shadowed,
1018
+ retired: retiredList,
1019
+ // 客户端在生成期间额外隐藏的行:只含“提示词槽位载体”的直接替换目标。
1020
+ restored,
992
1021
  // The seq where the replay's copies start in the normal path - the next
993
1022
  // turn's own bracket - and the fallback start (the rerun turn's bracket)
994
1023
  // for the failed-admission replay. `rerunTurnEnd` closes the rerun turn;
@@ -1222,7 +1251,11 @@ export function buildShadowWrites(
1222
1251
  type: 'user/message',
1223
1252
  surfaceOp: { op: 'replace', startSeq: plan.startSeq, endSeq: plan.endSeq },
1224
1253
  // 写入前规范化:嵌套数组/重复项/非整数都会让宿主的严格校验器拒绝整条事件。
1225
- sourceEventSeqs: normalizeShadowed(plan.shadowed),
1254
+ // 引用 = 将要退役的全部事件:窗口里的表面节点 + 被 edit 载体顶掉的原消息
1255
+ // (客户端据此把原文行一起隐藏,不会在 rerun 期间闪回旧提示词)。
1256
+ // 重放用的是 foldSurface 算出的真实遮蔽槽位,不读这个列表,所以多出来的
1257
+ // 原消息不会被复制第二遍。
1258
+ sourceEventSeqs: normalizeShadowed(plan.retired ?? plan.shadowed),
1226
1259
  data: {
1227
1260
  id: randomUUID(),
1228
1261
  role: 'user',
@@ -1376,30 +1409,142 @@ export function buildReplayWrites(events, plan, baseTurn, rerunId, startSeq, pro
1376
1409
  const shadowedSet = new Set(plan.shadowed)
1377
1410
  const editPatches = editPatchesOf(events)
1378
1411
  const turnOf = turnIndex(events)
1412
+ // A replacement carrier (an edit rewriting a turn's prompt) is appended AFTER
1413
+ // the turn closed, so the bracket walk has no turn for it. Its slot belongs to
1414
+ // the original it replaced: follow the chain to the original and read ITS turn.
1415
+ const carrierTargets = new Map()
1416
+ for (const replacement of foldSurface(events).replacements) carrierTargets.set(replacement.seq, replacement.shadowed)
1417
+ const turnFor = (seq) => {
1418
+ const own = turnOf.get(seq)
1419
+ if (typeof own === 'number') return own
1420
+ const seen = new Set([seq])
1421
+ let queue = [...(carrierTargets.get(seq) ?? [])]
1422
+ while (queue.length > 0) {
1423
+ const next = queue.shift()
1424
+ if (seen.has(next)) continue
1425
+ seen.add(next)
1426
+ const turn = turnOf.get(next)
1427
+ if (typeof turn === 'number') return turn
1428
+ queue = queue.concat(carrierTargets.get(next) ?? [])
1429
+ }
1430
+ return undefined
1431
+ }
1379
1432
  const shadowedTurns = new Set()
1380
1433
  for (const seq of plan.shadowed) {
1381
- const turn = turnOf.get(seq)
1434
+ const turn = turnFor(seq)
1382
1435
  if (typeof turn === 'number') shadowedTurns.add(turn)
1383
1436
  }
1384
- const included = (event) => {
1385
- if (isSurfaceEvent(event)) return shadowedSet.has(event.seq)
1386
- if (
1387
- event.type === 'turn/start' ||
1388
- event.type === 'turn/end' ||
1389
- event.type === 'step/start' ||
1390
- event.type === 'step/end' ||
1391
- event.type === 'tool/call'
1392
- ) {
1393
- return typeof event.data.turn === 'number' && shadowedTurns.has(event.data.turn)
1437
+ // 按**表面顺序**重放。表面顺序与日志顺序不是一回事:替换事件占的是被替换节点的
1438
+ // 槽位,它的 seq 却更大(edit 把第 2 轮提问改在 seq 33,而它那条回复是 seq 30),
1439
+ // 按日志顺序复制会把「回复数字4」排到它自己那条回复的后面,模型看到的消息顺序就
1440
+ // 变了。这里按 turn → 轮级消息(user)→ step → step 内消息(assistant/tool)
1441
+ // 的槽位顺序排列,括号事件取原日志里的那一条(没有就合成一条)。
1442
+ const bySeq = eventIndex(events)
1443
+ // Which turns the replay copies: the rerun turn's own content is replaced by the
1444
+ // fresh answer, so the normal path starts at the NEXT turn; when the prompt never
1445
+ // landed (fallback) the whole window, rerun turn included, is copied. A caller
1446
+ // that hands us a trimmed plan without `turn` (the replay task re-reads it from
1447
+ // the carrier) still works: the walk start names the first copied turn.
1448
+ const rerunTurn = typeof plan.turn === 'number' ? plan.turn : null
1449
+ const walkTurn = typeof plan.logFrom === 'number' ? turnOf.get(plan.logFrom) : undefined
1450
+ const copyFromTurn =
1451
+ rerunTurn !== null
1452
+ ? promptCopied === true
1453
+ ? rerunTurn
1454
+ : rerunTurn + 1
1455
+ : typeof walkTurn === 'number'
1456
+ ? walkTurn
1457
+ : -Infinity
1458
+ // Where each surface event sits in the log's bracket tree: a user message written
1459
+ // inside a step belongs to that step; a replacement carrier appended after the
1460
+ // turn closed belongs to the turn (it stands in a slot inside it).
1461
+ const placement = new Map()
1462
+ {
1463
+ let openTurn = null
1464
+ let openStep = null
1465
+ for (const event of events) {
1466
+ if (event.type === 'turn/start') {
1467
+ openTurn = event.data.turn
1468
+ openStep = null
1469
+ continue
1470
+ }
1471
+ if (event.type === 'turn/end') {
1472
+ openTurn = null
1473
+ openStep = null
1474
+ continue
1475
+ }
1476
+ if (event.type === 'step/start') {
1477
+ openStep = event.data.step
1478
+ continue
1479
+ }
1480
+ if (event.type === 'step/end') {
1481
+ openStep = null
1482
+ continue
1483
+ }
1484
+ if (!isSurfaceEvent(event)) continue
1485
+ placement.set(event.seq, { turn: openTurn, step: openStep })
1394
1486
  }
1395
- return false
1396
1487
  }
1397
- const range = []
1398
- const bySeq = eventIndex(events)
1399
- for (let seq = plan.logFrom; seq <= plan.logTo; seq += 1) {
1488
+ const placeOf = (seq) => {
1489
+ const placed = placement.get(seq)
1490
+ if (placed !== undefined && typeof placed.turn === 'number') return placed
1491
+ return { turn: turnFor(seq), step: null }
1492
+ }
1493
+ const buckets = new Map()
1494
+ for (const seq of plan.shadowed) {
1400
1495
  const event = bySeq.get(seq)
1401
1496
  if (event === undefined) continue
1402
- if (included(event)) range.push(event)
1497
+ const place = placeOf(seq)
1498
+ if (typeof place.turn !== 'number' || place.turn < copyFromTurn) continue
1499
+ let bucket = buckets.get(place.turn)
1500
+ if (bucket === undefined) {
1501
+ bucket = { level: [], steps: new Map() }
1502
+ buckets.set(place.turn, bucket)
1503
+ }
1504
+ if (place.step === null) {
1505
+ bucket.level.push(event)
1506
+ } else {
1507
+ const list = bucket.steps.get(place.step)
1508
+ if (list === undefined) bucket.steps.set(place.step, [event])
1509
+ else list.push(event)
1510
+ }
1511
+ }
1512
+ const bracketOf = (type, turn, step) =>
1513
+ events.find(
1514
+ (event) =>
1515
+ event.type === type &&
1516
+ event.data !== null &&
1517
+ event.data !== undefined &&
1518
+ event.data.turn === turn &&
1519
+ (step === undefined || event.data.step === step),
1520
+ )
1521
+ const callIdsOf = (turn, step) =>
1522
+ events.filter(
1523
+ (event) =>
1524
+ event.type === 'tool/call' &&
1525
+ event.data !== null &&
1526
+ event.data !== undefined &&
1527
+ event.data.turn === turn &&
1528
+ event.data.step === step,
1529
+ )
1530
+ const pushWithCalls = (event, turn) => {
1531
+ range.push(event)
1532
+ if (event.type !== 'assistant/message') return
1533
+ const step = event.data !== null && event.data !== undefined && typeof event.data.step === 'number' ? event.data.step : null
1534
+ if (step === null) return
1535
+ for (const call of callIdsOf(turn, step)) if (!range.includes(call)) range.push(call)
1536
+ }
1537
+ const range = []
1538
+ for (const turn of [...buckets.keys()].sort((left, right) => left - right)) {
1539
+ const bucket = buckets.get(turn)
1540
+ range.push(bracketOf('turn/start', turn) ?? { type: 'turn/start', data: { turn } })
1541
+ for (const event of bucket.level) pushWithCalls(event, turn)
1542
+ for (const step of [...bucket.steps.keys()].sort((left, right) => left - right)) {
1543
+ range.push(bracketOf('step/start', turn, step) ?? { type: 'step/start', data: { turn, step } })
1544
+ for (const event of bucket.steps.get(step)) pushWithCalls(event, turn)
1545
+ range.push(bracketOf('step/end', turn, step) ?? { type: 'step/end', data: { turn, step } })
1546
+ }
1547
+ range.push(bracketOf('turn/end', turn) ?? { type: 'turn/end', data: { turn, reason: { kind: 'completed' } } })
1403
1548
  }
1404
1549
  // Tool pairing facts for the whole range, so assistant content can be
1405
1550
  // corrected before anything is written.
@@ -2324,6 +2469,9 @@ async function applyRerun(ctx, sessionId, body, config) {
2324
2469
  rerunId,
2325
2470
  carrierSeq: plan.startSeq,
2326
2471
  shadowed: plan.shadowed,
2472
+ // 客户端在生成期间要额外隐藏的行:只含提示词槽位那条载体的**直接**替换目标
2473
+ // (即 edit 的原文)。多跳链上的其它目标属于别的轮次,隐藏它们会把界面清空。
2474
+ retired: plan.restored ?? [],
2327
2475
  turn: plan.turn,
2328
2476
  promptSeq: plan.promptSeq,
2329
2477
  ...flush,
@@ -1,6 +1,6 @@
1
1
  {
2
- "dsh-edit-turn": "0.5.12",
3
- "dsh-rerun-turn": "0.2.6",
4
- "dsh-delete-turn": "0.1.15",
2
+ "dsh-edit-turn": "0.5.13",
3
+ "dsh-rerun-turn": "0.2.7",
4
+ "dsh-delete-turn": "0.2.0",
5
5
  "dsh-markdown-bubble": "0.1.5"
6
6
  }