dsh-manage-session 0.5.4 → 0.5.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.ja.md +22 -0
- package/CHANGELOG.ko.md +22 -0
- package/CHANGELOG.md +22 -0
- package/README.en.md +13 -11
- package/README.md +11 -10
- package/dsh.plugin.json +2 -2
- package/lib/client.js +900 -386
- package/lib/index.d.ts +241 -16
- package/lib/index.js +526 -71
- package/package.json +15 -9
- package/src/client/card.tsx +14 -10
- package/src/client/index.ts +53 -15
- package/src/client/locales.ts +36 -0
- package/src/client/panel.tsx +26 -36
- package/src/client/search/host-api.ts +5 -0
- package/src/client/search/locales.ts +81 -0
- package/src/client/search/locate.ts +330 -0
- package/src/client/search/manage.tsx +6 -2
- package/src/client/search/panel.tsx +154 -29
- package/src/client/session-menu-legacy.ts +249 -0
- package/src/client/session-menu.tsx +96 -0
- package/src/config.ts +17 -0
- package/src/host/history/tombstones.ts +100 -0
- package/src/host/index/drs-source.ts +211 -0
- package/src/host/index/engine.ts +699 -571
- package/src/host/index/rebuild.ts +432 -368
- package/src/host/index/sync.ts +413 -286
- package/src/host/settings-bridge.ts +27 -0
- package/src/index.ts +1499 -1421
- package/src/client/history-panel.tsx +0 -319
|
@@ -14,7 +14,8 @@ import { createPortal } from 'react-dom'
|
|
|
14
14
|
import type { SwitchSearchConfig } from '../../config.ts'
|
|
15
15
|
// 0.1.7:插件族共用设置节(dsh-thinking-levels 持有并声明 `dsh-family.tab`
|
|
16
16
|
// 子席位;本仓不依赖 ui-slots 类型包,席位键以运行时 children 表为准)。
|
|
17
|
-
import {
|
|
17
|
+
import { locateSearchHit } from './locate.ts'
|
|
18
|
+
import { callHost, callHostAny, callSteward, type HostContentHit, type HostIndexStatus, type HostSessionItem, type HostSortMode, type SwitchSessionsService, type SwitchUiWorkspaceService } from './host-api.ts'
|
|
18
19
|
import type { SwitchCardScope } from './card.tsx'
|
|
19
20
|
import { translate, type LocaleKey } from './locales.ts'
|
|
20
21
|
import { LABELS, isInvokeChord } from './platform.ts'
|
|
@@ -110,6 +111,15 @@ const SORT_CHIPS: readonly { id: HostSortMode; labelKey: LocaleKey }[] = [
|
|
|
110
111
|
{ id: 'time', labelKey: 'sort.time' },
|
|
111
112
|
]
|
|
112
113
|
|
|
114
|
+
/** 命中粒度:merge=会话分组取最强(既有行为),expand=同会话多命中分行显示。 */
|
|
115
|
+
type GroupMode = 'merge' | 'expand'
|
|
116
|
+
|
|
117
|
+
/** 命中粒度 chips(仅内容模式)。 */
|
|
118
|
+
const GROUP_CHIPS: readonly { id: GroupMode; labelKey: LocaleKey }[] = [
|
|
119
|
+
{ id: 'merge', labelKey: 'group.merge' },
|
|
120
|
+
{ id: 'expand', labelKey: 'group.expand' },
|
|
121
|
+
]
|
|
122
|
+
|
|
113
123
|
/**
|
|
114
124
|
* The identity of one content-search request, derived from every input that
|
|
115
125
|
* changes the result set.
|
|
@@ -127,8 +137,8 @@ const SORT_CHIPS: readonly { id: HostSortMode; labelKey: LocaleKey }[] = [
|
|
|
127
137
|
* @param archivedFilter - the retrieval-domain chip (all/active/archived).
|
|
128
138
|
* @returns an opaque key, stable for equal inputs.
|
|
129
139
|
*/
|
|
130
|
-
function contentRequestKey(normalized: string, contentType: ContentType, sortBy: HostSortMode, archivedFilter: ArchivedDomain, dateRange: { from: string; to: string }): string {
|
|
131
|
-
return `${normalized}\u0000${contentType}\u0000${sortBy}\u0000${archivedFilter}\u0000${dateRange.from}\u0000${dateRange.to}`
|
|
140
|
+
function contentRequestKey(normalized: string, contentType: ContentType, sortBy: HostSortMode, archivedFilter: ArchivedDomain, dateRange: { from: string; to: string }, groupMode: GroupMode): string {
|
|
141
|
+
return `${normalized}\u0000${contentType}\u0000${sortBy}\u0000${archivedFilter}\u0000${dateRange.from}\u0000${dateRange.to}\u0000${groupMode}`
|
|
132
142
|
}
|
|
133
143
|
|
|
134
144
|
/**
|
|
@@ -155,6 +165,25 @@ function writeStoredSort(next: HostSortMode): void {
|
|
|
155
165
|
}
|
|
156
166
|
}
|
|
157
167
|
|
|
168
|
+
/** 命中粒度持久化(与排序同款:耐久偏好,不是会话情绪)。 */
|
|
169
|
+
const GROUP_STORE_KEY = 'dsh-search-index.groupMode'
|
|
170
|
+
|
|
171
|
+
function readStoredGroup(): GroupMode {
|
|
172
|
+
try {
|
|
173
|
+
return window.localStorage.getItem(GROUP_STORE_KEY) === 'expand' ? 'expand' : 'merge'
|
|
174
|
+
} catch {
|
|
175
|
+
return 'merge'
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function writeStoredGroup(next: GroupMode): void {
|
|
180
|
+
try {
|
|
181
|
+
window.localStorage.setItem(GROUP_STORE_KEY, next)
|
|
182
|
+
} catch {
|
|
183
|
+
// ignore: the choice applies for the rest of this session regardless
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
158
187
|
/**
|
|
159
188
|
* Local re-sort so the page honours the chosen ordering even against an older
|
|
160
189
|
* host half that ignores `sortBy` and therefore omits `updatedAt` entirely —
|
|
@@ -231,6 +260,12 @@ const CSS = `
|
|
|
231
260
|
.dsws_list{flex:1 1 auto;min-height:0;overflow-y:auto;margin:8px 0 0;padding:0 6px 8px;list-style:none}
|
|
232
261
|
.dsws_row{box-sizing:border-box;border-radius:8px;width:100%;padding:7px 8px;cursor:pointer;text-align:left;border:none;background:transparent;color:var(--dsw-alias-label-primary);display:flex;flex-direction:column;gap:2px;min-width:0}
|
|
233
262
|
.dsws_row:hover{background:var(--dsw-alias-interactive-bg-hover)}
|
|
263
|
+
.dsws_rowLine{display:flex;align-items:stretch;gap:4px}
|
|
264
|
+
.dsws_rowLine>.dsws_row{flex:1 1 auto;width:auto;min-width:0}
|
|
265
|
+
.dsws_delBtn{flex:none;align-self:center;height:24px;box-sizing:border-box;border:1px solid var(--dsw-alias-state-error-primary);background:transparent;color:var(--dsw-alias-state-error-primary);border-radius:999px;padding:0 10px;font-size:12px;line-height:22px;cursor:pointer;white-space:nowrap}
|
|
266
|
+
.dsws_delBtn:hover{background:var(--dsw-alias-state-error-primary);color:#fff}
|
|
267
|
+
.dsws_delBtnArm{background:var(--dsw-alias-state-error-primary);border-color:var(--dsw-alias-state-error-primary);color:#fff;font-weight:600}
|
|
268
|
+
.dsws_delBtn:disabled{opacity:.5;cursor:default}
|
|
234
269
|
.dsws_rowTitle{display:flex;align-items:center;gap:8px;min-width:0}
|
|
235
270
|
.dsws_titleText{flex:auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:13px;font-weight:500;line-height:18px}
|
|
236
271
|
.dsws_tag{flex:none;color:var(--dsw-alias-label-caption);font-size:11px;line-height:16px;white-space:nowrap;font-variant-numeric:tabular-nums}
|
|
@@ -330,11 +365,25 @@ function SwitchPanel({
|
|
|
330
365
|
// 日期范围(R6):YYYY-MM-DD,空串 = 不限;结束日含当天(宿主半排他上界 +1d)。
|
|
331
366
|
const [dateFrom, setDateFrom] = useState('')
|
|
332
367
|
const [dateTo, setDateTo] = useState('')
|
|
368
|
+
// 定位诊断徽标(T7.4 v3):点过命中行的定位态 ⏳/✓/✗——「停在底部」不再靠猜。
|
|
369
|
+
const [locateStatus, setLocateStatus] = useState<Record<string, 'pending' | 'ok' | 'fail'>>({})
|
|
370
|
+
// 直接删除入口(功能①):开关由 index-status 探针下发(管家
|
|
371
|
+
// session-steward.directDelete,缺省关);armed=待二次确认的会话 id。
|
|
372
|
+
const [directDelete, setDirectDelete] = useState(false)
|
|
373
|
+
const [armedDelete, setArmedDelete] = useState<string | null>(null)
|
|
374
|
+
const [deleteBusy, setDeleteBusy] = useState(false)
|
|
375
|
+
const [deleteNote, setDeleteNote] = useState<{ text: string; failed: boolean } | null>(null)
|
|
333
376
|
const [sortBy, setSortByState] = useState<HostSortMode>(readStoredSort)
|
|
334
377
|
const setSortBy = (next: HostSortMode): void => {
|
|
335
378
|
writeStoredSort(next)
|
|
336
379
|
setSortByState(next)
|
|
337
380
|
}
|
|
381
|
+
// 命中粒度(分开显示开关):expand=同会话多命中各自成行(DRS expand=1 臂)。
|
|
382
|
+
const [groupMode, setGroupModeState] = useState<GroupMode>(readStoredGroup)
|
|
383
|
+
const setGroupMode = (next: GroupMode): void => {
|
|
384
|
+
writeStoredGroup(next)
|
|
385
|
+
setGroupModeState(next)
|
|
386
|
+
}
|
|
338
387
|
const [sessions, setSessions] = useState<HostSessionItem[] | null>(null)
|
|
339
388
|
const [sessionsError, setSessionsError] = useState<string | null>(null)
|
|
340
389
|
const [content, setContent] = useState<{ query: string; status: 'idle' | 'loading' | 'ready' | 'error'; items: HostContentHit[]; error?: string }>({
|
|
@@ -372,6 +421,7 @@ function SwitchPanel({
|
|
|
372
421
|
}
|
|
373
422
|
const status = res as HostIndexStatus
|
|
374
423
|
const rebuilding = status.rebuild?.state === 'building' || status.rebuild?.state === 'swapping'
|
|
424
|
+
setDirectDelete(status.directDelete === true)
|
|
375
425
|
setSearchStatus({
|
|
376
426
|
available: status.available ?? false,
|
|
377
427
|
reason: status.reason,
|
|
@@ -395,6 +445,41 @@ function SwitchPanel({
|
|
|
395
445
|
void callHostAny<HostIndexStatus>('index-rebuild', {})
|
|
396
446
|
}
|
|
397
447
|
|
|
448
|
+
/**
|
|
449
|
+
* 真删一个会话(功能①):走管理端同款 session-history-purge 链路(内存
|
|
450
|
+
* 全痕删除优先,宿主成功后落墓碑台账);成功只把该会话从两侧结果面
|
|
451
|
+
* 摸掉——不刷语料、不关面板(失败要能原地重试)。
|
|
452
|
+
*/
|
|
453
|
+
const purgeSession = (sessionId: string): void => {
|
|
454
|
+
setArmedDelete(null)
|
|
455
|
+
setDeleteBusy(true)
|
|
456
|
+
setDeleteNote(null)
|
|
457
|
+
void callSteward<{ purged?: number }>('session-history-purge', { sessionIds: [sessionId] })
|
|
458
|
+
.then((res) => {
|
|
459
|
+
if (res.ok) {
|
|
460
|
+
setSessions(prev => (prev === null ? prev : prev.filter(it => it.sessionId !== sessionId)))
|
|
461
|
+
setContent(prev => ({ ...prev, items: prev.items.filter(it => it.sessionId !== sessionId) }))
|
|
462
|
+
setDeleteNote({ text: translate(t, 'panel.delete.done', { n: res.purged ?? 1 }), failed: false })
|
|
463
|
+
} else {
|
|
464
|
+
setDeleteNote({ text: translate(t, 'panel.delete.failed', { error: res.error ?? '?' }), failed: true })
|
|
465
|
+
}
|
|
466
|
+
})
|
|
467
|
+
.finally(() => { setDeleteBusy(false) })
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/** 行内删除按钮(仅 directDelete 开启时参与渲染):两步确认,第一击上弦。 */
|
|
471
|
+
const deleteBtn = (sessionId: string): ReactElement => createElement('button', {
|
|
472
|
+
key: 'del',
|
|
473
|
+
type: 'button',
|
|
474
|
+
className: `dsws_delBtn${armedDelete === sessionId ? ' dsws_delBtnArm' : ''}`,
|
|
475
|
+
title: translate(t, 'panel.delete.hint'),
|
|
476
|
+
disabled: deleteBusy,
|
|
477
|
+
onClick: () => {
|
|
478
|
+
if (armedDelete === sessionId) purgeSession(sessionId)
|
|
479
|
+
else setArmedDelete(sessionId)
|
|
480
|
+
},
|
|
481
|
+
}, armedDelete === sessionId ? translate(t, 'panel.delete.confirm') : translate(t, 'panel.delete'))
|
|
482
|
+
|
|
398
483
|
// Load the title-search corpus once on open.
|
|
399
484
|
useEffect(() => {
|
|
400
485
|
if (sessions !== null) return
|
|
@@ -415,7 +500,7 @@ function SwitchPanel({
|
|
|
415
500
|
}
|
|
416
501
|
let cancelled = false
|
|
417
502
|
const requestType: ContentType = contentType
|
|
418
|
-
const requestKey = contentRequestKey(normalized, requestType, sortBy, archivedFilter, { from: dateFrom, to: dateTo })
|
|
503
|
+
const requestKey = contentRequestKey(normalized, requestType, sortBy, archivedFilter, { from: dateFrom, to: dateTo }, groupMode)
|
|
419
504
|
setContent(prev => ({ query: requestKey, status: 'loading', items: prev.query === requestKey ? prev.items : [] }))
|
|
420
505
|
const timer = window.setTimeout(() => {
|
|
421
506
|
callHost<HostContentHit>('content-search', {
|
|
@@ -424,6 +509,7 @@ function SwitchPanel({
|
|
|
424
509
|
types: requestType === 'all' ? undefined : [requestType],
|
|
425
510
|
sortBy,
|
|
426
511
|
archived: archivedFilter,
|
|
512
|
+
expand: groupMode === 'expand' || undefined,
|
|
427
513
|
from: dateFrom === '' ? undefined : dateFrom,
|
|
428
514
|
to: dateTo === '' ? undefined : dateTo,
|
|
429
515
|
}).then((res) => {
|
|
@@ -440,7 +526,7 @@ function SwitchPanel({
|
|
|
440
526
|
cancelled = true
|
|
441
527
|
window.clearTimeout(timer)
|
|
442
528
|
}
|
|
443
|
-
}, [mode, normalized, contentType, sortBy, archivedFilter, dateFrom, dateTo])
|
|
529
|
+
}, [mode, normalized, contentType, sortBy, archivedFilter, dateFrom, dateTo, groupMode])
|
|
444
530
|
|
|
445
531
|
// Focus the input on open; reset mode on every open.
|
|
446
532
|
useEffect(() => {
|
|
@@ -486,10 +572,13 @@ function SwitchPanel({
|
|
|
486
572
|
}, [sessions, normalized, archivedFilter, dateRangeMs])
|
|
487
573
|
|
|
488
574
|
const children: ReactElement[] = []
|
|
575
|
+
if (deleteNote !== null) {
|
|
576
|
+
children.push(createElement('div', { key: 'delnote', className: deleteNote.failed ? 'dsws_error' : 'dsws_status' }, deleteNote.text))
|
|
577
|
+
}
|
|
489
578
|
if (sessionsError !== null) {
|
|
490
579
|
children.push(createElement('div', { key: 'err', className: 'dsws_error' }, translate(t, 'panel.sessionsError', { error: sessionsError })))
|
|
491
580
|
}
|
|
492
|
-
const activeRequestKey = contentRequestKey(normalized, contentType, sortBy, archivedFilter, { from: dateFrom, to: dateTo })
|
|
581
|
+
const activeRequestKey = contentRequestKey(normalized, contentType, sortBy, archivedFilter, { from: dateFrom, to: dateTo }, groupMode)
|
|
493
582
|
const activeContent = content.query === activeRequestKey ? content : { query: activeRequestKey, status: 'loading' as const, items: [] }
|
|
494
583
|
if (mode === 'title') {
|
|
495
584
|
if (sessions === null) {
|
|
@@ -503,17 +592,21 @@ function SwitchPanel({
|
|
|
503
592
|
className: 'dsws_list',
|
|
504
593
|
role: 'listbox',
|
|
505
594
|
'aria-label': translate(t, 'panel.titleSearch'),
|
|
506
|
-
}, titleRows.slice(0, 200).map(item => createElement('li', { key: item.sessionId, role: 'option' }, createElement('
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
595
|
+
}, titleRows.slice(0, 200).map(item => createElement('li', { key: item.sessionId, role: 'option' }, createElement('div', { className: 'dsws_rowLine' }, [
|
|
596
|
+
createElement('button', {
|
|
597
|
+
key: 'row',
|
|
598
|
+
type: 'button',
|
|
599
|
+
className: 'dsws_row',
|
|
600
|
+
onClick: () => { setArmedDelete(null); open(item.sessionId) },
|
|
601
|
+
}, [
|
|
602
|
+
createElement('span', { key: 't', className: 'dsws_rowTitle' }, [
|
|
603
|
+
createElement('span', { key: 'x', className: 'dsws_titleText' }, item.title || translate(t, 'panel.untitled')),
|
|
604
|
+
item.archived === true && createElement('span', { key: 'arch', className: 'dsws_tagArch' }, translate(t, 'tag.archived')),
|
|
605
|
+
createElement('span', { key: 'tag', className: 'dsws_tag' }, fmtTime(item.updatedAt)),
|
|
606
|
+
]),
|
|
607
|
+
item.cwd !== '' && createElement('span', { key: 'c', className: 'dsws_meta' }, item.cwd),
|
|
515
608
|
]),
|
|
516
|
-
|
|
609
|
+
directDelete && deleteBtn(item.sessionId),
|
|
517
610
|
])))))
|
|
518
611
|
}
|
|
519
612
|
} else {
|
|
@@ -559,20 +652,42 @@ function SwitchPanel({
|
|
|
559
652
|
className: 'dsws_list',
|
|
560
653
|
role: 'listbox',
|
|
561
654
|
'aria-label': translate(t, 'panel.contentSearch'),
|
|
562
|
-
}, activeContent.items.slice(0, 200).map(item => createElement('li', { key: item.sessionId
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
655
|
+
}, activeContent.items.slice(0, 200).map(item => createElement('li', { key: `${item.sessionId}:${item.seq}`, role: 'option' }, createElement('div', { className: 'dsws_rowLine' }, [
|
|
656
|
+
createElement('button', {
|
|
657
|
+
key: 'row',
|
|
658
|
+
type: 'button',
|
|
659
|
+
className: 'dsws_row',
|
|
660
|
+
// B 路线定位臂(T7.4):先开会话,再异步把命中行滚进视口高亮;
|
|
661
|
+
// 定位失败静默——行为与接臂前逐行一致(仅打开)。展开态下同会话
|
|
662
|
+
// 多行各自成行,行键/定位态键都降到 (sessionId,seq) 粒度。
|
|
663
|
+
onClick: () => {
|
|
664
|
+
const hitKey = `${item.sessionId}:${item.seq}`
|
|
665
|
+
setArmedDelete(null)
|
|
666
|
+
open(item.sessionId)
|
|
667
|
+
setLocateStatus(s => ({ ...s, [hitKey]: 'pending' }))
|
|
668
|
+
void locateSearchHit(item).then(ok => {
|
|
669
|
+
setLocateStatus(s => ({ ...s, [hitKey]: ok ? 'ok' : 'fail' }))
|
|
670
|
+
})
|
|
671
|
+
},
|
|
672
|
+
}, [
|
|
673
|
+
createElement('span', { key: 't', className: 'dsws_rowTitle' }, [
|
|
674
|
+
createElement('span', { key: 'x', className: 'dsws_titleText' }, item.title || translate(t, 'panel.untitled')),
|
|
675
|
+
createElement('span', { key: 'tag', className: 'dsws_tag' }, typeLabel(t, item.type)),
|
|
676
|
+
// The session clock — the very field the 「时间」 ordering sorts by and
|
|
677
|
+
// the one the title rows already show. Ordering rows by a time the
|
|
678
|
+
// rows never display is illegible: you cannot tell what the sort did.
|
|
679
|
+
// 展开态例外:同会话各行 updatedAt 相同,改显命中行自己的 time。
|
|
680
|
+
createElement('span', { key: 'time', className: 'dsws_tag' }, fmtTime(groupMode === 'expand' ? item.time : item.updatedAt)),
|
|
681
|
+
...(locateStatus[`${item.sessionId}:${item.seq}`] === undefined ? [] : [createElement('span', {
|
|
682
|
+
key: 'loc', className: 'dsws_tag',
|
|
683
|
+
title: locateStatus[`${item.sessionId}:${item.seq}`] === 'pending' ? '定位巡逻中'
|
|
684
|
+
: locateStatus[`${item.sessionId}:${item.seq}`] === 'ok' ? '已跳转到命中行'
|
|
685
|
+
: '未能定位(行未渲染/文本被拆分/超时)',
|
|
686
|
+
}, locateStatus[`${item.sessionId}:${item.seq}`] === 'pending' ? '⏳' : locateStatus[`${item.sessionId}:${item.seq}`] === 'ok' ? '✓' : '✗')]),
|
|
687
|
+
]),
|
|
688
|
+
createElement('span', { key: 's', className: 'dsws_snippet' }, item.snippet || translate(t, 'panel.noText')),
|
|
574
689
|
]),
|
|
575
|
-
|
|
690
|
+
directDelete && deleteBtn(item.sessionId),
|
|
576
691
|
])))))
|
|
577
692
|
}
|
|
578
693
|
}
|
|
@@ -633,6 +748,16 @@ function SwitchPanel({
|
|
|
633
748
|
'aria-pressed': contentType === chip.id,
|
|
634
749
|
onClick: () => { setContentType(chip.id) },
|
|
635
750
|
}, translate(t, chip.labelKey))) : []),
|
|
751
|
+
// 命中粒度开关(仅内容模式):分开显示=同会话多命中各自成行
|
|
752
|
+
// (DRS expand=1 臂);合并=每会话只显最强命中(既有行为)。
|
|
753
|
+
...(mode === 'content' ? GROUP_CHIPS.map(chip => createElement('button', {
|
|
754
|
+
key: `g-${chip.id}`,
|
|
755
|
+
type: 'button',
|
|
756
|
+
className: `dsws_chip${groupMode === chip.id ? ' dsws_chipActive' : ''}`,
|
|
757
|
+
'aria-pressed': groupMode === chip.id,
|
|
758
|
+
title: translate(t, chip.id === 'expand' ? 'group.expand.hint' : 'group.merge.hint'),
|
|
759
|
+
onClick: () => { setGroupMode(chip.id) },
|
|
760
|
+
}, translate(t, chip.labelKey))) : []),
|
|
636
761
|
// Ordering sits on the same row, pushed right: it filters the same
|
|
637
762
|
// result set the type chips do, so it is not a separate toolbar.
|
|
638
763
|
createElement('span', { key: 'gap', className: 'dsws_chipGap' }),
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 老线(≤0.1.5)会话「…」菜单删除注入臂(2026-10-20 用户定标)。
|
|
3
|
+
*
|
|
4
|
+
* 为什么需要:≤0.1.5 宿主的 ui-workspace 会话菜单是 React **写死**的
|
|
5
|
+
* `sessionMenuItems`(rename/fork/archive),portal 出 `div[role=menu]`,
|
|
6
|
+
* **无插件席位**——席位臂(session-menu.tsx)在老线 inject 静默等待、永不落行。
|
|
7
|
+
* purge 后端两格都通(file 兜底路),缺的只是菜单入口。本臂用 DOM 注入补上:
|
|
8
|
+
* 监听 portal 菜单出现 → 从被点「…」锚点的行 React fiber 取 sessionId →
|
|
9
|
+
* 追一行两步确认删除(与管理端/席位臂同款 purge 链路)。
|
|
10
|
+
*
|
|
11
|
+
* 现代线(0.1.7+)席位行已渲染并带 `data-steward-menu-delete` 标记,本臂据此
|
|
12
|
+
* dedup 跳过;故两线共用一个注入器不会出两条删除。
|
|
13
|
+
*
|
|
14
|
+
* 纪律:全程 best-effort、任何异常吞掉绝不影响宿主;仅在 directDelete=true 时
|
|
15
|
+
* 注入(缺省关,破坏性入口不默认铺开)。fiber 取 id 失败即放弃该行(不伪造)。
|
|
16
|
+
*/
|
|
17
|
+
import { callHostAny, callSteward, type HostIndexStatus } from './search/host-api.ts'
|
|
18
|
+
import { dictionaries, type LocaleKey } from './locales.ts'
|
|
19
|
+
|
|
20
|
+
/** dedup 标记(与 session-menu.tsx 席位行同串)。 */
|
|
21
|
+
export const STEWARD_MENU_DELETE_ATTR = 'data-steward-menu-delete'
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* 删除图标(逐字内联宿主 ui-primitives IconTrashOutline16——工作区「删除」
|
|
25
|
+
* 同款):单 path + fill=currentColor,16×16,随行文字色变化(上弦变红
|
|
26
|
+
* 时图标一起红)。插件不能 value-import 宿主图标包(老线 ModuleLoader
|
|
27
|
+
* 解析不了、未声明 peer),故内联 SVG 串;席位臂与注入臂共用此常量。
|
|
28
|
+
*/
|
|
29
|
+
export const TRASH_ICON_SVG =
|
|
30
|
+
'<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">'
|
|
31
|
+
+ '<path d="M14.4782 4.84067L14.2138 10.1152C14.1102 12.1872 14.067 13.0115 13.3866 13.9607C13.1044 14.3546 12.7498 14.6912 12.3424 14.9535C11.8239 15.2872 11.2415 15.4316 10.5585 15.4998C9.88727 15.5668 9.04946 15.5656 7.99998 15.5656C6.95051 15.5656 6.1127 15.5668 5.44142 15.4998C4.75851 15.4316 4.17602 15.2872 3.65753 14.9535C3.25012 14.6912 2.89559 14.3546 2.61332 13.9607C1.93296 13.0115 1.88979 12.1872 1.78619 10.1152L1.52179 4.84067L2.89006 4.77277L3.15343 10.0463C3.26221 12.2218 3.32452 12.6015 3.72646 13.1624C3.90825 13.4161 4.13686 13.6334 4.39927 13.8023C4.66204 13.9714 5.00263 14.0792 5.57825 14.1367C6.16562 14.1953 6.92298 14.1963 7.99998 14.1963C9.07699 14.1963 9.83434 14.1953 10.4217 14.1367C10.9973 14.0792 11.3379 13.9714 11.6007 13.8023C11.8631 13.6334 12.0917 13.4161 12.2735 13.1624C12.6755 12.6015 12.7378 12.2218 12.8465 10.0463L13.1099 4.77277L14.4782 4.84067ZM5.43011 6.22849H6.7994V11.3909H5.43011V6.22849ZM9.20056 6.22849H10.5699V11.3909H9.20056V6.22849ZM8.53597 0.434431C9.17976 0.434431 9.6522 0.426926 10.0966 0.571258C10.2357 0.616451 10.3717 0.672554 10.502 0.738948C10.9182 0.951107 11.2464 1.29099 11.7015 1.74612L12.4978 2.54136H15.3742V3.91169H0.625732V2.54136H3.50218L4.29845 1.74612C4.75358 1.29099 5.08174 0.951107 5.49801 0.738948C5.62831 0.672554 5.76425 0.616451 5.90334 0.571258C6.34776 0.426926 6.82021 0.434431 7.46399 0.434431H8.53597ZM7.46399 1.80476C6.73208 1.80476 6.51641 1.81187 6.32617 1.87369C6.25545 1.89667 6.18668 1.92533 6.12041 1.95907C5.96398 2.03878 5.82348 2.16253 5.44142 2.54136H10.5585C10.1765 2.16253 10.036 2.03878 9.87955 1.95907C9.81329 1.92533 9.74452 1.89667 9.6738 1.87369C9.48356 1.81187 9.26789 1.80476 8.53597 1.80476H7.46399Z" fill="currentColor"/></svg>'
|
|
32
|
+
|
|
33
|
+
/** 图标槽内联样式(对齐宿主 .itemIcon:16×16 inline-flex flex:none)。 */
|
|
34
|
+
export const ICON_SLOT_STYLE = 'display:inline-flex;flex:none;width:16px;height:16px;align-items:center'
|
|
35
|
+
|
|
36
|
+
/** 锚点点击 → 菜单出现的有效窗口(超时视为陈旧、不注入)。 */
|
|
37
|
+
const PENDING_TTL_MS = 1500
|
|
38
|
+
|
|
39
|
+
/** 删除行文案(从字典解析,缺省回退 zh)。 */
|
|
40
|
+
export interface DeleteLabels {
|
|
41
|
+
delete: string
|
|
42
|
+
confirm: string
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** 按语言前缀挑字典(`zh-CN`→`zh`;未知回退 zh;键缺失回退 zh 同键)。 */
|
|
46
|
+
export function resolveLabels(lang: string | undefined): DeleteLabels {
|
|
47
|
+
const dicts = dictionaries as Record<string, Record<string, string>>
|
|
48
|
+
const base = dicts['zh'] ?? {}
|
|
49
|
+
const pick = (key: LocaleKey): string => {
|
|
50
|
+
const norm = (lang ?? '').toLowerCase()
|
|
51
|
+
const prefix = norm.split('-')[0] ?? ''
|
|
52
|
+
const hit = dicts[prefix]?.[key] ?? dicts[norm]?.[key]
|
|
53
|
+
return hit ?? base[key] ?? key
|
|
54
|
+
}
|
|
55
|
+
return { delete: pick('menu.deleteSession' as LocaleKey), confirm: pick('menu.deleteSession.confirm' as LocaleKey) }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* 从被点元素向上找 `[role=treeitem]` 会话行,再沿 React fiber `.return` 链
|
|
60
|
+
* 找 `memoizedProps.node.id`(会话 id)。fiber 键前缀兼容 React 17/18/19
|
|
61
|
+
* (`__reactFiber$`) 与 16 (`__reactInternalInstance$`)。找不到返回 undefined。
|
|
62
|
+
* 接受任意带这些字段的对象以便单测(不强依赖真实 DOM 类型)。
|
|
63
|
+
*/
|
|
64
|
+
export function extractSessionIdFromFiber(startEl: unknown): string | undefined {
|
|
65
|
+
let el = startEl as { closest?: (s: string) => unknown; parentElement?: unknown } | null | undefined
|
|
66
|
+
// 先定位到会话行(treeitem);不是行内元素直接放弃。
|
|
67
|
+
let row: unknown = null
|
|
68
|
+
if (el !== null && el !== undefined && typeof (el as { closest?: unknown }).closest === 'function') {
|
|
69
|
+
row = (el as { closest: (s: string) => unknown }).closest('[role="treeitem"]')
|
|
70
|
+
}
|
|
71
|
+
if (row === null || row === undefined) return undefined
|
|
72
|
+
const fiberKey = Object.keys(row).find(
|
|
73
|
+
(k) => k.startsWith('__reactFiber$') || k.startsWith('__reactInternalInstance$'),
|
|
74
|
+
)
|
|
75
|
+
if (fiberKey === undefined) return undefined
|
|
76
|
+
let fiber = (row as Record<string, unknown>)[fiberKey] as
|
|
77
|
+
| { memoizedProps?: unknown; return?: unknown } | undefined
|
|
78
|
+
for (let i = 0; i < 40 && fiber !== undefined && fiber !== null; i++) {
|
|
79
|
+
const props = fiber.memoizedProps as { node?: { id?: unknown }; row?: { id?: unknown } } | undefined
|
|
80
|
+
const cand = props?.node?.id ?? props?.row?.id
|
|
81
|
+
if (typeof cand === 'string' && cand !== '') return cand
|
|
82
|
+
fiber = fiber.return as typeof fiber
|
|
83
|
+
}
|
|
84
|
+
return undefined
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** 该菜单容器是否已含删除行(席位行或本臂先前注入)。 */
|
|
88
|
+
export function hasStewardDelete(menuEl: { querySelector?: (s: string) => unknown } | null | undefined): boolean {
|
|
89
|
+
if (menuEl === null || menuEl === undefined) return false
|
|
90
|
+
return typeof menuEl.querySelector === 'function'
|
|
91
|
+
&& menuEl.querySelector(`[${STEWARD_MENU_DELETE_ATTR}]`) !== null
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* 隐藏侧栏中属于该 sessionId 的会话行(老线文件路 purge 后补位)。
|
|
96
|
+
* 背景:≤0.1.5 无 registry.deleteSession,purgeHistory 只动磁盘(归档+删目录)
|
|
97
|
+
* 与 steward 索引,不通知宿主内存 workspaceRegistry → 侧栏行滞留到重启。
|
|
98
|
+
* 本函数扫 `[role=treeitem]` 行、用 fiber 取 node.id 匹配、置 display:none(不
|
|
99
|
+
* removeChild,避开与 React 协调冲突;行无 style.display prop,重渲染不覆盖)。
|
|
100
|
+
* 返回隐藏行数(现代线行已被 deleteSession 真删,此处通常 0,无害)。
|
|
101
|
+
*/
|
|
102
|
+
export function hideSidebarRowsFor(doc: Document, sessionId: string): number {
|
|
103
|
+
if (sessionId === '') return 0
|
|
104
|
+
let n = 0
|
|
105
|
+
for (const row of Array.from(doc.querySelectorAll('[role="treeitem"]'))) {
|
|
106
|
+
if (extractSessionIdFromFiber(row) === sessionId) {
|
|
107
|
+
;(row as HTMLElement).style.display = 'none'
|
|
108
|
+
n += 1
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return n
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** 注入器依赖(浏览器装配;单测只覆盖上面的纯叶)。 */
|
|
115
|
+
export interface LegacyMenuInjectDeps {
|
|
116
|
+
doc: Document
|
|
117
|
+
/** 删除执行(走 session-history-purge);返回后调用方负责关菜单。 */
|
|
118
|
+
purge: (sessionId: string) => Promise<void>
|
|
119
|
+
/** directDelete 探针(含缓存策略由实现决定)。 */
|
|
120
|
+
isEnabled: () => Promise<boolean>
|
|
121
|
+
/** 当前语言(navigator.language)。 */
|
|
122
|
+
lang: () => string | undefined
|
|
123
|
+
log?: (msg: string) => void
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* 装配老线菜单注入器。返回 dispose(摘除监听)。任何内部异常都被吞——
|
|
128
|
+
* 本臂是增强,绝不允许拖垮宿主侧栏。DOM 构造器(MutationObserver/MouseEvent/
|
|
129
|
+
* KeyboardEvent/Element)走全局(DOM lib 顶层声明;Window 类型不暴露它们)。
|
|
130
|
+
*/
|
|
131
|
+
export function installLegacySessionMenuDelete(deps: LegacyMenuInjectDeps): () => void {
|
|
132
|
+
const { doc } = deps
|
|
133
|
+
let pending: { sessionId: string; at: number } | undefined
|
|
134
|
+
const anchored = new WeakSet<Element>()
|
|
135
|
+
|
|
136
|
+
const onRowAnchorClick = (ev: Event): void => {
|
|
137
|
+
try {
|
|
138
|
+
const target = ev.target
|
|
139
|
+
if (!(target instanceof Element)) return
|
|
140
|
+
const btn = target.closest('button')
|
|
141
|
+
if (btn === null) return
|
|
142
|
+
// 仅行内「…」锚点(在 treeitem 行里);行内其它按钮点击不会开菜单,
|
|
143
|
+
// 即便误取到 sessionId 也只是无菜单出现、pending 自然过期。
|
|
144
|
+
const sid = extractSessionIdFromFiber(btn)
|
|
145
|
+
if (sid !== undefined) pending = { sessionId: sid, at: Date.now() }
|
|
146
|
+
} catch (err) { deps.log?.(`anchor click extract failed: ${String(err)}`) }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const closeMenu = (): void => {
|
|
150
|
+
try {
|
|
151
|
+
// 宿主菜单 onClose 走 outside pointerdown / Escape;用一次 body pointerdown 兜底关。
|
|
152
|
+
doc.body.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true }))
|
|
153
|
+
doc.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
|
|
154
|
+
} catch { /* 关不掉不致命 */ }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const injectInto = (menuEl: Element, sessionId: string): void => {
|
|
158
|
+
if (anchored.has(menuEl) || hasStewardDelete(menuEl)) return
|
|
159
|
+
const labels = resolveLabels(deps.lang())
|
|
160
|
+
const row = doc.createElement('div')
|
|
161
|
+
row.setAttribute('role', 'menuitem')
|
|
162
|
+
row.setAttribute(STEWARD_MENU_DELETE_ATTR, '1')
|
|
163
|
+
row.style.cssText = 'display:flex;align-items:center;gap:8px;width:100%;box-sizing:border-box;padding:6px 10px;margin:0;color:inherit;font:inherit;font-size:13px;line-height:18px;text-align:start;cursor:pointer;border-radius:6px'
|
|
164
|
+
// 图标槽 + 文字槽(对齐宿主 rename/fork/archive 的 itemIcon+itemLabel 结构)。
|
|
165
|
+
const icon = doc.createElement('span')
|
|
166
|
+
icon.style.cssText = ICON_SLOT_STYLE
|
|
167
|
+
icon.innerHTML = TRASH_ICON_SVG
|
|
168
|
+
const label = doc.createElement('span')
|
|
169
|
+
let armed = false
|
|
170
|
+
const paint = (): void => {
|
|
171
|
+
label.textContent = armed ? labels.confirm : labels.delete
|
|
172
|
+
row.style.background = armed ? 'rgba(255,99,99,0.12)' : 'transparent'
|
|
173
|
+
row.style.color = armed ? 'var(--dsw-alias-state-error-primary,#e5484d)' : 'inherit'
|
|
174
|
+
}
|
|
175
|
+
row.append(icon, label)
|
|
176
|
+
paint()
|
|
177
|
+
row.addEventListener('mouseenter', () => { row.style.background = armed ? 'rgba(255,99,99,0.12)' : 'var(--dsw-alias-interactive-bg-hover,rgba(128,128,128,0.12))' })
|
|
178
|
+
row.addEventListener('mouseleave', () => { armed = false; paint() })
|
|
179
|
+
row.addEventListener('click', (e) => {
|
|
180
|
+
e.stopPropagation()
|
|
181
|
+
if (!armed) { armed = true; paint(); return }
|
|
182
|
+
// 成功:先隐藏侧栏滞留行(老线文件路不通知宿主 registry),再关菜单。
|
|
183
|
+
void deps.purge(sessionId).then(() => { hideSidebarRowsFor(doc, sessionId); closeMenu() }, () => { closeMenu() })
|
|
184
|
+
})
|
|
185
|
+
menuEl.appendChild(row)
|
|
186
|
+
anchored.add(menuEl)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const tryInject = (): void => {
|
|
190
|
+
try {
|
|
191
|
+
if (pending === undefined || Date.now() - pending.at > PENDING_TTL_MS) return
|
|
192
|
+
const sid = pending.sessionId
|
|
193
|
+
const menus = Array.from(doc.querySelectorAll('[role="menu"]'))
|
|
194
|
+
for (const menu of menus) {
|
|
195
|
+
if (menu.querySelector('[role="menuitem"]') === null) continue // 空/非会话菜单
|
|
196
|
+
void deps.isEnabled().then((on) => {
|
|
197
|
+
if (!on) return
|
|
198
|
+
if (!menu.isConnected) return
|
|
199
|
+
injectInto(menu, sid)
|
|
200
|
+
}, () => {})
|
|
201
|
+
}
|
|
202
|
+
pending = undefined
|
|
203
|
+
} catch (err) { deps.log?.(`menu inject failed: ${String(err)}`) }
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
let observer: MutationObserver | undefined
|
|
207
|
+
const start = (): void => {
|
|
208
|
+
if (typeof MutationObserver !== 'function') return
|
|
209
|
+
const obs = new MutationObserver(() => { tryInject() })
|
|
210
|
+
obs.observe(doc.body, { childList: true, subtree: true })
|
|
211
|
+
observer = obs
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
try {
|
|
215
|
+
doc.addEventListener('click', onRowAnchorClick, true)
|
|
216
|
+
if (doc.body !== null && doc.body !== undefined) start()
|
|
217
|
+
else doc.addEventListener('DOMContentLoaded', start, { once: true })
|
|
218
|
+
} catch (err) { deps.log?.(`legacy menu arm init failed: ${String(err)}`) }
|
|
219
|
+
|
|
220
|
+
return () => {
|
|
221
|
+
try {
|
|
222
|
+
doc.removeEventListener('click', onRowAnchorClick, true)
|
|
223
|
+
observer?.disconnect()
|
|
224
|
+
} catch { /* dispose 尽力 */ }
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** directDelete 探针(缓存 30s)——注入器与外部共用一份。 */
|
|
229
|
+
export function makeDirectDeleteProbe(): () => Promise<boolean> {
|
|
230
|
+
let cached = false
|
|
231
|
+
let at = 0
|
|
232
|
+
return async (): Promise<boolean> => {
|
|
233
|
+
if (Date.now() - at < 30_000) return cached
|
|
234
|
+
at = Date.now()
|
|
235
|
+
try {
|
|
236
|
+
const res = await callHostAny<HostIndexStatus>('index-status', {})
|
|
237
|
+
cached = (res as Partial<HostIndexStatus>).directDelete === true
|
|
238
|
+
} catch {
|
|
239
|
+
cached = false
|
|
240
|
+
}
|
|
241
|
+
return cached
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** 默认删除执行:走 session-history-purge(与管理端/席位臂同链路)。 */
|
|
246
|
+
export async function purgeSessionById(sessionId: string): Promise<void> {
|
|
247
|
+
const res = await callSteward<{ purged?: number }>('session-history-purge', { sessionIds: [sessionId] })
|
|
248
|
+
if (res.ok !== true) throw new Error(res.error ?? 'purge failed')
|
|
249
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 侧栏会话「…」菜单删除臂(2026-10-20 用户定标,按左侧边栏实现换代识别)。
|
|
3
|
+
*
|
|
4
|
+
* 宿主换代事实(主仓 tag 谱系实证):
|
|
5
|
+
* - ≤0.1.5:ui-workspace 会话菜单**写死** rename/fork/archive,无席位无删除;
|
|
6
|
+
* - ≥0.1.7:菜单行席位化 `sidebar.workspaces.session.menu.item`,宿主自带
|
|
7
|
+
* pin(100)/rename(200)/fork(300)/archive(400)/session-id(450)——插件行按
|
|
8
|
+
* order 落位,owner 只给行身份(sessionId + useMenuOpenState + t)。
|
|
9
|
+
* 本臂 order=500(末位,破坏性动作不抢位)。老线(≤0.1.5)inject 静默等待
|
|
10
|
+
* 无害;删除入口在老线仍由搜索面板行内按钮承担(同一 purge 链路)。
|
|
11
|
+
*
|
|
12
|
+
* 可见性:行挂载时探针 index-status.directDelete(管家开关,缺省关)——
|
|
13
|
+
* 非 true(含探针失败/旧宿主半无字段)整行不渲染;两步确认与管理端/面板
|
|
14
|
+
* 同款(第一击上弦变红「确认删除」,第二击执行;点其它处菜单收起即解弦)。
|
|
15
|
+
*/
|
|
16
|
+
import { createElement, useEffect, useState } from 'react'
|
|
17
|
+
import { callHostAny, callSteward, type HostIndexStatus } from './search/host-api.ts'
|
|
18
|
+
import { TRASH_ICON_SVG } from './session-menu-legacy.ts'
|
|
19
|
+
|
|
20
|
+
/** 宿主菜单席位名(0.1.7+ 存在)。 */
|
|
21
|
+
export const SESSION_MENU_SEAT = 'sidebar.workspaces.session.menu.item'
|
|
22
|
+
/** 本行注册 id(包名空间,避免遮蔽宿主行)。 */
|
|
23
|
+
export const SESSION_MENU_DELETE_ID = 'dsh-session-steward.delete'
|
|
24
|
+
/** 落位序:宿主末行 session-id(450) 之后。 */
|
|
25
|
+
export const SESSION_MENU_DELETE_ORDER = 500
|
|
26
|
+
|
|
27
|
+
/** 可见性判定(纯叶):仅显式 true 放行——未知/缺席一律隐藏。 */
|
|
28
|
+
export function menuDeleteVisible(directDelete: unknown): boolean {
|
|
29
|
+
return directDelete === true
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** owner props 最小面(宿主契约:行身份 + 菜单开合 hook + locale 译器)。 */
|
|
33
|
+
export interface StewardMenuDeleteProps {
|
|
34
|
+
sessionId?: string
|
|
35
|
+
useMenuOpenState?: () => [boolean, (open: boolean) => void]
|
|
36
|
+
t?: ((key: string) => string) | undefined
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* 菜单行组件:普通 `role="menuitem"` button(不 import ui-primitives——
|
|
41
|
+
* 未声明 peer 在老线 ModuleLoader 下解析有风险,宿主键盘走查只依赖 DOM)。
|
|
42
|
+
* @param props - owner 行身份与菜单开合面。
|
|
43
|
+
* @returns 菜单行或 null(开关关/探针未就绪)。
|
|
44
|
+
*/
|
|
45
|
+
export function StewardDeleteMenuItem(props: StewardMenuDeleteProps) {
|
|
46
|
+
const { sessionId, useMenuOpenState, t } = props
|
|
47
|
+
const [visible, setVisible] = useState(false)
|
|
48
|
+
const [armed, setArmed] = useState(false)
|
|
49
|
+
// 菜单开合 hook 无条件调用(props 面每次挂载固定,不违反 hooks 规则)。
|
|
50
|
+
const openState = typeof useMenuOpenState === 'function' ? useMenuOpenState() : undefined
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
let disposed = false
|
|
53
|
+
callHostAny<HostIndexStatus>('index-status', {})
|
|
54
|
+
.then((res) => { if (!disposed) setVisible(menuDeleteVisible((res as HostIndexStatus).directDelete)) })
|
|
55
|
+
.catch(() => { if (!disposed) setVisible(false) })
|
|
56
|
+
return () => { disposed = true }
|
|
57
|
+
}, [])
|
|
58
|
+
if (sessionId === undefined || sessionId === '' || !visible) return null
|
|
59
|
+
const fire = (): void => {
|
|
60
|
+
openState?.[1](false)
|
|
61
|
+
callSteward<{ purged?: number }>('session-history-purge', { sessionIds: [sessionId] })
|
|
62
|
+
.then((res) => {
|
|
63
|
+
if (res.ok !== true) console.warn('steward menu delete failed:', res.error ?? 'unknown')
|
|
64
|
+
})
|
|
65
|
+
.catch((err: unknown) => { console.warn('steward menu delete failed:', err) })
|
|
66
|
+
}
|
|
67
|
+
return createElement('button', {
|
|
68
|
+
type: 'button',
|
|
69
|
+
role: 'menuitem',
|
|
70
|
+
// dedup 钩子:老线 DOM 注入臂据此判断菜单是否已有删除行(现代线席位行
|
|
71
|
+
// 已渲染则跳过注入,避免两条删除)。
|
|
72
|
+
'data-steward-menu-delete': '1',
|
|
73
|
+
style: {
|
|
74
|
+
display: 'flex', alignItems: 'center', gap: '8px', width: '100%',
|
|
75
|
+
boxSizing: 'border-box', padding: '6px 10px', margin: 0, border: 'none',
|
|
76
|
+
background: armed ? 'rgba(255, 99, 99, 0.12)' : 'transparent',
|
|
77
|
+
color: armed ? 'var(--dsw-alias-state-error-primary, #e5484d)' : 'inherit',
|
|
78
|
+
font: 'inherit', fontSize: '13px', lineHeight: '18px', textAlign: 'start',
|
|
79
|
+
cursor: 'pointer', borderRadius: '6px',
|
|
80
|
+
},
|
|
81
|
+
onClick: () => {
|
|
82
|
+
if (armed) { fire(); return }
|
|
83
|
+
setArmed(true)
|
|
84
|
+
},
|
|
85
|
+
// 指针移出菜单行区域即解弦(防误触残留态;菜单容器关闭由卸载兜底)。
|
|
86
|
+
onMouseLeave: () => { setArmed(false) },
|
|
87
|
+
}, [
|
|
88
|
+
// 图标槽(内联宿主 IconTrashOutline16,与 rename/fork/archive 图标对齐;
|
|
89
|
+
// fill=currentColor 随行色变红)。
|
|
90
|
+
createElement('span', {
|
|
91
|
+
key: 'icon', style: { display: 'inline-flex', flex: 'none', width: 16, height: 16, alignItems: 'center' },
|
|
92
|
+
dangerouslySetInnerHTML: { __html: TRASH_ICON_SVG },
|
|
93
|
+
}),
|
|
94
|
+
createElement('span', { key: 'label' }, armed ? (t?.('menu.deleteSession.confirm') ?? '确认删除') : (t?.('menu.deleteSession') ?? '删除会话')),
|
|
95
|
+
])
|
|
96
|
+
}
|