@240xu/dsh-message-ops 0.2.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/src/client.js ADDED
@@ -0,0 +1,560 @@
1
+ /* global window, document, fetch, navigator */
2
+ /**
3
+ * dsh-message-ops 客户端:会话头部按钮 + 共享操作对话框 + 侧栏行菜单项。
4
+ *
5
+ * 一个对话框承载三种操作(回滚 / 删除 / 分支):
6
+ * - 拉取 GET /api/message-ops/messages 展示消息级列表(可见性已标注)
7
+ * - 回滚/删除为破坏性操作:需勾选风险确认(回滚仍可通过再次发送恢复,
8
+ * 删除为遮蔽语义;日志永远 append-only,原事件保留)
9
+ * - 分支为唯一非破坏操作:无需确认,成功后提示新会话 id 并刷新列表
10
+ * 回滚/删除成功后整页刷新(surface 变化需要会话重渲染)。
11
+ *
12
+ * 结构照抄 @huanlin/dsh-plugin-session-delete 的成熟模式:slots.inject
13
+ * 延迟注册(宿主晚声明也不白屏)、locale 服务 zh/en、DOM 注入侧栏菜单。
14
+ */
15
+ window.__ModuleLoader__.load({
16
+ id: '@240xu/dsh-message-ops',
17
+ factory: (require) => {
18
+ const React = require('react')
19
+ const { useCallback, useEffect, useState } = React
20
+ const { Modal } = require('@deepseek-ai/dsh-client-ui-primitives')
21
+
22
+ const SLOT = 'conversation.session.header.actions'
23
+ const ROW_ID = 'message-ops'
24
+ const OVERLAY_SLOT = 'shell.overlay'
25
+ const DIALOG_ID = 'message-ops-dialog'
26
+ const EVENT = 'dsh-message-ops:open'
27
+ const NS = 'dsh-message-ops'
28
+
29
+ const zhDict = {
30
+ 'button.title': '消息操作(回滚/删除/分支)',
31
+ 'dialog.title': '消息操作',
32
+ 'dialog.cancel': '关闭',
33
+ 'dialog.loading': '正在读取消息列表…',
34
+ 'dialog.loadFail': '消息列表读取失败:',
35
+ 'dialog.empty': '该会话没有可操作的消息。',
36
+ 'dialog.session': '会话:',
37
+ 'dialog.runningWarn': '⚠ 会话正在运行:请先停止该会话再执行回滚/删除/分支。',
38
+ 'dialog.pick': '选择一条消息:',
39
+ 'dialog.invisible': '(已遮蔽,仅日志可见)',
40
+ 'dialog.showMore': '显示更多(剩余 {n} 条)',
41
+ 'dialog.more': '…(仅显示最近 200 条,共 {n} 条)',
42
+ 'op.revert': '回滚到此条(含)之后全部移除',
43
+ 'op.revertDesc': '遮蔽所选消息及其后所有可见内容;日志 append-only,可通过重新发送恢复语境。',
44
+ 'op.delete': '仅删除这一条',
45
+ 'op.deleteDesc': '仅遮蔽所选的一条消息;其余内容保持不变。',
46
+ 'op.branch': '从此条分支为新会话',
47
+ 'op.branchDesc': '复制所选消息(含)之前的全部事件为新会话(原会话不变,非破坏操作)。',
48
+ 'ack.revert': '我已了解:回滚会遮蔽所选消息及其后的全部内容',
49
+ 'ack.delete': '我已了解:将遮蔽这一条消息(日志中仍保留)',
50
+ 'confirm.revert': '回滚',
51
+ 'confirm.delete': '删除',
52
+ 'confirm.branch': '分支',
53
+ 'busy.revert': '回滚中…',
54
+ 'busy.delete': '删除中…',
55
+ 'busy.branch': '分支中…',
56
+ 'done.branch': '分支完成:新会话 {id}(列表刷新后可见)',
57
+ 'done.revert': '回滚完成,页面即将刷新…',
58
+ 'done.delete': '删除完成,页面即将刷新…',
59
+ 'errorPrefix': '操作失败:',
60
+ 'menu.ops': '消息操作',
61
+ }
62
+
63
+ const enDict = {
64
+ 'button.title': 'Message ops (revert / delete / branch)',
65
+ 'dialog.title': 'Message ops',
66
+ 'dialog.cancel': 'Close',
67
+ 'dialog.loading': 'Loading messages…',
68
+ 'dialog.loadFail': 'Failed to load messages: ',
69
+ 'dialog.empty': 'No operable messages in this session.',
70
+ 'dialog.session': 'Session: ',
71
+ 'dialog.runningWarn': '⚠ Session is running: stop it before revert / delete / branch.',
72
+ 'dialog.pick': 'Pick a message:',
73
+ 'dialog.invisible': ' (shadowed, log-only)',
74
+ 'dialog.showMore': 'Show more ({n} older)',
75
+ 'dialog.more': '…(showing latest 200 of {n})',
76
+ 'op.revert': 'Revert: remove this message and everything after',
77
+ 'op.revertDesc': 'Shadows the picked message and all later visible content; the log stays append-only so context can be restored by re-sending.',
78
+ 'op.delete': 'Delete only this message',
79
+ 'op.deleteDesc': 'Shadows only the picked message; everything else stays.',
80
+ 'op.branch': 'Branch into a new session from here',
81
+ 'op.branchDesc': 'Copies everything up to and including the picked message into a new session (original untouched, non-destructive).',
82
+ 'ack.revert': 'I understand: revert shadows the picked message and everything after it',
83
+ 'ack.delete': 'I understand: this message will be shadowed (kept in the log)',
84
+ 'confirm.revert': 'Revert',
85
+ 'confirm.delete': 'Delete',
86
+ 'confirm.branch': 'Branch',
87
+ 'busy.revert': 'Reverting…',
88
+ 'busy.delete': 'Deleting…',
89
+ 'busy.branch': 'Branching…',
90
+ 'done.branch': 'Branched: new session {id} (visible after list refresh)',
91
+ 'done.revert': 'Reverted; reloading…',
92
+ 'done.delete': 'Deleted; reloading…',
93
+ 'errorPrefix': 'Operation failed: ',
94
+ 'menu.ops': 'Message ops',
95
+ }
96
+
97
+ var __locale = null
98
+ var __sessionsSvc = null
99
+
100
+ function localeFallbackLang() {
101
+ if (typeof navigator === 'undefined') return 'zh'
102
+ for (const tag of (navigator.languages || []).concat([navigator.language])) {
103
+ const primary = String(tag || '').toLowerCase().split('-')[0]
104
+ if (primary === 'zh' || primary === 'en') return primary
105
+ }
106
+ return 'zh'
107
+ }
108
+
109
+ function __t(key, vars) {
110
+ let text = key
111
+ if (__locale && typeof __locale.translate === 'function') {
112
+ const translated = __locale.translate(NS, key)
113
+ if (typeof translated === 'string' && translated !== key) text = translated
114
+ }
115
+ if (text === key) text = (localeFallbackLang() === 'en' ? enDict : zhDict)[key] || key
116
+ if (vars) {
117
+ for (const k of Object.keys(vars)) text = text.split('{' + k + '}').join(String(vars[k]))
118
+ }
119
+ return text
120
+ }
121
+
122
+ function useLocaleRevision() {
123
+ const [, setRev] = useState(0)
124
+ useEffect(() => {
125
+ if (!__locale || typeof __locale.subscribe !== 'function') return undefined
126
+ return __locale.subscribe(() => setRev((v) => v + 1))
127
+ }, [])
128
+ }
129
+
130
+ // --- styles(仅 --dsw-* 主题令牌) ----------------------------------------
131
+ const btnStyle = {
132
+ display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
133
+ width: 28, height: 28, padding: 0, border: 'none', borderRadius: 6,
134
+ background: 'transparent',
135
+ color: 'var(--dsw-alias-label-tertiary, #8a8a8e)', cursor: 'pointer', flex: 'none',
136
+ }
137
+ const metaStyle = {
138
+ color: 'var(--dsw-alias-label-secondary, #8a8a8e)', fontSize: 13,
139
+ lineHeight: '20px', margin: '0 0 8px', overflow: 'hidden', textOverflow: 'ellipsis',
140
+ }
141
+ const warnStyle = {
142
+ color: 'var(--dsw-alias-state-warn-primary, #f5a524)', fontSize: 13,
143
+ lineHeight: '20px', margin: '0 0 10px',
144
+ }
145
+ const errStyle = {
146
+ color: 'var(--dsw-alias-state-error-primary, #e5484d)', fontSize: 12,
147
+ lineHeight: '16px', marginTop: 8,
148
+ }
149
+ const statusStyle = {
150
+ color: 'var(--dsw-alias-label-secondary, #8a8a8e)', fontSize: 12,
151
+ lineHeight: '16px', marginTop: 8,
152
+ }
153
+ const listStyle = {
154
+ maxHeight: 260, overflowY: 'auto', border: '1px solid var(--dsw-alias-border-l2, rgba(128,128,128,.35))',
155
+ borderRadius: 8, margin: '8px 0 4px', padding: '4px 0',
156
+ }
157
+ const rowStyle = {
158
+ display: 'flex', alignItems: 'flex-start', gap: 8,
159
+ padding: '4px 10px', fontSize: 13, lineHeight: '18px', cursor: 'pointer',
160
+ }
161
+ const descStyle = {
162
+ color: 'var(--dsw-alias-label-secondary, #8a8a8e)', fontSize: 12,
163
+ lineHeight: '17px', margin: '6px 0 0',
164
+ }
165
+ const cancelBtnStyle = {
166
+ padding: '6px 14px', borderRadius: 8,
167
+ border: '1px solid var(--dsw-alias-border-l2, rgba(128,128,128,.4))',
168
+ background: 'transparent', color: 'var(--dsw-alias-label-primary, inherit)',
169
+ fontSize: 13, cursor: 'pointer', marginRight: 8,
170
+ }
171
+ const primaryBtnStyle = {
172
+ padding: '6px 14px', borderRadius: 8, border: 'none',
173
+ background: 'var(--dsw-alias-brand-primary, #4d6bfe)', color: '#fff',
174
+ fontSize: 13, cursor: 'pointer',
175
+ }
176
+ const dangerBtnStyle = {
177
+ padding: '6px 14px', borderRadius: 8,
178
+ border: '1px solid var(--dsw-alias-state-error-primary, #e5484d)',
179
+ background: 'var(--dsw-alias-state-error-primary, #e5484d)',
180
+ color: '#fff', fontSize: 13, cursor: 'pointer',
181
+ }
182
+ const optStyle = {
183
+ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, lineHeight: '20px',
184
+ color: 'var(--dsw-alias-label-primary, inherit)', marginTop: 10,
185
+ }
186
+
187
+ // 头部按钮图标:三叉分支(git-branch 风格,16x16)。
188
+ const BRANCH_PATH = 'M5 2.5a2 2 0 1 1-.9 3.78v3.44a2 2 0 1 1-1.2 0V6.28A2 2 0 1 1 5 2.5Zm0 1.2a.8.8 0 1 0 0 1.6.8.8 0 0 0 0-1.6ZM3.5 11.5a.8.8 0 1 0 1.6 0 .8.8 0 0 0-1.6 0ZM12.5 2.5a2 2 0 0 1 .6 3.9v.85c0 1.9-1.54 3.44-3.44 3.44H8.03a2 2 0 1 1 0-1.2h1.63a2.24 2.24 0 0 0 2.24-2.24V6.4a2 2 0 0 1 .6-3.9Zm0 1.2a.8.8 0 1 0 0 1.6.8.8 0 0 0 0-1.6Z'
189
+
190
+ function BranchIcon() {
191
+ return React.createElement('svg', {
192
+ width: 16, height: 16, viewBox: '0 0 16 16', fill: 'none',
193
+ xmlns: 'http://www.w3.org/2000/svg',
194
+ }, React.createElement('path', { d: BRANCH_PATH, fill: 'currentColor' }))
195
+ }
196
+
197
+ // --- 共享对话框 -------------------------------------------------------------
198
+ // 模块级缓存最近一次 target,事件驱动打开。
199
+ const MAX_RENDER = 200
200
+ const PAGE_SIZE = 50 // 低成本分批渲染:先渲 50 条,按钮渐进展开
201
+
202
+ function OpsDialog(props) {
203
+ const t = (props && props.t) || __t
204
+ useLocaleRevision()
205
+ const [target, setTarget] = useState(null) // {sessionId, title, running}
206
+ const [state, setState] = useState('idle') // idle|loading|ready|busy|done
207
+ const [messages, setMessages] = useState([])
208
+ const [sessionMeta, setSessionMeta] = useState(null)
209
+ const [picked, setPicked] = useState(null) // seq
210
+ const [mode, setMode] = useState(null) // revert|delete|branch
211
+ const [acknowledged, setAcknowledged] = useState(false)
212
+ const [busyMsg, setBusyMsg] = useState('')
213
+ const [renderLimit, setRenderLimit] = useState(PAGE_SIZE) // 已展开的渲染条数
214
+ const [doneMsg, setDoneMsg] = useState('')
215
+ const [error, setError] = useState(null)
216
+
217
+ useEffect(() => {
218
+ const handler = (e) => {
219
+ const d = e && e.detail ? e.detail : {}
220
+ setTarget({ sessionId: d.sessionId || null, title: d.title || null, running: d.running === true })
221
+ setState('loading')
222
+ setMessages([]); setSessionMeta(null); setPicked(null)
223
+ setMode(null); setAcknowledged(false); setError(null); setDoneMsg('')
224
+ setRenderLimit(PAGE_SIZE)
225
+ }
226
+ window.addEventListener(EVENT, handler)
227
+ return () => window.removeEventListener(EVENT, handler)
228
+ }, [])
229
+
230
+ useEffect(() => {
231
+ if (state !== 'loading' || !target || !target.sessionId) return
232
+ let cancelled = false
233
+ fetch('/api/message-ops/messages?sessionId=' + encodeURIComponent(target.sessionId))
234
+ .then((r) => r.json())
235
+ .then((data) => {
236
+ if (cancelled) return
237
+ if (!data || !data.ok) throw new Error(data && data.error ? data.error : `HTTP ${data && data.status}`)
238
+ setMessages(data.messages || [])
239
+ setSessionMeta(data.session || null)
240
+ if (data.running === true) setTarget((prev) => ({ ...(prev || {}), running: true }))
241
+ setState('ready')
242
+ })
243
+ .catch((reason) => {
244
+ if (cancelled) return
245
+ setError(t('dialog.loadFail') + (reason && reason.message ? reason.message : String(reason)))
246
+ setState('ready')
247
+ })
248
+ return () => { cancelled = true }
249
+ }, [state, target, t])
250
+
251
+ const close = useCallback(() => {
252
+ if (state === 'busy') return
253
+ setTarget(null); setError(null)
254
+ }, [state])
255
+
256
+ const pick = useCallback((m) => {
257
+ if (state === 'busy') return
258
+ setPicked(m.seq)
259
+ setMode(null); setAcknowledged(false); setError(null)
260
+ }, [state])
261
+
262
+ const chooseMode = useCallback((m) => {
263
+ if (picked == null || state === 'busy') return
264
+ setMode(m); setAcknowledged(false); setError(null)
265
+ }, [picked, state])
266
+
267
+ const run = useCallback(() => {
268
+ if (state === 'busy' || picked == null || !mode) return
269
+ if (mode !== 'branch' && !acknowledged) return
270
+ setBusyMsg(t(mode === 'revert' ? 'busy.revert' : mode === 'delete' ? 'busy.delete' : 'busy.branch'))
271
+ setState('busy'); setError(null)
272
+ const path = mode === 'revert' ? 'revert' : mode === 'delete' ? 'delete' : 'branch'
273
+ const body = mode === 'branch'
274
+ ? { sessionId: target.sessionId, upToSeq: picked }
275
+ : { sessionId: target.sessionId, seq: picked }
276
+ fetch('/api/message-ops/' + path, {
277
+ method: 'POST',
278
+ headers: { 'content-type': 'application/json' },
279
+ body: JSON.stringify(body),
280
+ })
281
+ .then(async (res) => {
282
+ let data = {}
283
+ try { data = await res.json() } catch { /* keep {} */ }
284
+ if (!res.ok || !data.ok) throw new Error(data.error || `HTTP ${res.status}`)
285
+ if (mode === 'branch') {
286
+ setDoneMsg(t('done.branch', { id: data.newId || '' }))
287
+ setBusyMsg('')
288
+ setState('done')
289
+ if (__sessionsSvc && typeof __sessionsSvc.refreshList === 'function') {
290
+ try { Promise.resolve(__sessionsSvc.refreshList()).catch(() => {}) } catch { /* ignore */ }
291
+ }
292
+ } else {
293
+ setDoneMsg(t(mode === 'revert' ? 'done.revert' : 'done.delete'))
294
+ setBusyMsg('')
295
+ setState('done')
296
+ setTimeout(() => { window.location.reload() }, 900)
297
+ }
298
+ })
299
+ .catch((reason) => {
300
+ setBusyMsg('')
301
+ setState('ready')
302
+ setError(t('errorPrefix') + (reason && reason.message ? reason.message : String(reason)))
303
+ })
304
+ }, [state, picked, mode, acknowledged, target, t])
305
+
306
+ if (!target) return null
307
+
308
+ const visible = messages.filter((m) => m.visible !== false)
309
+ const shownBase = visible.slice(-MAX_RENDER)
310
+ const hiddenCount = visible.length - shownBase.length
311
+ // 分批渲染(评审 M1):只渲染 renderLimit 条,更早的留给「显示更多」
312
+ // 渐进展开,避免 200 行 radio 列表一次进 DOM。
313
+ const shown = shownBase.slice(-renderLimit)
314
+ const pagedCount = shownBase.length - shown.length
315
+ const pickedMsg = picked != null ? messages.find((m) => m.seq === picked) : null
316
+
317
+ let body
318
+ if (state === 'loading') {
319
+ body = React.createElement('div', { key: 'load', style: statusStyle }, t('dialog.loading'))
320
+ } else if (!messages.length) {
321
+ body = React.createElement('div', { key: 'empty', style: statusStyle }, t('dialog.empty'))
322
+ } else {
323
+ body = React.createElement(React.Fragment, null, [
324
+ target.running ? React.createElement('div', { key: 'warn', style: warnStyle }, t('dialog.runningWarn')) : null,
325
+ React.createElement('div', { key: 'pick', style: metaStyle }, t('dialog.pick')),
326
+ React.createElement('div', { key: 'list', style: listStyle },
327
+ hiddenCount > 0
328
+ ? React.createElement('div', { key: 'more', style: { ...rowStyle, cursor: 'default', color: 'var(--dsw-alias-label-secondary,#8a8a8e)' } },
329
+ t('dialog.more', { n: String(visible.length) }))
330
+ : null,
331
+ pagedCount > 0
332
+ ? React.createElement('button', {
333
+ key: 'show-more', type: 'button',
334
+ style: { ...rowStyle, border: 'none', width: '100%', color: 'var(--dsw-alias-label-secondary,#8a8a8e)', cursor: 'pointer' },
335
+ onClick: () => setRenderLimit((l) => Math.min(l + PAGE_SIZE, MAX_RENDER)),
336
+ }, t('dialog.showMore', { n: String(pagedCount) }))
337
+ : null,
338
+ shown.map((m, i) => React.createElement('label', {
339
+ key: m.seq + '-' + i, style: {
340
+ ...rowStyle,
341
+ background: picked === m.seq ? 'var(--dsw-alias-interactive-bg-hover,rgba(128,128,128,.14))' : 'transparent',
342
+ },
343
+ },
344
+ React.createElement('input', {
345
+ type: 'radio', name: 'dsh-message-ops-pick', checked: picked === m.seq,
346
+ onChange: () => pick(m), disabled: state === 'busy',
347
+ }),
348
+ React.createElement('span', {
349
+ style: {
350
+ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
351
+ color: m.visible === false ? 'var(--dsw-alias-label-tertiary,#b0b0b4)' : 'inherit',
352
+ },
353
+ },
354
+ '#' + m.seq + ' ' + (m.role || '') + ' · ' + (m.snippet || '') + (m.visible === false ? t('dialog.invisible') : '')),
355
+ )),
356
+ ),
357
+ pickedMsg ? React.createElement('div', { key: 'ops' },
358
+ ['revert', 'delete', 'branch'].map((m, i) => React.createElement('div', { key: m, style: { marginTop: i === 0 ? 8 : 4 } },
359
+ React.createElement('label', { style: optStyle },
360
+ React.createElement('input', {
361
+ type: 'radio', name: 'dsh-message-ops-mode', checked: mode === m,
362
+ onChange: () => chooseMode(m), disabled: state === 'busy',
363
+ }),
364
+ t('op.' + m)),
365
+ mode === m ? React.createElement('div', { style: descStyle }, t('op.' + m + 'Desc')) : null,
366
+ ))) : null,
367
+ mode && mode !== 'branch'
368
+ ? React.createElement('label', { key: 'ack', style: optStyle },
369
+ React.createElement('input', {
370
+ type: 'checkbox', checked: acknowledged, disabled: state === 'busy',
371
+ onChange: (e) => setAcknowledged(e.target.checked),
372
+ }),
373
+ t(mode === 'revert' ? 'ack.revert' : 'ack.delete'))
374
+ : null,
375
+ ])
376
+ }
377
+
378
+ const canRun = state !== 'busy' && state !== 'loading' && picked != null && mode != null
379
+ && (mode === 'branch' || acknowledged)
380
+ const confirmLabel = busyMsg || (mode ? t('confirm.' + mode) : t('dialog.cancel'))
381
+
382
+ return React.createElement(Modal, {
383
+ open: true,
384
+ onClose: close,
385
+ title: t('dialog.title'),
386
+ closeLabel: t('dialog.cancel'),
387
+ description: t('dialog.session') + (target.title || target.sessionId || ''),
388
+ footer: [
389
+ React.createElement('button', {
390
+ key: 'cancel', type: 'button', disabled: state === 'busy',
391
+ onClick: close, style: { ...cancelBtnStyle, ...(state === 'busy' ? { opacity: 0.5, cursor: 'default' } : {}) },
392
+ }, t('dialog.cancel')),
393
+ React.createElement('button', {
394
+ key: 'confirm', type: 'button', disabled: !canRun,
395
+ onClick: run,
396
+ style: {
397
+ ...(mode === 'delete' || mode === 'revert' ? dangerBtnStyle : primaryBtnStyle),
398
+ ...(!canRun ? { opacity: 0.5, cursor: 'default' } : {}),
399
+ },
400
+ }, confirmLabel),
401
+ ],
402
+ }, [
403
+ React.createElement('div', { key: 'meta', style: metaStyle },
404
+ t('dialog.session'), target.sessionId || ''),
405
+ body,
406
+ doneMsg ? React.createElement('div', { key: 'done', style: statusStyle }, doneMsg) : null,
407
+ error ? React.createElement('div', { key: 'err', style: errStyle, role: 'alert' }, error) : null,
408
+ ])
409
+ }
410
+
411
+ // --- 头部按钮 ---------------------------------------------------------------
412
+
413
+ function OpsButton(props) {
414
+ const { sessionId, useSessions } = props
415
+ const t = (props && props.t) || __t
416
+ useLocaleRevision()
417
+ const sessions = useSessions ? useSessions((s) => s) : undefined
418
+ const summary = sessions && sessions.byId ? sessions.byId[sessionId] : undefined
419
+ const running = summary ? summary.running === true : false
420
+
421
+ const openDialog = useCallback(() => {
422
+ window.dispatchEvent(new CustomEvent(EVENT, {
423
+ detail: { sessionId, title: summary && summary.title ? summary.title : null, running },
424
+ }))
425
+ }, [sessionId, summary, running])
426
+
427
+ return React.createElement('button', {
428
+ type: 'button', title: t('button.title'), 'aria-label': t('button.title'),
429
+ style: btnStyle, onClick: openDialog,
430
+ }, React.createElement(BranchIcon))
431
+ }
432
+
433
+ // --- 侧栏行菜单注入(DOM 级,同 session-delete 模式) ------------------------
434
+
435
+ function findOpenSessionRow() {
436
+ var rows = document.querySelectorAll('[class*=sessionRow]')
437
+ for (var i = 0; i < rows.length; i++) {
438
+ if (String(rows[i].className || '').indexOf('menuOpen') >= 0) return rows[i]
439
+ }
440
+ return null
441
+ }
442
+
443
+ function ensureSidebarOpsItem() {
444
+ var menu = document.querySelector('[role=menu]')
445
+ if (!menu) return
446
+ if (menu.querySelector('[data-dsh-message-ops]')) return
447
+ var row = findOpenSessionRow()
448
+ if (!row) return
449
+ var item = document.createElement('button')
450
+ item.type = 'button'
451
+ item.setAttribute('role', 'menuitem')
452
+ item.setAttribute('data-dsh-message-ops', '1')
453
+ item.style.cssText = [
454
+ 'display:flex', 'align-items:center', 'gap:8px', 'width:100%',
455
+ 'padding:6px 12px', 'border:none', 'background:transparent',
456
+ 'color:var(--dsw-alias-label-primary,inherit)',
457
+ 'font:inherit', 'font-size:13px', 'line-height:20px',
458
+ 'text-align:left', 'border-radius:6px', 'cursor:pointer',
459
+ ].join(';')
460
+ item.innerHTML = '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" style="flex:none"><path d="' + BRANCH_PATH + '" fill="currentColor"/></svg><span></span>'
461
+ item.querySelector('span').textContent = __t('menu.ops')
462
+ item.addEventListener('mouseenter', function () {
463
+ item.style.background = 'var(--dsw-alias-interactive-bg-hover,rgba(128,128,128,.14))'
464
+ })
465
+ item.addEventListener('mouseleave', function () { item.style.background = 'transparent' })
466
+ item.addEventListener('click', function () {
467
+ var titleEl = row.querySelector('[class*=title]')
468
+ var title = titleEl ? String(titleEl.innerText || '').trim() : ''
469
+ if (!title) return
470
+ window.dispatchEvent(new CustomEvent(EVENT, { detail: { title: title } }))
471
+ })
472
+ var sep = document.createElement('div')
473
+ sep.style.cssText = 'height:1px;margin:4px 8px;background:var(--dsw-alias-border-l1,rgba(128,128,128,.2))'
474
+ menu.appendChild(sep)
475
+ menu.appendChild(item)
476
+ }
477
+
478
+ function refreshSidebarOpsLabel() {
479
+ const items = document.querySelectorAll('[data-dsh-message-ops]')
480
+ for (let i = 0; i < items.length; i++) {
481
+ const span = items[i].querySelector('span')
482
+ if (span) span.textContent = __t('menu.ops')
483
+ }
484
+ }
485
+
486
+ function installSidebarOps() {
487
+ if (window.__dshMessageOpsSidebarInstalled) return
488
+ window.__dshMessageOpsSidebarInstalled = true
489
+ try { ensureSidebarOpsItem() } catch (e) { /* never crash the UI */ }
490
+ // 观察范围收窄(评审 M2):仍需 observe document.body(会话行菜单由宿主
491
+ // React 动态渲染在侧栏任意挂载点,安装期无法锚定稳定容器),但回调改为
492
+ // 精确过滤——只有新增节点本身就是(或包含)[role=menu] 时才调度探测,
493
+ // 其余海量 childList mutation(聊天流渲染、流式 chunk 等)零探测成本;
494
+ // 同帧多次命中经 setTimeout 合并为一次探测。
495
+ var pending = false
496
+ function scheduleEnsure() {
497
+ if (pending) return
498
+ pending = true
499
+ setTimeout(function () {
500
+ pending = false
501
+ try { ensureSidebarOpsItem() } catch (e) { /* never crash the UI */ }
502
+ }, 0)
503
+ }
504
+ var observer = new MutationObserver(function (mutations) {
505
+ for (var i = 0; i < mutations.length; i++) {
506
+ var added = mutations[i].addedNodes
507
+ for (var j = 0; j < added.length; j++) {
508
+ var n = added[j]
509
+ if (n.nodeType !== 1) continue
510
+ var isMenu = false
511
+ try {
512
+ isMenu = (typeof n.matches === 'function' && n.matches('[role=menu]'))
513
+ || (typeof n.querySelector === 'function' && n.querySelector('[role=menu]') !== null)
514
+ } catch (e) { isMenu = false }
515
+ if (isMenu) { scheduleEnsure(); return }
516
+ }
517
+ }
518
+ })
519
+ observer.observe(document.body, { childList: true, subtree: true })
520
+ }
521
+
522
+ // --- apply ------------------------------------------------------------------
523
+
524
+ function adoptLocale(locale, ctx) {
525
+ if (!locale) return
526
+ __locale = locale
527
+ try {
528
+ if (typeof locale.register === 'function') {
529
+ ctx.effect(() => locale.register(NS, { zh: zhDict, en: enDict }))
530
+ }
531
+ } catch { /* namespace already registered: keep existing copy */ }
532
+ }
533
+
534
+ function apply(ctx) {
535
+ __sessionsSvc = ctx.get('sessions')
536
+ if (!__sessionsSvc) {
537
+ ctx.inject(['sessions'], (sub) => { __sessionsSvc = sub.sessions })
538
+ }
539
+ adoptLocale(ctx.get('locale'), ctx)
540
+ if (!__locale) {
541
+ ctx.inject(['locale'], (sub) => {
542
+ adoptLocale(sub.locale, ctx)
543
+ refreshSidebarOpsLabel()
544
+ })
545
+ }
546
+ ctx.on('locale/change', refreshSidebarOpsLabel)
547
+ ctx.slots.inject(SLOT, () => ctx.slots.register({
548
+ name: SLOT, id: ROW_ID, order: 31,
549
+ ...(__locale ? { locale: NS } : {}),
550
+ }, OpsButton))
551
+ ctx.slots.inject(OVERLAY_SLOT, () => ctx.slots.register({
552
+ name: OVERLAY_SLOT, id: DIALOG_ID, order: 101,
553
+ ...(__locale ? { locale: NS } : {}),
554
+ }, OpsDialog))
555
+ installSidebarOps()
556
+ }
557
+
558
+ return { apply, inject: ['slots'] }
559
+ },
560
+ })