@240xu/dsh-message-ops 0.4.1 → 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
@@ -208,3 +208,14 @@ MIT
208
208
  (`--dsw-specific-menu` + `--dsw-elevation-panel` + `--dsw-radius-md`)、
209
209
  composer 卡片对齐宽度(`--dsh-composer-dock-inset` 系列)、13px/500 label、
210
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.1",
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,8 +70,9 @@ window.__ModuleLoader__.load({
70
70
  'busy.restore': '恢复中…',
71
71
  'confirm.restore': '恢复',
72
72
  'ack.restore': '我已了解:恢复将以新 seq 重放被遮蔽的消息(原日志不变)',
73
- 'dock.title': '回撤记录 · {n}',
74
- 'dock.shadowedN': '遮蔽 {n} 条',
73
+ 'dock.title': '已回撤 {n} 条消息',
74
+ 'dock.expand': '展开回撤列表',
75
+ 'dock.collapse': '折叠回撤列表',
75
76
  'dock.restore': '恢复',
76
77
  'dock.restoring': '恢复中…',
77
78
  'menu.ops': '消息操作',
@@ -121,7 +122,9 @@ window.__ModuleLoader__.load({
121
122
  'done.delete': 'Deleted; reload to apply',
122
123
  'action.reload': 'Reload page',
123
124
  'errorPrefix': 'Operation failed: ',
124
- 'dock.title': 'Revert history · {n}',
125
+ 'dock.title': '{n} messages rolled back',
126
+ 'dock.expand': 'Expand revert list',
127
+ 'dock.collapse': 'Collapse revert list',
125
128
  'dock.shadowedN': '{n} shadowed',
126
129
  'dock.restore': 'Restore',
127
130
  'dock.restoring': 'Restoring…',
@@ -677,6 +680,16 @@ window.__ModuleLoader__.load({
677
680
  const INPUT_DOCK_SLOT = 'conversation.input.dock'
678
681
  const INPUT_DOCK_ID = 'message-ops-revert-dock'
679
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
+
680
693
  function RevertDock(props) {
681
694
  const { sessionId, useSessions, inputActions } = props
682
695
  const t = (props && props.t) || __t
@@ -686,27 +699,34 @@ window.__ModuleLoader__.load({
686
699
  const summary = sessions && sessions.byId ? sessions.byId[sessionId] : undefined
687
700
  const running = summary ? summary.running === true : false
688
701
  const [open, setOpen] = useState(false)
689
- const [markers, setMarkers] = useState(null) // [{seq, snippet, range}] 非 compaction 的回撤标记
690
- const [hiddenByMarker, setHiddenByMarker] = useState({}) // markerSeq -> 当前不可见条数
702
+ const [markers, setMarkers] = useState(null) // 活跃回撤标记(已恢复的自动消失)
691
703
  const [restoring, setRestoring] = useState(null)
692
704
  const [styleInjected, setStyleInjected] = useState(false)
693
705
 
694
- // 官方 dock 视觉(GoalDock/TodoDock 同款值,一次注入)
706
+ // opencode SessionRevertDock 视觉(v2 布局,DSH 令牌等价映射)
695
707
  useEffect(() => {
696
708
  if (styleInjected || typeof document === 'undefined') return
697
709
  if (document.getElementById('dsh-message-ops-dock-style')) { setStyleInjected(true); return }
698
710
  const tag = document.createElement('style')
699
711
  tag.id = 'dsh-message-ops-dock-style'
700
712
  tag.textContent = [
701
- '.mopsDock{box-sizing:border-box;width:calc(100% - var(--dsh-composer-side-clearance)*2 - var(--dsh-composer-dock-inset)*4);margin:0 auto 8px}',
702
- '.mopsDockBar{isolation:isolate;box-sizing:border-box;width:100%;max-width:calc(var(--dsh-composer-card-max-width) - 4 * var(--dsh-composer-dock-inset));border-radius:var(--dsw-radius-md);min-height:36px;box-shadow:var(--dsw-elevation-panel);border:0;align-items:center;gap:10px;margin:0 auto;padding:4px 5px 4px 12px;display:flex;position:relative}',
703
- '.mopsDockBar:before{z-index:-1;border-radius:inherit;background:var(--dsw-specific-menu);backdrop-filter:var(--dsw-menu-backdrop-filter);content:"";pointer-events:none;position:absolute;inset:0}',
704
- '.mopsDockLabel{color:var(--dsw-alias-label-primary);flex:none;font-size:13px;font-weight:500;line-height:24px}',
705
- '.mopsDockToggle{width:28px;height:28px;color:var(--dsw-alias-label-tertiary);cursor:pointer;background:0 0;border:none;border-radius:999px;justify-content:center;align-items:center;padding:0;display:inline-flex;font:inherit}',
706
- '.mopsDockRow{display:flex;align-items:center;gap:8px;padding:4px 12px;border-top:1px solid var(--dsw-alias-border-l1,rgba(128,128,128,.15));position:relative}',
707
- '.mopsDockRowText{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px;opacity:.85;color:var(--dsw-alias-label-primary)}',
708
- '.mopsDockRestore{border:none;background:transparent;color:var(--dsw-alias-link,#8ab4f8);font:inherit;font-size:12px;cursor:pointer;padding:4px 8px;min-height:28px;opacity:1}',
709
- '.mopsDockList{max-height:180px;overflow-y:auto;padding-bottom:4px}',
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}',
710
730
  ].join('')
711
731
  document.head.appendChild(tag)
712
732
  setStyleInjected(true)
@@ -718,25 +738,14 @@ window.__ModuleLoader__.load({
718
738
  .then((r) => (r.ok ? r.json() : null))
719
739
  .then((data) => {
720
740
  if (!alive || !data || !data.ok) return
721
- const msgs = data.messages || []
722
- // 仅手工回撤/删除产生的标记(排除 compaction checkpoint)
723
- const marks = msgs.filter((m) => m.marker && m.sourceKind !== 'compact-checkpoint')
724
- setMarkers(marks)
725
- // 每标记的当前不可见条数:落在该标记 range 内、当前不可见的 user/assistant 消息
726
- const hidden = {}
727
- for (const mk of marks) {
728
- if (!mk.range) { hidden[mk.seq] = null; continue }
729
- hidden[mk.seq] = msgs.filter((m) => m.visible === false && m.role !== 'system'
730
- && m.seq >= mk.range.start && m.seq <= mk.range.end).length
731
- }
732
- setHiddenByMarker(hidden)
741
+ setMarkers(activeMarkers(data.messages || []))
733
742
  })
734
743
  .catch(() => {})
735
744
  load()
736
745
  return () => { alive = false }
737
746
  }, [sessionId])
738
747
 
739
- if (!markers || !markers.length) return null // 无手工回撤标记 → dock 不渲染
748
+ if (!markers || !markers.length) return null
740
749
 
741
750
  const restoreRow = (row) => {
742
751
  if (restoring != null || running) return
@@ -751,20 +760,8 @@ window.__ModuleLoader__.load({
751
760
  if (!res.ok || !data.ok) throw new Error(data.error || ('HTTP ' + res.status))
752
761
  notifyDone(t('done.restore', { n: String(data.restoredCount != null ? data.restoredCount : '?'), s: String(data.skipped != null ? data.skipped : 0) }))
753
762
  setRestoring(null)
754
- // 重放后刷新标记与计数
755
763
  const fresh = await fetch('/api/message-ops/messages?sessionId=' + encodeURIComponent(sessionId)).then((r) => (r.ok ? r.json() : null)).catch(() => null)
756
- if (fresh && fresh.ok) {
757
- const msgs = fresh.messages || []
758
- const marks = msgs.filter((m) => m.marker && m.sourceKind !== 'compact-checkpoint')
759
- setMarkers(marks)
760
- const hidden = {}
761
- for (const mk of marks) {
762
- if (!mk.range) { hidden[mk.seq] = null; continue }
763
- hidden[mk.seq] = msgs.filter((m) => m.visible === false && m.role !== 'system'
764
- && m.seq >= mk.range.start && m.seq <= mk.range.end).length
765
- }
766
- setHiddenByMarker(hidden)
767
- }
764
+ if (fresh && fresh.ok) setMarkers(activeMarkers(fresh.messages || []))
768
765
  })
769
766
  .catch((reason) => {
770
767
  notifyDone(t('errorPrefix') + (reason && reason.message ? reason.message : String(reason)), 'error')
@@ -772,25 +769,42 @@ window.__ModuleLoader__.load({
772
769
  })
773
770
  }
774
771
 
775
- const rowEl = (row) => React.createElement('div', { key: row.seq, className: 'mopsDockRow' },
776
- React.createElement('span', { className: 'mopsDockRowText' },
777
- '#' + row.seq + (hiddenByMarker[row.seq] != null ? ' · ' + t('dock.shadowedN', { n: String(hiddenByMarker[row.seq]) }) : '')
778
- + ' · ' + (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 || '')),
779
781
  React.createElement('button', {
780
- type: 'button', 'aria-label': t('dock.restore'), disabled: restoring != null || running,
781
- className: 'mopsDockRestore',
782
+ type: 'button', className: 'mopsRdRestore', disabled: restoring != null || running,
782
783
  onClick: () => restoreRow(row),
783
784
  }, restoring === row.seq ? t('dock.restoring') : t('dock.restore')),
784
785
  )
785
786
 
786
- return React.createElement('div', { className: 'mopsDock', role: 'region', 'aria-label': t('dock.title', { n: String(markers.length) }) },
787
- React.createElement('div', { className: 'mopsDockBar' },
788
- React.createElement('span', { className: 'mopsDockLabel' }, t('dock.title', { n: String(markers.length) })),
789
- React.createElement('button', {
790
- type: 'button', 'aria-expanded': open, className: 'mopsDockToggle',
791
- onClick: () => setOpen((v) => !v),
792
- }, open ? '▾' : '▸')),
793
- open ? React.createElement('div', { className: 'mopsDockList' }, markers.map(rowEl)) : null,
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,
799
+ },
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,
794
808
  )
795
809
  }
796
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);
@@ -188,6 +188,9 @@ export function listMessages(events) {
188
188
  range: isReplaceMarker && e.surfaceOp.startSeq != null && e.surfaceOp.endSeq != null
189
189
  ? { start: e.surfaceOp.startSeq, end: e.surfaceOp.endSeq } : null,
190
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,
191
194
  time: e.time ?? null,
192
195
  turn: (e.data && e.data.turn) ?? null,
193
196
  });