@240xu/dsh-message-ops 0.4.0 → 0.4.2

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/README.md CHANGED
@@ -198,3 +198,24 @@ MIT
198
198
  逐条「恢复」(restoring 中间态禁用,S11);无标记时 dock 不渲染。
199
199
  - 数据链:messages 端点为 user 消息新增 `fullText` 字段(回填原文)。
200
200
  - 0.1.x 宿主两个槽均不存在,inject 自动 no-op。
201
+
202
+ ### 0.4.1 · dock 修复:统计口径 + 原生观感
203
+
204
+ - **统计修复**:旧版把历史累积的被遮蔽消息全算进「已回撤 N 条」(多次恢复重放后
205
+ 虚高到数百)。现在 dock 只列**手工回撤/删除标记**(排除 compaction checkpoint),
206
+ 每条标记显示其 range 内**当前不可见**的消息数;头部为标记计数。
207
+ - **原生观感**:样式改为官方 GoalDock/TodoDock 同款——36px 高毛玻璃条
208
+ (`--dsw-specific-menu` + `--dsw-elevation-panel` + `--dsw-radius-md`)、
209
+ composer 卡片对齐宽度(`--dsh-composer-dock-inset` 系列)、13px/500 label、
210
+ 28px 圆形切换钮——与目标/计划 dock 视觉完全一致。
211
+
212
+ ### 0.4.2 · dock 像素级对齐 opencode + 回撤生命周期修正
213
+
214
+ - **回撤生命周期**(对齐 opencode clear 语义):restore 的 notice 事件现携带
215
+ `restoresSeq` 引用被恢复的标记——dock 只显示**活跃**标记,已恢复项自动消失
216
+ (此前恢复过的标记永远挂在列表里,计数随之虚高)。
217
+ - **像素级对齐 opencode SessionRevertDock**(读其 dev 分支源码逐项落地):
218
+ 42px 头部(reset 图标 + 13px/500 label + 折叠时第一条预览文本 + 180° 旋转
219
+ chevron)、rounded-xl + 0.5px 边框 + bg-layer-01 容器、24px 行高 + neutral
220
+ 小按钮(非文字链)、items 变化自动折叠、Enter/Space 键盘切换、18px
221
+ sacrificial 空间。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@240xu/dsh-message-ops",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "description": "Roll back, branch, and export any DSH conversation — from the UI or straight from the agent via a message_ops tool. Non-destructive: logs stay append-only and recoverable. 消息回滚/删除/分支/导出四合一,内置 message_ops agent 工具;非破坏语义,日志只追加可恢复。",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/client.js CHANGED
@@ -70,7 +70,9 @@ window.__ModuleLoader__.load({
70
70
  'busy.restore': '恢复中…',
71
71
  'confirm.restore': '恢复',
72
72
  'ack.restore': '我已了解:恢复将以新 seq 重放被遮蔽的消息(原日志不变)',
73
- 'dock.title': '已回撤 {n} 条(可恢复)',
73
+ 'dock.title': '已回撤 {n} 条消息',
74
+ 'dock.expand': '展开回撤列表',
75
+ 'dock.collapse': '折叠回撤列表',
74
76
  'dock.restore': '恢复',
75
77
  'dock.restoring': '恢复中…',
76
78
  'menu.ops': '消息操作',
@@ -120,7 +122,10 @@ window.__ModuleLoader__.load({
120
122
  'done.delete': 'Deleted; reload to apply',
121
123
  'action.reload': 'Reload page',
122
124
  'errorPrefix': 'Operation failed: ',
123
- 'dock.title': '{n} rolled back (restorable)',
125
+ 'dock.title': '{n} messages rolled back',
126
+ 'dock.expand': 'Expand revert list',
127
+ 'dock.collapse': 'Collapse revert list',
128
+ 'dock.shadowedN': '{n} shadowed',
124
129
  'dock.restore': 'Restore',
125
130
  'dock.restoring': 'Restoring…',
126
131
  'menu.ops': 'Message ops',
@@ -675,6 +680,16 @@ window.__ModuleLoader__.load({
675
680
  const INPUT_DOCK_SLOT = 'conversation.input.dock'
676
681
  const INPUT_DOCK_ID = 'message-ops-revert-dock'
677
682
 
683
+ // 0.4.2 活跃标记计算:排除 compaction 标记 + 已被恢复(后续 restore notice
684
+ // 引用其 seq)的标记 —— 对齐 opencode clear 语义:恢复后不再显示为待恢复项。
685
+ function activeMarkers(msgs) {
686
+ const restored = new Set()
687
+ for (const m of msgs || []) {
688
+ if (m && typeof m.restoresSeq === 'number') restored.add(m.restoresSeq)
689
+ }
690
+ return (msgs || []).filter((m) => m.marker && m.sourceKind !== 'compact-checkpoint' && !restored.has(m.seq))
691
+ }
692
+
678
693
  function RevertDock(props) {
679
694
  const { sessionId, useSessions, inputActions } = props
680
695
  const t = (props && props.t) || __t
@@ -684,25 +699,53 @@ window.__ModuleLoader__.load({
684
699
  const summary = sessions && sessions.byId ? sessions.byId[sessionId] : undefined
685
700
  const running = summary ? summary.running === true : false
686
701
  const [open, setOpen] = useState(false)
687
- const [rows, setRows] = useState(null) // [{seq, snippet, role}] replace 标记行
688
- const [shadowedCount, setShadowedCount] = useState(0)
702
+ const [markers, setMarkers] = useState(null) // 活跃回撤标记(已恢复的自动消失)
689
703
  const [restoring, setRestoring] = useState(null)
704
+ const [styleInjected, setStyleInjected] = useState(false)
705
+
706
+ // opencode SessionRevertDock 视觉(v2 布局,DSH 令牌等价映射)
707
+ useEffect(() => {
708
+ if (styleInjected || typeof document === 'undefined') return
709
+ if (document.getElementById('dsh-message-ops-dock-style')) { setStyleInjected(true); return }
710
+ const tag = document.createElement('style')
711
+ tag.id = 'dsh-message-ops-dock-style'
712
+ tag.textContent = [
713
+ // 容器:rounded-xl + 0.5px 边框 + bg-layer-01(M1 修正令牌)
714
+ '.mopsRd{width:100%;overflow:hidden;border-radius:var(--dsw-radius-md,12px);border:.5px solid var(--dsw-alias-border-l1,rgba(128,128,128,.25));background:var(--dsw-alias-bg-layer-1,#1e1e20)}',
715
+ // 头部 42px:图标 + label + 折叠预览 + 旋转 chevron
716
+ '.mopsRdHead{display:flex;height:42px;align-items:center;gap:8px;padding-left:16px;padding-right:8px;cursor:pointer;user-select:none}',
717
+ '.mopsRdIcon{display:inline-flex;color:var(--dsw-alias-label-tertiary,#9a9aa0);flex:none}',
718
+ '.mopsRdLabel{font-size:13px;font-weight:500;line-height:20px;letter-spacing:-.04px;flex:none;cursor:default;color:var(--dsw-alias-label-primary,inherit)}',
719
+ '.mopsRdLabelCollapsed{color:var(--dsw-alias-label-secondary,#a8a8ae)}',
720
+ '.mopsRdPreview{min-width:0;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:13px;font-weight:400;line-height:20px;letter-spacing:-.04px;cursor:default;color:var(--dsw-alias-label-tertiary,#9a9aa0)}',
721
+ '.mopsRdChevron{margin-left:auto;flex:none;display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;border:none;border-radius:999px;background:transparent;color:var(--dsw-alias-label-tertiary,#9a9aa0);cursor:pointer;transition:transform .15s ease}',
722
+ // 列表:24px 行 + neutral 小按钮(非文字链)
723
+ '.mopsRdList{display:flex;flex-direction:column;gap:8px;max-height:168px;overflow-y:auto;padding:1px 16px 12px}',
724
+ '.mopsRdRow{display:flex;height:24px;min-width:0;align-items:center;gap:8px}',
725
+ '.mopsRdRowText{min-width:0;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:13px;font-weight:400;line-height:20px;letter-spacing:-.04px;color:var(--dsw-alias-label-secondary,#a8a8ae)}',
726
+ '.mopsRdRestore{flex:none;font:inherit;font-size:12px;line-height:18px;padding:2px 10px;border-radius:6px;cursor:pointer;color:var(--dsw-alias-label-primary,inherit);background:var(--dsw-alias-interactive-bg-hover,rgba(128,128,128,.12));border:1px solid var(--dsw-alias-border-l1,rgba(128,128,128,.2));opacity:1}',
727
+ '.mopsRdRestore:disabled{opacity:.45;cursor:default}',
728
+ // 折叠时 18px sacrificial 空间(composer 负 lift 重叠)
729
+ '.mopsRdSpacer{height:18px}',
730
+ ].join('')
731
+ document.head.appendChild(tag)
732
+ setStyleInjected(true)
733
+ }, [styleInjected])
690
734
 
691
735
  useEffect(() => {
692
736
  let alive = true
693
- fetch('/api/message-ops/messages?sessionId=' + encodeURIComponent(sessionId))
737
+ const load = () => fetch('/api/message-ops/messages?sessionId=' + encodeURIComponent(sessionId))
694
738
  .then((r) => (r.ok ? r.json() : null))
695
739
  .then((data) => {
696
740
  if (!alive || !data || !data.ok) return
697
- const msgs = data.messages || []
698
- setShadowedCount(msgs.filter((m) => m.visible === false && m.role !== 'system').length)
699
- setRows(msgs.filter((m) => m.marker))
741
+ setMarkers(activeMarkers(data.messages || []))
700
742
  })
701
743
  .catch(() => {})
744
+ load()
702
745
  return () => { alive = false }
703
746
  }, [sessionId])
704
747
 
705
- if (!rows || !rows.length) return null // 无回撤标记 → dock 不渲染
748
+ if (!markers || !markers.length) return null
706
749
 
707
750
  const restoreRow = (row) => {
708
751
  if (restoring != null || running) return
@@ -716,51 +759,52 @@ window.__ModuleLoader__.load({
716
759
  try { data = await res.json() } catch { /* keep {} */ }
717
760
  if (!res.ok || !data.ok) throw new Error(data.error || ('HTTP ' + res.status))
718
761
  notifyDone(t('done.restore', { n: String(data.restoredCount != null ? data.restoredCount : '?'), s: String(data.skipped != null ? data.skipped : 0) }))
719
- // 刷新 dock 数据(重放后标记行状态变化)
762
+ setRestoring(null)
720
763
  const fresh = await fetch('/api/message-ops/messages?sessionId=' + encodeURIComponent(sessionId)).then((r) => (r.ok ? r.json() : null)).catch(() => null)
721
- if (fresh && fresh.ok) {
722
- const msgs = fresh.messages || []
723
- setShadowedCount(msgs.filter((m) => m.visible === false && m.role !== 'system').length)
724
- setRows(msgs.filter((m) => m.marker))
725
- }
764
+ if (fresh && fresh.ok) setMarkers(activeMarkers(fresh.messages || []))
726
765
  })
727
766
  .catch((reason) => {
728
767
  notifyDone(t('errorPrefix') + (reason && reason.message ? reason.message : String(reason)), 'error')
768
+ setRestoring(null)
729
769
  })
730
- .finally(() => setRestoring(null))
731
770
  }
732
771
 
733
- const rowEl = (row) => React.createElement('div', {
734
- key: row.seq,
735
- style: { display: 'flex', alignItems: 'center', gap: 8, padding: '4px 10px',
736
- borderTop: '1px solid var(--dsw-alias-border-l1,rgba(128,128,128,.15))' },
737
- },
738
- React.createElement('span', { style: { flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontSize: 12, opacity: 0.85 } },
739
- '#' + row.seq + ' ' + (row.role || '') + ' · ' + (row.snippet || '')),
772
+ // items 变化自动折叠(opencode createEffect 同款)
773
+ useEffect(() => { setOpen(false) }, [markers && markers.length, markers && markers[0] && markers[0].seq])
774
+
775
+ const headerKbd = (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setOpen((v) => !v) } }
776
+ const label = t('dock.title', { n: String(markers.length) })
777
+ const preview = markers[0] && markers[0].snippet ? markers[0].snippet : ''
778
+
779
+ const rowEl = (row) => React.createElement('div', { key: row.seq, className: 'mopsRdRow' },
780
+ React.createElement('span', { className: 'mopsRdRowText' }, '#' + row.seq + ' · ' + (row.snippet || '')),
740
781
  React.createElement('button', {
741
- type: 'button', 'aria-label': t('dock.restore'), disabled: restoring != null || running,
742
- style: { border: 'none', background: 'transparent', color: 'var(--dsw-alias-link,#8ab4f8)',
743
- font: 'inherit', fontSize: 12, cursor: restoring != null || running ? 'default' : 'pointer',
744
- opacity: restoring != null || running ? 0.5 : 1, padding: '4px 8px', minHeight: 28 },
782
+ type: 'button', className: 'mopsRdRestore', disabled: restoring != null || running,
745
783
  onClick: () => restoreRow(row),
746
784
  }, restoring === row.seq ? t('dock.restoring') : t('dock.restore')),
747
785
  )
748
786
 
749
- return React.createElement('div', {
750
- role: 'region', 'aria-label': t('dock.title', { n: String(shadowedCount) }),
751
- style: { border: '1px solid var(--dsw-alias-border-l1,rgba(128,128,128,.25))', borderRadius: 8, margin: '0 0 8px', overflow: 'hidden',
752
- background: 'var(--dsw-alias-bg-elevated,rgba(128,128,128,.06))' },
753
- },
754
- React.createElement('button', {
755
- type: 'button', 'aria-expanded': open,
756
- style: { display: 'flex', width: '100%', alignItems: 'center', justifyContent: 'space-between',
757
- border: 'none', background: 'transparent', color: 'inherit',
758
- font: 'inherit', fontSize: 12, padding: '6px 10px', cursor: 'pointer', minHeight: 32 },
759
- onClick: () => setOpen((v) => !v),
787
+ const chevron = React.createElement('button', {
788
+ type: 'button', 'aria-label': open ? t('dock.collapse') : t('dock.expand'), 'aria-expanded': open,
789
+ className: 'mopsRdChevron',
790
+ onClick: (e) => { e.stopPropagation(); setOpen((v) => !v) },
791
+ }, React.createElement('svg', { width: 14, height: 14, viewBox: '0 0 16 16', fill: 'none',
792
+ style: { transform: open ? 'rotate(0deg)' : 'rotate(180deg)', transition: 'transform .15s ease' } },
793
+ React.createElement('path', { d: 'M4 6l4 4 4-4', stroke: 'currentColor', strokeWidth: 1.3, strokeLinecap: 'round', strokeLinejoin: 'round' })))
794
+
795
+ return React.createElement('div', { className: 'mopsRd', role: 'region', 'aria-label': label },
796
+ React.createElement('div', {
797
+ className: 'mopsRdHead', role: 'button', tabIndex: 0,
798
+ onClick: () => setOpen((v) => !v), onKeyDown: headerKbd,
760
799
  },
761
- React.createElement('span', null, t('dock.title', { n: String(shadowedCount) })),
762
- React.createElement('span', { style: { opacity: 0.6 } }, open ? '▾' : '▸')),
763
- open ? React.createElement('div', { style: { maxHeight: 180, overflowY: 'auto' } }, rows.map(rowEl)) : null,
800
+ React.createElement('span', { className: 'mopsRdIcon' },
801
+ React.createElement('svg', { width: 15, height: 15, viewBox: '0 0 16 16', fill: 'none' },
802
+ React.createElement('path', { d: 'M2.5 8a5.5 5.5 0 1 0 1.6-3.9', stroke: 'currentColor', strokeWidth: 1.2, strokeLinecap: 'round' }),
803
+ React.createElement('path', { d: 'M2.2 2.8v3h3', stroke: 'currentColor', strokeWidth: 1.2, strokeLinecap: 'round', strokeLinejoin: 'round' }))),
804
+ React.createElement('span', { className: 'mopsRdLabel' + (open ? '' : ' mopsRdLabelCollapsed') }, label),
805
+ open ? null : React.createElement('span', { className: 'mopsRdPreview' }, preview),
806
+ React.createElement('span', { style: { marginLeft: 'auto', flex: 'none' } }, chevron)),
807
+ open ? React.createElement('div', { className: 'mopsRdList' }, markers.map(rowEl)) : null,
764
808
  )
765
809
  }
766
810
 
package/src/ops-core.js CHANGED
@@ -143,9 +143,11 @@ export function applyRestore(session, plan, { flush } = {}) {
143
143
  const notice = `[消息恢复] 重放 seq ${plan.startSeq}..${plan.endSeq} 的 ${plan.replayable.length} 条消息` +
144
144
  (plan.skipped > 0 ? `(另有 ${plan.skipped} 条不可重放事件已跳过)` : "") +
145
145
  `;原区间仍处于遮蔽状态,恢复为重放而非解除遮蔽`;
146
+ // 0.4.2:notice 事件携带 restoresSeq —— dock 据此把被恢复的标记从「活跃回撤」
147
+ // 中移除(对齐 opencode clear 语义:恢复后不再显示为待恢复项)。
146
148
  const noticeEvent = session.append(
147
149
  "system/message",
148
- { message: { role: "system", content: [{ type: "text", text: notice }] } },
150
+ { message: { role: "system", content: [{ type: "text", text: notice }] }, restoresSeq: plan.restoreSeq },
149
151
  { surfaceOp: "append" },
150
152
  );
151
153
  if (noticeEvent && noticeEvent.seq != null) eventSeqs.push(noticeEvent.seq);
@@ -184,6 +184,13 @@ export function listMessages(events) {
184
184
  snippet: text.replace(/\s+/g, " ").trim().slice(0, 160),
185
185
  // 0.4.0 composer 回填:user 消息携带全文(revert 后 setDraft 原文)
186
186
  fullText: e.type === "user/message" ? text : null,
187
+ // 0.4.1 dock:replace 标记携带范围与来源(区分手工回撤 vs compaction)
188
+ range: isReplaceMarker && e.surfaceOp.startSeq != null && e.surfaceOp.endSeq != null
189
+ ? { start: e.surfaceOp.startSeq, end: e.surfaceOp.endSeq } : null,
190
+ sourceKind: (e.data && e.data.source && e.data.source.kind) || null,
191
+ // 0.4.2:恢复标记引用(restore 的 notice 事件带回它所恢复的标记 seq)
192
+ restoresSeq: (e.type === "system/message" && e.data && typeof e.data.restoresSeq === "number")
193
+ ? e.data.restoresSeq : null,
187
194
  time: e.time ?? null,
188
195
  turn: (e.data && e.data.turn) ?? null,
189
196
  });