dsh-as-aistudio 0.2.8 → 0.2.12

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-as-aistudio",
3
- "version": "0.2.8",
3
+ "version": "0.2.12",
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; the four components are dependencies mounted by it. Each still installs alone and works alone.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -52,10 +52,10 @@
52
52
  "verify:manifest": "node tools/verify-manifest.mjs"
53
53
  },
54
54
  "dependencies": {
55
- "dsh-delete-turn": "^0.1.8",
56
- "dsh-edit-turn": "^0.2.19",
55
+ "dsh-delete-turn": "^0.1.9",
56
+ "dsh-edit-turn": "^0.2.20",
57
57
  "dsh-markdown-bubble": "^0.1.4",
58
- "dsh-rerun-turn": "^0.1.25"
58
+ "dsh-rerun-turn": "^0.1.26"
59
59
  },
60
60
  "dsh": {
61
61
  "bundle": {
package/src/client.js CHANGED
@@ -40,7 +40,7 @@ window.__ModuleLoader__.load({
40
40
  const RERUN_PREFIX = '/dsh-rerun-turn'
41
41
 
42
42
  /** Keep in sync with package.json and lib/index.js. */
43
- const PLUGIN_VERSION = '0.2.19'
43
+ const PLUGIN_VERSION = '0.2.20'
44
44
 
45
45
  // --- copy -----------------------------------------------------------------
46
46
 
@@ -83,7 +83,6 @@ window.__ModuleLoader__.load({
83
83
  'error.prompt': '已回退,但改后的内容没能写进上下文。请重试。',
84
84
  'error.reply': '已回退,但替换没有落地。请重试。',
85
85
  'error.empty': '改写后的内容不能为空。',
86
- 'error.unchanged': '内容没有变化,未做任何修改。',
87
86
  'error.rerun-nothing': '已保存,但这一轮没有可重跑的作答。',
88
87
  'error.rerun-unavailable': '已保存,但重跑没有开始,请稍后重试。',
89
88
  'error.not-rerunnable': '这一轮没有可重跑的作答。',
@@ -131,7 +130,6 @@ window.__ModuleLoader__.load({
131
130
  'error.prompt': 'Rolled back, but the re-run did not start. Send the revised text manually.',
132
131
  'error.reply': 'Rolled back, but the replacement did not land. Try again.',
133
132
  'error.empty': 'The revised message cannot be empty.',
134
- 'error.unchanged': 'Nothing changed, so no edit was made.',
135
133
  'error.rerun-nothing': 'Saved, but this turn has no reply that can be re-run.',
136
134
  'error.rerun-unavailable': 'Saved, but the re-run did not start; try again.',
137
135
  'error.not-rerunnable': 'This turn has no reply that can be re-run.',
@@ -746,10 +744,18 @@ window.__ModuleLoader__.load({
746
744
  failure: null,
747
745
  })
748
746
  // `unchanged` means the host wrote NOTHING: the draft is (verbatim)
749
- // the text the message already carries. There is no failure to report -
750
- // the edit was simply not an edit - so it is noticed as such, and
751
- // nothing else about this pass changes: no row was shadowed, so none is
752
- // hidden, and the message stays on screen with the pencil it had.
747
+ // the text the message already carries. The flag is read here for one
748
+ // reason, and it is not the user's: a no-op is not a failure. The
749
+ // `applied === false` wording below would claim the rollback landed
750
+ // and the replacement did not, which is a lie about an edit that was
751
+ // never attempted.
752
+ //
753
+ // Nothing is announced for it, deliberately (0.2.20 removed the
754
+ // notice this used to draw): the edit was simply not an edit, so the
755
+ // editor closes like any save and the screen is exactly as it was -
756
+ // no row was shadowed, so none is hidden, and the message keeps the
757
+ // pencil it had. (The banner also landed on top of the composer's
758
+ // placeholder text, which is not a place to report anything from.)
753
759
  //
754
760
  // The re-run intent survives it. "Re-run" saves first only because the
755
761
  // sibling regenerates the turn from the prompt the surface shows; a
@@ -757,9 +763,7 @@ window.__ModuleLoader__.load({
757
763
  // wording moved, so the chain below is NOT gated on a write having
758
764
  // happened. (The decision itself is the host's - the text is parsed
759
765
  // there, once - and this half only reads the flag it sends back.)
760
- if (data.unchanged === true) {
761
- if (!rerunIntent) this.notify('unchanged')
762
- } else {
766
+ if (data.unchanged !== true) {
763
767
  // The rollback has landed, so the editor is gone with its row: a
764
768
  // failed second half has to be reported somewhere that survives that.
765
769
  if (data.kind === 'reply' && data.applied === false) this.notify('reply')
@@ -2643,7 +2647,7 @@ window.__ModuleLoader__.load({
2643
2647
  const ROUTE_PREFIX = '/dsh-rerun-turn'
2644
2648
 
2645
2649
  /** Keep in sync with package.json and lib/index.js. */
2646
- const PLUGIN_VERSION = '0.1.25'
2650
+ const PLUGIN_VERSION = '0.1.27'
2647
2651
 
2648
2652
  // --- copy -----------------------------------------------------------------
2649
2653
 
@@ -3175,11 +3179,19 @@ window.__ModuleLoader__.load({
3175
3179
  resolved.set(key, decision)
3176
3180
  return decision
3177
3181
  }
3178
- // The turn a row belongs to, from its key shape
3179
- // (`turn-process17`, `turn-tail17`, `assistant-step17:1`).
3180
- const keyTurnOf = (key) => {
3182
+ // The turn a row belongs to. The platform publishes it on the flow item
3183
+ // itself (`data-chat-turn`, the same anchor the prompt-row buttons read),
3184
+ // and a key sometimes spells it out as well
3185
+ // (`turn-process17`, `turn-tail17`, `assistant-step17:1`). Reading the
3186
+ // key alone is what left a bookkeeping turn's empty "completed" strip on
3187
+ // screen once DSH started keying those rows by node kind
3188
+ // (`turn-tail`, `["turn-tail","response"]`) with the turn only in the
3189
+ // data attribute.
3190
+ const keyTurnOf = (key, row) => {
3181
3191
  const match = /(?:turn-process|turn-tail)(\d+)|assistant-step(\d+):/.exec(key)
3182
- return match === null ? null : Number(match[1] ?? match[2])
3192
+ if (match !== null) return Number(match[1] ?? match[2])
3193
+ const fromRow = row && row.dataset ? Number(row.dataset.chatTurn) : Number.NaN
3194
+ return Number.isInteger(fromRow) ? fromRow : null
3183
3195
  }
3184
3196
  const nodeOf = (key) => {
3185
3197
  const direct = snapshot.nodes.get(key)
@@ -3209,7 +3221,7 @@ window.__ModuleLoader__.load({
3209
3221
  key,
3210
3222
  kind: node ? node.kind : null,
3211
3223
  seqs: node ? seqsFor(node) : [],
3212
- keyTurn: keyTurnOf(key),
3224
+ keyTurn: keyTurnOf(key, row),
3213
3225
  inTarget: false,
3214
3226
  inTail: false,
3215
3227
  fresh: false,
@@ -3645,16 +3657,78 @@ window.__ModuleLoader__.load({
3645
3657
  // Minimum gap between automatic snapshot refreshes while a transcript grows.
3646
3658
  const REFRESH_INTERVAL_MS = 3000
3647
3659
 
3660
+ // --- preferences ----------------------------------------------------------
3661
+ //
3662
+ // One client-side preference: whether a deletion opens the confirmation
3663
+ // dialog first. It lives in localStorage - the same store the official
3664
+ // client plugins keep their view options in - so it survives reloads with no
3665
+ // host round-trip, and a subscription keeps the settings page and the delete
3666
+ // paths on one value.
3667
+
3668
+ const PREFS_KEY = 'dsh-delete-turn:prefs'
3669
+ const DEFAULT_ASK = true
3670
+
3671
+ function readStoredAsk() {
3672
+ try {
3673
+ if (typeof window === 'undefined' || !window.localStorage) return DEFAULT_ASK
3674
+ const raw = window.localStorage.getItem(PREFS_KEY)
3675
+ if (raw === null) return DEFAULT_ASK
3676
+ const parsed = JSON.parse(raw)
3677
+ return parsed && typeof parsed.ask === 'boolean' ? parsed.ask : DEFAULT_ASK
3678
+ } catch {
3679
+ // A missing or malformed entry falls back to the shipped default rather
3680
+ // than breaking the host: the dialog path is the safe behaviour.
3681
+ return DEFAULT_ASK
3682
+ }
3683
+ }
3684
+
3685
+ let askBeforeDelete = readStoredAsk()
3686
+ const prefListeners = new Set()
3687
+
3688
+ function getPrefs() {
3689
+ return { ask: askBeforeDelete }
3690
+ }
3691
+
3692
+ function setAskBeforeDelete(value) {
3693
+ const ask = value === true
3694
+ if (askBeforeDelete === ask) return
3695
+ askBeforeDelete = ask
3696
+ try {
3697
+ if (typeof window !== 'undefined' && window.localStorage) {
3698
+ window.localStorage.setItem(PREFS_KEY, JSON.stringify({ ask }))
3699
+ }
3700
+ } catch {
3701
+ // Storage that refuses writes (private mode) must not break the toggle.
3702
+ }
3703
+ for (const listener of prefListeners) {
3704
+ try {
3705
+ listener()
3706
+ } catch (error) {
3707
+ console.error('[dsh-delete-turn] preference subscriber threw:', error)
3708
+ }
3709
+ }
3710
+ }
3711
+
3712
+ function subscribePrefs(listener) {
3713
+ prefListeners.add(listener)
3714
+ return () => {
3715
+ prefListeners.delete(listener)
3716
+ }
3717
+ }
3718
+
3648
3719
  // --- copy -----------------------------------------------------------------
3649
3720
 
3650
3721
  const zh = {
3651
3722
  'action.tooltip.message': '删除这条消息',
3723
+ 'action.tooltip.turn': '删除这一轮',
3652
3724
  'action.tooltip.step': '删除这一步',
3653
3725
  'action.tooltip.reply': '删除这条回复',
3654
3726
  'dialog.title.message': '删除这条消息?',
3727
+ 'dialog.title.turn': '删除这一轮?',
3655
3728
  'dialog.title.step': '删除这一步?',
3656
3729
  'dialog.title.reply': '删除这条回复?',
3657
3730
  'dialog.desc.message': '这条消息将从模型上下文中移除,并从当前转录中隐藏。原始会话日志保持不变。',
3731
+ 'dialog.desc.turn': '这一轮(含你的提问、注入上下文、全部回复与工具结果)将从模型上下文中移除;它之后的轮次原样保留,在对话视图里直接接上来。',
3658
3732
  'dialog.desc.step': '这一步的回复与它请求的工具结果将一并从模型上下文中移除,同一回合的其它步骤保留。',
3659
3733
  'dialog.desc.reply': '这条回复连同它的思考、工具调用与注入上下文将从模型上下文中移除,你的提问会保留。',
3660
3734
  'dialog.note': '删除只影响模型后续看到的内容,不会改写历史日志。',
@@ -3662,6 +3736,11 @@ window.__ModuleLoader__.load({
3662
3736
  'dialog.confirm': '删除',
3663
3737
  'dialog.pending': '删除中…',
3664
3738
  'dialog.retry': '重试',
3739
+ 'config.ask': '删除时询问',
3740
+ 'config.hint.ask': '每次删除前都会弹出确认框。',
3741
+ 'config.hint.auto': '删除会直接执行,不再询问。',
3742
+ 'config.summary.ask': '删除前询问',
3743
+ 'config.summary.auto': '直接删除',
3665
3744
  'error.invalid': '请求无效,请刷新后重试。',
3666
3745
  'error.session-not-active': '这个会话当前未激活,请先打开该会话再删除。',
3667
3746
  'error.session-not-found': '找不到该会话的日志。',
@@ -3677,12 +3756,15 @@ window.__ModuleLoader__.load({
3677
3756
 
3678
3757
  const en = {
3679
3758
  'action.tooltip.message': 'Delete this message',
3759
+ 'action.tooltip.turn': 'Delete this turn',
3680
3760
  'action.tooltip.step': 'Delete this step',
3681
3761
  'action.tooltip.reply': 'Delete this reply',
3682
3762
  'dialog.title.message': 'Delete this message?',
3763
+ 'dialog.title.turn': 'Delete this turn?',
3683
3764
  'dialog.title.step': 'Delete this step?',
3684
3765
  'dialog.title.reply': 'Delete this reply?',
3685
3766
  'dialog.desc.message': 'This message leaves the model context and is hidden from the current transcript. The original session log stays untouched.',
3767
+ 'dialog.desc.turn': 'This turn (your prompt, injected context, every reply step and tool result) leaves the model context. Later turns stay exactly where they are and simply move up in the transcript.',
3686
3768
  'dialog.desc.step': 'This step and the tool results it requested leave the model context together; other steps in the same turn stay.',
3687
3769
  'dialog.desc.reply': 'This reply leaves the model context together with its reasoning, tool calls and injected context; your prompt stays.',
3688
3770
  'dialog.note': 'Deletion only changes what the model sees next; the append-only log is never rewritten.',
@@ -3690,6 +3772,11 @@ window.__ModuleLoader__.load({
3690
3772
  'dialog.confirm': 'Delete',
3691
3773
  'dialog.pending': 'Deleting...',
3692
3774
  'dialog.retry': 'Retry',
3775
+ 'config.ask': 'Ask before deleting',
3776
+ 'config.hint.ask': 'Show a confirmation dialog before every deletion.',
3777
+ 'config.hint.auto': 'Delete right away without asking.',
3778
+ 'config.summary.ask': 'Ask before deleting',
3779
+ 'config.summary.auto': 'Delete without asking',
3693
3780
  'error.invalid': 'Invalid request; refresh and try again.',
3694
3781
  'error.session-not-active': 'This session is not open in DSH; open it first.',
3695
3782
  'error.session-not-found': 'No session log was found for this id.',
@@ -3720,10 +3807,22 @@ window.__ModuleLoader__.load({
3720
3807
  '[data-variant="think"]:hover .dshdt-think-action,.dshdt-think-action:focus-within{opacity:1}',
3721
3808
  '.dshdt-collapsing{overflow:hidden;transition:height .2s ease,opacity .14s ease,margin .2s ease,padding .2s ease}',
3722
3809
  '[data-dshdt-hidden="1"]{display:none!important}',
3810
+ // The second row action: delete the whole turn (prompt included). It is
3811
+ // its own class so the trash action's selector, its counts and the
3812
+ // interop contract stay exactly as they were; the row attribute turns it
3813
+ // on only where the host can plan the operation.
3814
+ '.dshdt-turn{width:28px;height:28px;padding:6px;display:none;align-items:center;justify-content:center;border:none;border-radius:28px;background:transparent;color:var(--dsw-alias-label-tertiary,#8a8f98);cursor:pointer;transition:background-color .12s,color .12s}',
3815
+ '[data-dshdt-turn="1"] .dshdt-turn{display:inline-flex}',
3816
+ '.dshdt-turn:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.12));color:var(--dsw-alias-button-primary-fill,#4d6bfe)}',
3817
+ '.dshdt-turn:focus-visible{outline:2px solid var(--dsw-alias-button-primary-fill,#4d6bfe);outline-offset:2px}',
3818
+ '.dshdt-turn:disabled{cursor:default;opacity:.4}',
3819
+ '.dshdt-turn svg{width:15px;height:15px}',
3723
3820
  '[data-dshdt-no-target="1"] .dshdt-action{display:none!important}',
3724
3821
  '.dshdt-dialog-text{margin:0;color:var(--dsw-alias-label-primary,inherit);font-size:14px;line-height:22px}',
3725
3822
  '.dshdt-dialog-note{margin:10px 0 0;color:var(--dsw-alias-label-tertiary,#8a8f98);font-size:13px;line-height:20px}',
3726
3823
  '.dshdt-dialog-error{margin:10px 0 0;color:var(--dsw-alias-state-error-primary,#d54941);font-size:13px;line-height:20px}',
3824
+ '.dshdt-config{display:flex;flex-direction:column;gap:8px}',
3825
+ '.dshdt-config-hint{margin:0;color:var(--dsw-alias-label-tertiary,#8a8f98);font-size:13px;line-height:20px}',
3727
3826
  '.dshdt-danger{background:var(--dsw-alias-state-error-primary,#d54941)!important;color:var(--dsw-alias-label-primary-foreground,#fff)!important}',
3728
3827
  '@media (prefers-reduced-motion:reduce){.dshdt-collapsing{transition:none}.dshdt-floating{transition:none}}',
3729
3828
  ].join('')
@@ -3755,6 +3854,23 @@ window.__ModuleLoader__.load({
3755
3854
  ).join('') +
3756
3855
  '</svg>'
3757
3856
 
3857
+ // Collapse glyph: a centre rail with one arrow above and one below, both
3858
+ // pointing at it - "the neighbours move up into this one's place".
3859
+ const TURN_ICON_PATHS = [
3860
+ 'M3.2 8h9.6',
3861
+ 'M8 2.4v4',
3862
+ 'M6.3 4.7L8 6.4l1.7-1.7',
3863
+ 'M8 13.6v-4',
3864
+ 'M6.3 11.3L8 9.6l1.7 1.7',
3865
+ ]
3866
+ const TURN_ICON_MARKUP =
3867
+ '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">' +
3868
+ TURN_ICON_PATHS.map(
3869
+ (d) =>
3870
+ '<path d="' + d + '" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>',
3871
+ ).join('') +
3872
+ '</svg>'
3873
+
3758
3874
  function TrashIcon() {
3759
3875
  return jsx('svg', {
3760
3876
  width: 16,
@@ -3781,6 +3897,9 @@ window.__ModuleLoader__.load({
3781
3897
  // replacement seq -> the seq it was recorded as shadowing. See hiddenViaOf.
3782
3898
  hiddenVia: new Map(),
3783
3899
  surface: new Set(),
3900
+ // The seqs the host would accept a turn-delete for (advertised, not
3901
+ // guessed): the entry appears only on those rows.
3902
+ turnSeqs: new Set(),
3784
3903
  replyTurns: new Set(),
3785
3904
  edits: new Map(),
3786
3905
  markerTurns: new Set(),
@@ -3847,6 +3966,8 @@ window.__ModuleLoader__.load({
3847
3966
  const hiddenVia = hiddenViaOf(data.hidden)
3848
3967
  const surface = new Set()
3849
3968
  for (const seq of Array.isArray(data.surface) ? data.surface : []) surface.add(seq)
3969
+ const turnSeqs = new Set()
3970
+ for (const seq of Array.isArray(data.turnSeqs) ? data.turnSeqs : []) turnSeqs.add(seq)
3850
3971
  const replyTurns = new Set()
3851
3972
  for (const turn of Array.isArray(data.replyTurns) ? data.replyTurns : []) replyTurns.add(turn)
3852
3973
  const edits = new Map()
@@ -3858,7 +3979,7 @@ window.__ModuleLoader__.load({
3858
3979
  if (typeof turn === 'number') markerTurns.add(turn)
3859
3980
  }
3860
3981
  const surfaceThrough = typeof data.lastSeq === 'number' ? data.lastSeq : -1
3861
- this.publish({ hidden, hiddenVia, surface, replyTurns, edits, markerTurns, surfaceReady: true, surfaceThrough, loaded: true, loadError: false })
3982
+ this.publish({ hidden, hiddenVia, surface, turnSeqs, replyTurns, edits, markerTurns, surfaceReady: true, surfaceThrough, loaded: true, loadError: false })
3862
3983
  })
3863
3984
  .catch(() => {
3864
3985
  this.publish({ loadError: true })
@@ -3881,8 +4002,25 @@ window.__ModuleLoader__.load({
3881
4002
  }
3882
4003
 
3883
4004
  async confirm() {
3884
- const requested = this.view.dialog
3885
- if (requested === null || this.view.pending) return
4005
+ await this.perform(this.view.dialog)
4006
+ }
4007
+
4008
+ // The entry every action uses. With "ask" on, it opens the confirmation
4009
+ // dialog as before. With it off, it deletes straight away - and since a
4010
+ // silent failure would have nowhere to surface, a failed attempt opens the
4011
+ // dialog carrying its error so the user can retry or cancel.
4012
+ async trigger(target) {
4013
+ if (target === null || target === undefined || this.view.pending) return
4014
+ if (getPrefs().ask) {
4015
+ this.open(target)
4016
+ return
4017
+ }
4018
+ const result = await this.perform(target)
4019
+ if (result.failure !== null) this.publish({ dialog: target, pending: false, failure: result.failure })
4020
+ }
4021
+
4022
+ async perform(requested) {
4023
+ if (requested === null || this.view.pending) return { failure: null }
3886
4024
  this.publish({ pending: true, failure: null })
3887
4025
  try {
3888
4026
  // The live node can move while the dialog sits open: another producer
@@ -3903,7 +4041,7 @@ window.__ModuleLoader__.load({
3903
4041
  }
3904
4042
  if (!result.ok) {
3905
4043
  this.publish({ pending: false, failure: result.code })
3906
- return
4044
+ return { failure: result.code }
3907
4045
  }
3908
4046
  const data = result.data
3909
4047
  const hidden = new Map(this.view.hidden)
@@ -3914,8 +4052,10 @@ window.__ModuleLoader__.load({
3914
4052
  for (const [via, seq] of hiddenViaOf(data.hidden)) hiddenVia.set(via, seq)
3915
4053
  this.animateOnce = true
3916
4054
  this.publish({ pending: false, dialog: null, hidden, hiddenVia, loaded: true, loadError: false })
4055
+ return { failure: null }
3917
4056
  } catch {
3918
4057
  this.publish({ pending: false, failure: 'generic' })
4058
+ return { failure: 'generic' }
3919
4059
  }
3920
4060
  }
3921
4061
 
@@ -3984,6 +4124,16 @@ window.__ModuleLoader__.load({
3984
4124
  }
3985
4125
  }
3986
4126
 
4127
+ // The second entry: delete the WHOLE turn the row belongs to - the prompt
4128
+ // included - as one range replace. Only a human prompt anchors it; an
4129
+ // injected context row or a reply row is not the turn's own question.
4130
+ function turnTargetFor(node) {
4131
+ if (node.kind !== 'user') return null
4132
+ const data = node.data || {}
4133
+ const seq = typeof data.seq === 'number' ? data.seq : node.anchorSeq
4134
+ return typeof seq === 'number' ? { mode: 'turn', seq, label: 'turn' } : null
4135
+ }
4136
+
3987
4137
  function seqsFor(node) {
3988
4138
  const data = node.data || {}
3989
4139
  const out = []
@@ -4106,6 +4256,7 @@ window.__ModuleLoader__.load({
4106
4256
  // does not, and a re-apply (HMR, plugin toggle, bundle-group reload) starts
4107
4257
  // with fresh module state over the very same page.
4108
4258
  const HOST_MARK = 'data-dshdt-action-host'
4259
+ const TURN_CLASS = 'dshdt-turn'
4109
4260
  const THINK_MARK = 'data-dshdt-think-action'
4110
4261
  const HOST_CLASS = 'dshdt-action-host'
4111
4262
  const THINK_CLASS = 'dshdt-think-action'
@@ -4148,7 +4299,23 @@ window.__ModuleLoader__.load({
4148
4299
  button.innerHTML = ICON_MARKUP
4149
4300
  host.appendChild(button)
4150
4301
  }
4151
- return { host, button }
4302
+ // The row host carries this plugin's second action as well: delete the
4303
+ // whole turn. It rides in the SAME host -
4304
+ // the trash action's namespace, its selector and its one-host-per-row
4305
+ // contract are untouched - under its own class, and the reasoning host
4306
+ // never gets one (a reasoning card owns no turn).
4307
+ let turn = null
4308
+ if (kind !== 'think') {
4309
+ turn = host.querySelector('.' + TURN_CLASS)
4310
+ if (turn === null) {
4311
+ turn = document.createElement('button')
4312
+ turn.type = 'button'
4313
+ turn.className = TURN_CLASS + ' dshdt-row-action'
4314
+ turn.innerHTML = TURN_ICON_MARKUP
4315
+ host.appendChild(turn)
4316
+ }
4317
+ }
4318
+ return { host, button, turn }
4152
4319
  }
4153
4320
 
4154
4321
  // Drop every node this plugin injected. Matched through this plugin's own
@@ -4237,7 +4404,7 @@ window.__ModuleLoader__.load({
4237
4404
  rowActions.delete(row)
4238
4405
  }
4239
4406
 
4240
- function injectRowAction(row, node, target, controller, t) {
4407
+ function injectRowAction(row, node, target, turnTarget, controller, t) {
4241
4408
  const label = t(`action.tooltip.${target.label}`)
4242
4409
  let entry = rowActions.get(row)
4243
4410
  if (!entry) {
@@ -4246,7 +4413,7 @@ window.__ModuleLoader__.load({
4246
4413
  entry = adoptHost(findOwnedHost(row, false), 'row')
4247
4414
  rowActions.set(row, entry)
4248
4415
  }
4249
- const { host, button } = entry
4416
+ const { host, button, turn } = entry
4250
4417
  if (button.getAttribute('aria-label') !== label) {
4251
4418
  button.setAttribute('aria-label', label)
4252
4419
  button.setAttribute('title', label)
@@ -4254,7 +4421,25 @@ window.__ModuleLoader__.load({
4254
4421
  button.onclick = (event) => {
4255
4422
  event.preventDefault()
4256
4423
  event.stopPropagation()
4257
- controller.open(target)
4424
+ controller.trigger(target)
4425
+ }
4426
+ // The second action is wired only where a turn target exists (and stays
4427
+ // hidden by the row attribute otherwise), so a row can never offer an
4428
+ // operation the host would refuse for the wrong reason.
4429
+ if (turn !== null && turn !== undefined) {
4430
+ const turnLabel = t('action.tooltip.turn')
4431
+ if (turn.getAttribute('aria-label') !== turnLabel) {
4432
+ turn.setAttribute('aria-label', turnLabel)
4433
+ turn.setAttribute('title', turnLabel)
4434
+ }
4435
+ turn.onclick =
4436
+ turnTarget === null || turnTarget === undefined
4437
+ ? null
4438
+ : (event) => {
4439
+ event.preventDefault()
4440
+ event.stopPropagation()
4441
+ controller.trigger(turnTarget)
4442
+ }
4258
4443
  }
4259
4444
  const anchor = node.kind === 'user' || node.kind === 'steering' ? row.querySelector('[class*="_actions"]') : null
4260
4445
  if (anchor) {
@@ -4295,7 +4480,7 @@ window.__ModuleLoader__.load({
4295
4480
  button.onclick = (event) => {
4296
4481
  event.preventDefault()
4297
4482
  event.stopPropagation()
4298
- controller.open(target)
4483
+ controller.trigger(target)
4299
4484
  }
4300
4485
  if (host.parentElement !== think) think.appendChild(host)
4301
4486
  }
@@ -4365,8 +4550,14 @@ window.__ModuleLoader__.load({
4365
4550
  const target = hidden ? null : liveTarget(targetFor(node), view.edits)
4366
4551
  const covered = target !== null || slotCoversRow(node)
4367
4552
  const deletable = !hidden && covered && rowDeletable(node, seqs, view)
4368
- if (deletable && target !== null) injectRowAction(row, node, target, controller, t)
4553
+ // The turn-delete entry rides on the same gates as the delete entry: a
4554
+ // row whose content already left the context offers neither.
4555
+ const turnTarget =
4556
+ deletable && turnTargetFor(node) !== null && view.turnSeqs.has(target.seq) ? liveTarget(turnTargetFor(node), view.edits) : null
4557
+ if (deletable && target !== null) injectRowAction(row, node, target, turnTarget, controller, t)
4369
4558
  else removeRowAction(row)
4559
+ if (deletable && target !== null && turnTarget !== null) row.dataset.dshdtTurn = '1'
4560
+ else delete row.dataset.dshdtTurn
4370
4561
  if (!hidden && covered && !rowDeletable(node, seqs, view)) row.dataset.dshdtNoTarget = '1'
4371
4562
  else delete row.dataset.dshdtNoTarget
4372
4563
  }
@@ -4436,7 +4627,7 @@ window.__ModuleLoader__.load({
4436
4627
  'aria-label': label,
4437
4628
  title: label,
4438
4629
  disabled: view.pending,
4439
- onClick: () => controller.open({ mode: 'reply', messageId }),
4630
+ onClick: () => controller.trigger({ mode: 'reply', messageId }),
4440
4631
  children: jsx(TrashIcon, {}),
4441
4632
  })
4442
4633
  }
@@ -4481,6 +4672,27 @@ window.__ModuleLoader__.load({
4481
4672
  })
4482
4673
  }
4483
4674
 
4675
+ // The plugin page entry (the component row's "configure" arrow, slot
4676
+ // `plugins.row.config`, key `<bundle>#<row id>`). It renders the row's
4677
+ // one-liner for the manager's summary slot and the toggle card for the page.
4678
+ function AskBeforeDeleteOption({ view, t }) {
4679
+ const tr = typeof t === 'function' ? t : (key) => key
4680
+ const [ask, setAsk] = react.useState(getPrefs().ask)
4681
+ react.useEffect(() => subscribePrefs(() => setAsk(getPrefs().ask)), [])
4682
+ if (view === 'summary') return tr(ask ? 'config.summary.ask' : 'config.summary.auto')
4683
+ return jsxs('div', {
4684
+ className: 'dshdt-config',
4685
+ children: [
4686
+ jsx(primitives.Checkbox, {
4687
+ checked: ask,
4688
+ label: tr('config.ask'),
4689
+ onChange: (checked) => setAskBeforeDelete(checked),
4690
+ }),
4691
+ jsx('p', { className: 'dshdt-config-hint', children: tr(ask ? 'config.hint.ask' : 'config.hint.auto') }),
4692
+ ],
4693
+ })
4694
+ }
4695
+
4484
4696
  function OverlayEntry({ useChat, useDeletion, controller, t }) {
4485
4697
  const snapshot = typeof useChat === 'function' ? useChat((state) => state) : undefined
4486
4698
  const view = useDeletion((state) => state)
@@ -4575,6 +4787,21 @@ window.__ModuleLoader__.load({
4575
4787
  OverlayEntry,
4576
4788
  ),
4577
4789
  )
4790
+
4791
+ // The plugin's own page in the manager: the row this bundle inserts
4792
+ // (`cordis.patch.yml`, id `dsh-delete-turn`) gains a configure arrow that
4793
+ // opens this card. The key is `<bundle package>#<row id>`, the contract of
4794
+ // ui-plugin-manager's rowConfigKey.
4795
+ ctx.slots.inject('plugins.row.config', () =>
4796
+ ctx.slots.register(
4797
+ {
4798
+ name: 'plugins.row.config',
4799
+ key: 'dsh-delete-turn#dsh-delete-turn',
4800
+ locale: NS,
4801
+ },
4802
+ AskBeforeDeleteOption,
4803
+ ),
4804
+ )
4578
4805
  }
4579
4806
 
4580
4807
  exports.apply = apply
@@ -5465,7 +5692,7 @@ window.__ModuleLoader__.load({
5465
5692
  const NS = 'dsh-as-aistudio'
5466
5693
 
5467
5694
  /** Keep in sync with package.json and src/components.js. */
5468
- const PLUGIN_VERSION = '0.2.8'
5695
+ const PLUGIN_VERSION = '0.2.12'
5469
5696
 
5470
5697
  /** Presence marker the live verifier reads. */
5471
5698
  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.2.8'
29
+ export const STUDIO_VERSION = '0.2.12'
30
30
 
31
31
  /**
32
32
  * The AI Studio action strip this studio composes, in the order the user meets
package/src/studio.js CHANGED
@@ -48,7 +48,7 @@ window.__ModuleLoader__.load({
48
48
  const NS = 'dsh-as-aistudio'
49
49
 
50
50
  /** Keep in sync with package.json and src/components.js. */
51
- const PLUGIN_VERSION = '0.2.8'
51
+ const PLUGIN_VERSION = '0.2.12'
52
52
 
53
53
  /** Presence marker the live verifier reads. */
54
54
  const DEBUG_KEY = '__DSH_AS_AISTUDIO__'
@@ -1,6 +1,6 @@
1
1
  // dsh-delete-turn browser half, VENDORED by tools/vendor.mjs - DO NOT EDIT.
2
2
  //
3
- // Source: ../dsh-delete-turn/src/client.js (v0.1.8, sha256 09de24fd97ecd4ec)
3
+ // Source: ../dsh-delete-turn/src/client.js (v0.1.11, sha256 7d14658143d66eb2)
4
4
  // Regenerate with `npm run vendor`; test/vendor-sync.test.js fails when this is stale.
5
5
  //
6
6
  // The component registers its factory with the host module loader; inside this
@@ -21,16 +21,78 @@ export default (require) => {
21
21
  // Minimum gap between automatic snapshot refreshes while a transcript grows.
22
22
  const REFRESH_INTERVAL_MS = 3000
23
23
 
24
+ // --- preferences ----------------------------------------------------------
25
+ //
26
+ // One client-side preference: whether a deletion opens the confirmation
27
+ // dialog first. It lives in localStorage - the same store the official
28
+ // client plugins keep their view options in - so it survives reloads with no
29
+ // host round-trip, and a subscription keeps the settings page and the delete
30
+ // paths on one value.
31
+
32
+ const PREFS_KEY = 'dsh-delete-turn:prefs'
33
+ const DEFAULT_ASK = true
34
+
35
+ function readStoredAsk() {
36
+ try {
37
+ if (typeof window === 'undefined' || !window.localStorage) return DEFAULT_ASK
38
+ const raw = window.localStorage.getItem(PREFS_KEY)
39
+ if (raw === null) return DEFAULT_ASK
40
+ const parsed = JSON.parse(raw)
41
+ return parsed && typeof parsed.ask === 'boolean' ? parsed.ask : DEFAULT_ASK
42
+ } catch {
43
+ // A missing or malformed entry falls back to the shipped default rather
44
+ // than breaking the host: the dialog path is the safe behaviour.
45
+ return DEFAULT_ASK
46
+ }
47
+ }
48
+
49
+ let askBeforeDelete = readStoredAsk()
50
+ const prefListeners = new Set()
51
+
52
+ function getPrefs() {
53
+ return { ask: askBeforeDelete }
54
+ }
55
+
56
+ function setAskBeforeDelete(value) {
57
+ const ask = value === true
58
+ if (askBeforeDelete === ask) return
59
+ askBeforeDelete = ask
60
+ try {
61
+ if (typeof window !== 'undefined' && window.localStorage) {
62
+ window.localStorage.setItem(PREFS_KEY, JSON.stringify({ ask }))
63
+ }
64
+ } catch {
65
+ // Storage that refuses writes (private mode) must not break the toggle.
66
+ }
67
+ for (const listener of prefListeners) {
68
+ try {
69
+ listener()
70
+ } catch (error) {
71
+ console.error('[dsh-delete-turn] preference subscriber threw:', error)
72
+ }
73
+ }
74
+ }
75
+
76
+ function subscribePrefs(listener) {
77
+ prefListeners.add(listener)
78
+ return () => {
79
+ prefListeners.delete(listener)
80
+ }
81
+ }
82
+
24
83
  // --- copy -----------------------------------------------------------------
25
84
 
26
85
  const zh = {
27
86
  'action.tooltip.message': '删除这条消息',
87
+ 'action.tooltip.turn': '删除这一轮',
28
88
  'action.tooltip.step': '删除这一步',
29
89
  'action.tooltip.reply': '删除这条回复',
30
90
  'dialog.title.message': '删除这条消息?',
91
+ 'dialog.title.turn': '删除这一轮?',
31
92
  'dialog.title.step': '删除这一步?',
32
93
  'dialog.title.reply': '删除这条回复?',
33
94
  'dialog.desc.message': '这条消息将从模型上下文中移除,并从当前转录中隐藏。原始会话日志保持不变。',
95
+ 'dialog.desc.turn': '这一轮(含你的提问、注入上下文、全部回复与工具结果)将从模型上下文中移除;它之后的轮次原样保留,在对话视图里直接接上来。',
34
96
  'dialog.desc.step': '这一步的回复与它请求的工具结果将一并从模型上下文中移除,同一回合的其它步骤保留。',
35
97
  'dialog.desc.reply': '这条回复连同它的思考、工具调用与注入上下文将从模型上下文中移除,你的提问会保留。',
36
98
  'dialog.note': '删除只影响模型后续看到的内容,不会改写历史日志。',
@@ -38,6 +100,11 @@ export default (require) => {
38
100
  'dialog.confirm': '删除',
39
101
  'dialog.pending': '删除中…',
40
102
  'dialog.retry': '重试',
103
+ 'config.ask': '删除时询问',
104
+ 'config.hint.ask': '每次删除前都会弹出确认框。',
105
+ 'config.hint.auto': '删除会直接执行,不再询问。',
106
+ 'config.summary.ask': '删除前询问',
107
+ 'config.summary.auto': '直接删除',
41
108
  'error.invalid': '请求无效,请刷新后重试。',
42
109
  'error.session-not-active': '这个会话当前未激活,请先打开该会话再删除。',
43
110
  'error.session-not-found': '找不到该会话的日志。',
@@ -53,12 +120,15 @@ export default (require) => {
53
120
 
54
121
  const en = {
55
122
  'action.tooltip.message': 'Delete this message',
123
+ 'action.tooltip.turn': 'Delete this turn',
56
124
  'action.tooltip.step': 'Delete this step',
57
125
  'action.tooltip.reply': 'Delete this reply',
58
126
  'dialog.title.message': 'Delete this message?',
127
+ 'dialog.title.turn': 'Delete this turn?',
59
128
  'dialog.title.step': 'Delete this step?',
60
129
  'dialog.title.reply': 'Delete this reply?',
61
130
  'dialog.desc.message': 'This message leaves the model context and is hidden from the current transcript. The original session log stays untouched.',
131
+ 'dialog.desc.turn': 'This turn (your prompt, injected context, every reply step and tool result) leaves the model context. Later turns stay exactly where they are and simply move up in the transcript.',
62
132
  'dialog.desc.step': 'This step and the tool results it requested leave the model context together; other steps in the same turn stay.',
63
133
  'dialog.desc.reply': 'This reply leaves the model context together with its reasoning, tool calls and injected context; your prompt stays.',
64
134
  'dialog.note': 'Deletion only changes what the model sees next; the append-only log is never rewritten.',
@@ -66,6 +136,11 @@ export default (require) => {
66
136
  'dialog.confirm': 'Delete',
67
137
  'dialog.pending': 'Deleting...',
68
138
  'dialog.retry': 'Retry',
139
+ 'config.ask': 'Ask before deleting',
140
+ 'config.hint.ask': 'Show a confirmation dialog before every deletion.',
141
+ 'config.hint.auto': 'Delete right away without asking.',
142
+ 'config.summary.ask': 'Ask before deleting',
143
+ 'config.summary.auto': 'Delete without asking',
69
144
  'error.invalid': 'Invalid request; refresh and try again.',
70
145
  'error.session-not-active': 'This session is not open in DSH; open it first.',
71
146
  'error.session-not-found': 'No session log was found for this id.',
@@ -96,10 +171,22 @@ export default (require) => {
96
171
  '[data-variant="think"]:hover .dshdt-think-action,.dshdt-think-action:focus-within{opacity:1}',
97
172
  '.dshdt-collapsing{overflow:hidden;transition:height .2s ease,opacity .14s ease,margin .2s ease,padding .2s ease}',
98
173
  '[data-dshdt-hidden="1"]{display:none!important}',
174
+ // The second row action: delete the whole turn (prompt included). It is
175
+ // its own class so the trash action's selector, its counts and the
176
+ // interop contract stay exactly as they were; the row attribute turns it
177
+ // on only where the host can plan the operation.
178
+ '.dshdt-turn{width:28px;height:28px;padding:6px;display:none;align-items:center;justify-content:center;border:none;border-radius:28px;background:transparent;color:var(--dsw-alias-label-tertiary,#8a8f98);cursor:pointer;transition:background-color .12s,color .12s}',
179
+ '[data-dshdt-turn="1"] .dshdt-turn{display:inline-flex}',
180
+ '.dshdt-turn:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.12));color:var(--dsw-alias-button-primary-fill,#4d6bfe)}',
181
+ '.dshdt-turn:focus-visible{outline:2px solid var(--dsw-alias-button-primary-fill,#4d6bfe);outline-offset:2px}',
182
+ '.dshdt-turn:disabled{cursor:default;opacity:.4}',
183
+ '.dshdt-turn svg{width:15px;height:15px}',
99
184
  '[data-dshdt-no-target="1"] .dshdt-action{display:none!important}',
100
185
  '.dshdt-dialog-text{margin:0;color:var(--dsw-alias-label-primary,inherit);font-size:14px;line-height:22px}',
101
186
  '.dshdt-dialog-note{margin:10px 0 0;color:var(--dsw-alias-label-tertiary,#8a8f98);font-size:13px;line-height:20px}',
102
187
  '.dshdt-dialog-error{margin:10px 0 0;color:var(--dsw-alias-state-error-primary,#d54941);font-size:13px;line-height:20px}',
188
+ '.dshdt-config{display:flex;flex-direction:column;gap:8px}',
189
+ '.dshdt-config-hint{margin:0;color:var(--dsw-alias-label-tertiary,#8a8f98);font-size:13px;line-height:20px}',
103
190
  '.dshdt-danger{background:var(--dsw-alias-state-error-primary,#d54941)!important;color:var(--dsw-alias-label-primary-foreground,#fff)!important}',
104
191
  '@media (prefers-reduced-motion:reduce){.dshdt-collapsing{transition:none}.dshdt-floating{transition:none}}',
105
192
  ].join('')
@@ -131,6 +218,23 @@ export default (require) => {
131
218
  ).join('') +
132
219
  '</svg>'
133
220
 
221
+ // Collapse glyph: a centre rail with one arrow above and one below, both
222
+ // pointing at it - "the neighbours move up into this one's place".
223
+ const TURN_ICON_PATHS = [
224
+ 'M3.2 8h9.6',
225
+ 'M8 2.4v4',
226
+ 'M6.3 4.7L8 6.4l1.7-1.7',
227
+ 'M8 13.6v-4',
228
+ 'M6.3 11.3L8 9.6l1.7 1.7',
229
+ ]
230
+ const TURN_ICON_MARKUP =
231
+ '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">' +
232
+ TURN_ICON_PATHS.map(
233
+ (d) =>
234
+ '<path d="' + d + '" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>',
235
+ ).join('') +
236
+ '</svg>'
237
+
134
238
  function TrashIcon() {
135
239
  return jsx('svg', {
136
240
  width: 16,
@@ -157,6 +261,9 @@ export default (require) => {
157
261
  // replacement seq -> the seq it was recorded as shadowing. See hiddenViaOf.
158
262
  hiddenVia: new Map(),
159
263
  surface: new Set(),
264
+ // The seqs the host would accept a turn-delete for (advertised, not
265
+ // guessed): the entry appears only on those rows.
266
+ turnSeqs: new Set(),
160
267
  replyTurns: new Set(),
161
268
  edits: new Map(),
162
269
  markerTurns: new Set(),
@@ -223,6 +330,8 @@ export default (require) => {
223
330
  const hiddenVia = hiddenViaOf(data.hidden)
224
331
  const surface = new Set()
225
332
  for (const seq of Array.isArray(data.surface) ? data.surface : []) surface.add(seq)
333
+ const turnSeqs = new Set()
334
+ for (const seq of Array.isArray(data.turnSeqs) ? data.turnSeqs : []) turnSeqs.add(seq)
226
335
  const replyTurns = new Set()
227
336
  for (const turn of Array.isArray(data.replyTurns) ? data.replyTurns : []) replyTurns.add(turn)
228
337
  const edits = new Map()
@@ -234,7 +343,7 @@ export default (require) => {
234
343
  if (typeof turn === 'number') markerTurns.add(turn)
235
344
  }
236
345
  const surfaceThrough = typeof data.lastSeq === 'number' ? data.lastSeq : -1
237
- this.publish({ hidden, hiddenVia, surface, replyTurns, edits, markerTurns, surfaceReady: true, surfaceThrough, loaded: true, loadError: false })
346
+ this.publish({ hidden, hiddenVia, surface, turnSeqs, replyTurns, edits, markerTurns, surfaceReady: true, surfaceThrough, loaded: true, loadError: false })
238
347
  })
239
348
  .catch(() => {
240
349
  this.publish({ loadError: true })
@@ -257,8 +366,25 @@ export default (require) => {
257
366
  }
258
367
 
259
368
  async confirm() {
260
- const requested = this.view.dialog
261
- if (requested === null || this.view.pending) return
369
+ await this.perform(this.view.dialog)
370
+ }
371
+
372
+ // The entry every action uses. With "ask" on, it opens the confirmation
373
+ // dialog as before. With it off, it deletes straight away - and since a
374
+ // silent failure would have nowhere to surface, a failed attempt opens the
375
+ // dialog carrying its error so the user can retry or cancel.
376
+ async trigger(target) {
377
+ if (target === null || target === undefined || this.view.pending) return
378
+ if (getPrefs().ask) {
379
+ this.open(target)
380
+ return
381
+ }
382
+ const result = await this.perform(target)
383
+ if (result.failure !== null) this.publish({ dialog: target, pending: false, failure: result.failure })
384
+ }
385
+
386
+ async perform(requested) {
387
+ if (requested === null || this.view.pending) return { failure: null }
262
388
  this.publish({ pending: true, failure: null })
263
389
  try {
264
390
  // The live node can move while the dialog sits open: another producer
@@ -279,7 +405,7 @@ export default (require) => {
279
405
  }
280
406
  if (!result.ok) {
281
407
  this.publish({ pending: false, failure: result.code })
282
- return
408
+ return { failure: result.code }
283
409
  }
284
410
  const data = result.data
285
411
  const hidden = new Map(this.view.hidden)
@@ -290,8 +416,10 @@ export default (require) => {
290
416
  for (const [via, seq] of hiddenViaOf(data.hidden)) hiddenVia.set(via, seq)
291
417
  this.animateOnce = true
292
418
  this.publish({ pending: false, dialog: null, hidden, hiddenVia, loaded: true, loadError: false })
419
+ return { failure: null }
293
420
  } catch {
294
421
  this.publish({ pending: false, failure: 'generic' })
422
+ return { failure: 'generic' }
295
423
  }
296
424
  }
297
425
 
@@ -360,6 +488,16 @@ export default (require) => {
360
488
  }
361
489
  }
362
490
 
491
+ // The second entry: delete the WHOLE turn the row belongs to - the prompt
492
+ // included - as one range replace. Only a human prompt anchors it; an
493
+ // injected context row or a reply row is not the turn's own question.
494
+ function turnTargetFor(node) {
495
+ if (node.kind !== 'user') return null
496
+ const data = node.data || {}
497
+ const seq = typeof data.seq === 'number' ? data.seq : node.anchorSeq
498
+ return typeof seq === 'number' ? { mode: 'turn', seq, label: 'turn' } : null
499
+ }
500
+
363
501
  function seqsFor(node) {
364
502
  const data = node.data || {}
365
503
  const out = []
@@ -482,6 +620,7 @@ export default (require) => {
482
620
  // does not, and a re-apply (HMR, plugin toggle, bundle-group reload) starts
483
621
  // with fresh module state over the very same page.
484
622
  const HOST_MARK = 'data-dshdt-action-host'
623
+ const TURN_CLASS = 'dshdt-turn'
485
624
  const THINK_MARK = 'data-dshdt-think-action'
486
625
  const HOST_CLASS = 'dshdt-action-host'
487
626
  const THINK_CLASS = 'dshdt-think-action'
@@ -524,7 +663,23 @@ export default (require) => {
524
663
  button.innerHTML = ICON_MARKUP
525
664
  host.appendChild(button)
526
665
  }
527
- return { host, button }
666
+ // The row host carries this plugin's second action as well: delete the
667
+ // whole turn. It rides in the SAME host -
668
+ // the trash action's namespace, its selector and its one-host-per-row
669
+ // contract are untouched - under its own class, and the reasoning host
670
+ // never gets one (a reasoning card owns no turn).
671
+ let turn = null
672
+ if (kind !== 'think') {
673
+ turn = host.querySelector('.' + TURN_CLASS)
674
+ if (turn === null) {
675
+ turn = document.createElement('button')
676
+ turn.type = 'button'
677
+ turn.className = TURN_CLASS + ' dshdt-row-action'
678
+ turn.innerHTML = TURN_ICON_MARKUP
679
+ host.appendChild(turn)
680
+ }
681
+ }
682
+ return { host, button, turn }
528
683
  }
529
684
 
530
685
  // Drop every node this plugin injected. Matched through this plugin's own
@@ -613,7 +768,7 @@ export default (require) => {
613
768
  rowActions.delete(row)
614
769
  }
615
770
 
616
- function injectRowAction(row, node, target, controller, t) {
771
+ function injectRowAction(row, node, target, turnTarget, controller, t) {
617
772
  const label = t(`action.tooltip.${target.label}`)
618
773
  let entry = rowActions.get(row)
619
774
  if (!entry) {
@@ -622,7 +777,7 @@ export default (require) => {
622
777
  entry = adoptHost(findOwnedHost(row, false), 'row')
623
778
  rowActions.set(row, entry)
624
779
  }
625
- const { host, button } = entry
780
+ const { host, button, turn } = entry
626
781
  if (button.getAttribute('aria-label') !== label) {
627
782
  button.setAttribute('aria-label', label)
628
783
  button.setAttribute('title', label)
@@ -630,7 +785,25 @@ export default (require) => {
630
785
  button.onclick = (event) => {
631
786
  event.preventDefault()
632
787
  event.stopPropagation()
633
- controller.open(target)
788
+ controller.trigger(target)
789
+ }
790
+ // The second action is wired only where a turn target exists (and stays
791
+ // hidden by the row attribute otherwise), so a row can never offer an
792
+ // operation the host would refuse for the wrong reason.
793
+ if (turn !== null && turn !== undefined) {
794
+ const turnLabel = t('action.tooltip.turn')
795
+ if (turn.getAttribute('aria-label') !== turnLabel) {
796
+ turn.setAttribute('aria-label', turnLabel)
797
+ turn.setAttribute('title', turnLabel)
798
+ }
799
+ turn.onclick =
800
+ turnTarget === null || turnTarget === undefined
801
+ ? null
802
+ : (event) => {
803
+ event.preventDefault()
804
+ event.stopPropagation()
805
+ controller.trigger(turnTarget)
806
+ }
634
807
  }
635
808
  const anchor = node.kind === 'user' || node.kind === 'steering' ? row.querySelector('[class*="_actions"]') : null
636
809
  if (anchor) {
@@ -671,7 +844,7 @@ export default (require) => {
671
844
  button.onclick = (event) => {
672
845
  event.preventDefault()
673
846
  event.stopPropagation()
674
- controller.open(target)
847
+ controller.trigger(target)
675
848
  }
676
849
  if (host.parentElement !== think) think.appendChild(host)
677
850
  }
@@ -741,8 +914,14 @@ export default (require) => {
741
914
  const target = hidden ? null : liveTarget(targetFor(node), view.edits)
742
915
  const covered = target !== null || slotCoversRow(node)
743
916
  const deletable = !hidden && covered && rowDeletable(node, seqs, view)
744
- if (deletable && target !== null) injectRowAction(row, node, target, controller, t)
917
+ // The turn-delete entry rides on the same gates as the delete entry: a
918
+ // row whose content already left the context offers neither.
919
+ const turnTarget =
920
+ deletable && turnTargetFor(node) !== null && view.turnSeqs.has(target.seq) ? liveTarget(turnTargetFor(node), view.edits) : null
921
+ if (deletable && target !== null) injectRowAction(row, node, target, turnTarget, controller, t)
745
922
  else removeRowAction(row)
923
+ if (deletable && target !== null && turnTarget !== null) row.dataset.dshdtTurn = '1'
924
+ else delete row.dataset.dshdtTurn
746
925
  if (!hidden && covered && !rowDeletable(node, seqs, view)) row.dataset.dshdtNoTarget = '1'
747
926
  else delete row.dataset.dshdtNoTarget
748
927
  }
@@ -812,7 +991,7 @@ export default (require) => {
812
991
  'aria-label': label,
813
992
  title: label,
814
993
  disabled: view.pending,
815
- onClick: () => controller.open({ mode: 'reply', messageId }),
994
+ onClick: () => controller.trigger({ mode: 'reply', messageId }),
816
995
  children: jsx(TrashIcon, {}),
817
996
  })
818
997
  }
@@ -857,6 +1036,27 @@ export default (require) => {
857
1036
  })
858
1037
  }
859
1038
 
1039
+ // The plugin page entry (the component row's "configure" arrow, slot
1040
+ // `plugins.row.config`, key `<bundle>#<row id>`). It renders the row's
1041
+ // one-liner for the manager's summary slot and the toggle card for the page.
1042
+ function AskBeforeDeleteOption({ view, t }) {
1043
+ const tr = typeof t === 'function' ? t : (key) => key
1044
+ const [ask, setAsk] = react.useState(getPrefs().ask)
1045
+ react.useEffect(() => subscribePrefs(() => setAsk(getPrefs().ask)), [])
1046
+ if (view === 'summary') return tr(ask ? 'config.summary.ask' : 'config.summary.auto')
1047
+ return jsxs('div', {
1048
+ className: 'dshdt-config',
1049
+ children: [
1050
+ jsx(primitives.Checkbox, {
1051
+ checked: ask,
1052
+ label: tr('config.ask'),
1053
+ onChange: (checked) => setAskBeforeDelete(checked),
1054
+ }),
1055
+ jsx('p', { className: 'dshdt-config-hint', children: tr(ask ? 'config.hint.ask' : 'config.hint.auto') }),
1056
+ ],
1057
+ })
1058
+ }
1059
+
860
1060
  function OverlayEntry({ useChat, useDeletion, controller, t }) {
861
1061
  const snapshot = typeof useChat === 'function' ? useChat((state) => state) : undefined
862
1062
  const view = useDeletion((state) => state)
@@ -951,6 +1151,21 @@ export default (require) => {
951
1151
  OverlayEntry,
952
1152
  ),
953
1153
  )
1154
+
1155
+ // The plugin's own page in the manager: the row this bundle inserts
1156
+ // (`cordis.patch.yml`, id `dsh-delete-turn`) gains a configure arrow that
1157
+ // opens this card. The key is `<bundle package>#<row id>`, the contract of
1158
+ // ui-plugin-manager's rowConfigKey.
1159
+ ctx.slots.inject('plugins.row.config', () =>
1160
+ ctx.slots.register(
1161
+ {
1162
+ name: 'plugins.row.config',
1163
+ key: 'dsh-delete-turn#dsh-delete-turn',
1164
+ locale: NS,
1165
+ },
1166
+ AskBeforeDeleteOption,
1167
+ ),
1168
+ )
954
1169
  }
955
1170
 
956
1171
  exports.apply = apply
@@ -1,6 +1,6 @@
1
1
  // dsh-edit-turn browser half, VENDORED by tools/vendor.mjs - DO NOT EDIT.
2
2
  //
3
- // Source: ../dsh-edit-turn/lib/client.js (v0.2.19, sha256 618c7394fb3880c4)
3
+ // Source: ../dsh-edit-turn/lib/client.js (v0.2.20, sha256 dd3f857d93cbdb0c)
4
4
  // Regenerate with `npm run vendor`; test/vendor-sync.test.js fails when this is stale.
5
5
  //
6
6
  // The component registers its factory with the host module loader; inside this
@@ -32,7 +32,7 @@ export default (require) => {
32
32
  const RERUN_PREFIX = '/dsh-rerun-turn'
33
33
 
34
34
  /** Keep in sync with package.json and lib/index.js. */
35
- const PLUGIN_VERSION = '0.2.19'
35
+ const PLUGIN_VERSION = '0.2.20'
36
36
 
37
37
  // --- copy -----------------------------------------------------------------
38
38
 
@@ -75,7 +75,6 @@ export default (require) => {
75
75
  'error.prompt': '已回退,但改后的内容没能写进上下文。请重试。',
76
76
  'error.reply': '已回退,但替换没有落地。请重试。',
77
77
  'error.empty': '改写后的内容不能为空。',
78
- 'error.unchanged': '内容没有变化,未做任何修改。',
79
78
  'error.rerun-nothing': '已保存,但这一轮没有可重跑的作答。',
80
79
  'error.rerun-unavailable': '已保存,但重跑没有开始,请稍后重试。',
81
80
  'error.not-rerunnable': '这一轮没有可重跑的作答。',
@@ -123,7 +122,6 @@ export default (require) => {
123
122
  'error.prompt': 'Rolled back, but the re-run did not start. Send the revised text manually.',
124
123
  'error.reply': 'Rolled back, but the replacement did not land. Try again.',
125
124
  'error.empty': 'The revised message cannot be empty.',
126
- 'error.unchanged': 'Nothing changed, so no edit was made.',
127
125
  'error.rerun-nothing': 'Saved, but this turn has no reply that can be re-run.',
128
126
  'error.rerun-unavailable': 'Saved, but the re-run did not start; try again.',
129
127
  'error.not-rerunnable': 'This turn has no reply that can be re-run.',
@@ -738,10 +736,18 @@ export default (require) => {
738
736
  failure: null,
739
737
  })
740
738
  // `unchanged` means the host wrote NOTHING: the draft is (verbatim)
741
- // the text the message already carries. There is no failure to report -
742
- // the edit was simply not an edit - so it is noticed as such, and
743
- // nothing else about this pass changes: no row was shadowed, so none is
744
- // hidden, and the message stays on screen with the pencil it had.
739
+ // the text the message already carries. The flag is read here for one
740
+ // reason, and it is not the user's: a no-op is not a failure. The
741
+ // `applied === false` wording below would claim the rollback landed
742
+ // and the replacement did not, which is a lie about an edit that was
743
+ // never attempted.
744
+ //
745
+ // Nothing is announced for it, deliberately (0.2.20 removed the
746
+ // notice this used to draw): the edit was simply not an edit, so the
747
+ // editor closes like any save and the screen is exactly as it was -
748
+ // no row was shadowed, so none is hidden, and the message keeps the
749
+ // pencil it had. (The banner also landed on top of the composer's
750
+ // placeholder text, which is not a place to report anything from.)
745
751
  //
746
752
  // The re-run intent survives it. "Re-run" saves first only because the
747
753
  // sibling regenerates the turn from the prompt the surface shows; a
@@ -749,9 +755,7 @@ export default (require) => {
749
755
  // wording moved, so the chain below is NOT gated on a write having
750
756
  // happened. (The decision itself is the host's - the text is parsed
751
757
  // there, once - and this half only reads the flag it sends back.)
752
- if (data.unchanged === true) {
753
- if (!rerunIntent) this.notify('unchanged')
754
- } else {
758
+ if (data.unchanged !== true) {
755
759
  // The rollback has landed, so the editor is gone with its row: a
756
760
  // failed second half has to be reported somewhere that survives that.
757
761
  if (data.kind === 'reply' && data.applied === false) this.notify('reply')
@@ -1,6 +1,6 @@
1
1
  // dsh-rerun-turn browser half, VENDORED by tools/vendor.mjs - DO NOT EDIT.
2
2
  //
3
- // Source: ../dsh-rerun-turn/lib/client.js (v0.1.25, sha256 7ce84517d66de598)
3
+ // Source: ../dsh-rerun-turn/lib/client.js (v0.1.27, sha256 1db55a5a2a12ac66)
4
4
  // Regenerate with `npm run vendor`; test/vendor-sync.test.js fails when this is stale.
5
5
  //
6
6
  // The component registers its factory with the host module loader; inside this
@@ -19,7 +19,7 @@ export default (require) => {
19
19
  const ROUTE_PREFIX = '/dsh-rerun-turn'
20
20
 
21
21
  /** Keep in sync with package.json and lib/index.js. */
22
- const PLUGIN_VERSION = '0.1.25'
22
+ const PLUGIN_VERSION = '0.1.27'
23
23
 
24
24
  // --- copy -----------------------------------------------------------------
25
25
 
@@ -551,11 +551,19 @@ export default (require) => {
551
551
  resolved.set(key, decision)
552
552
  return decision
553
553
  }
554
- // The turn a row belongs to, from its key shape
555
- // (`turn-process17`, `turn-tail17`, `assistant-step17:1`).
556
- const keyTurnOf = (key) => {
554
+ // The turn a row belongs to. The platform publishes it on the flow item
555
+ // itself (`data-chat-turn`, the same anchor the prompt-row buttons read),
556
+ // and a key sometimes spells it out as well
557
+ // (`turn-process17`, `turn-tail17`, `assistant-step17:1`). Reading the
558
+ // key alone is what left a bookkeeping turn's empty "completed" strip on
559
+ // screen once DSH started keying those rows by node kind
560
+ // (`turn-tail`, `["turn-tail","response"]`) with the turn only in the
561
+ // data attribute.
562
+ const keyTurnOf = (key, row) => {
557
563
  const match = /(?:turn-process|turn-tail)(\d+)|assistant-step(\d+):/.exec(key)
558
- return match === null ? null : Number(match[1] ?? match[2])
564
+ if (match !== null) return Number(match[1] ?? match[2])
565
+ const fromRow = row && row.dataset ? Number(row.dataset.chatTurn) : Number.NaN
566
+ return Number.isInteger(fromRow) ? fromRow : null
559
567
  }
560
568
  const nodeOf = (key) => {
561
569
  const direct = snapshot.nodes.get(key)
@@ -585,7 +593,7 @@ export default (require) => {
585
593
  key,
586
594
  kind: node ? node.kind : null,
587
595
  seqs: node ? seqsFor(node) : [],
588
- keyTurn: keyTurnOf(key),
596
+ keyTurn: keyTurnOf(key, row),
589
597
  inTarget: false,
590
598
  inTail: false,
591
599
  fresh: false,