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.
@@ -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 { callHost, callHostAny, type HostContentHit, type HostIndexStatus, type HostSessionItem, type HostSortMode, type SwitchSessionsService, type SwitchUiWorkspaceService } from './host-api.ts'
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('button', {
507
- type: 'button',
508
- className: 'dsws_row',
509
- onClick: () => { open(item.sessionId) },
510
- }, [
511
- createElement('span', { key: 't', className: 'dsws_rowTitle' }, [
512
- createElement('span', { key: 'x', className: 'dsws_titleText' }, item.title || translate(t, 'panel.untitled')),
513
- item.archived === true && createElement('span', { key: 'arch', className: 'dsws_tagArch' }, translate(t, 'tag.archived')),
514
- createElement('span', { key: 'tag', className: 'dsws_tag' }, fmtTime(item.updatedAt)),
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
- item.cwd !== '' && createElement('span', { key: 'c', className: 'dsws_meta' }, item.cwd),
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, role: 'option' }, createElement('button', {
563
- type: 'button',
564
- className: 'dsws_row',
565
- onClick: () => { open(item.sessionId) },
566
- }, [
567
- createElement('span', { key: 't', className: 'dsws_rowTitle' }, [
568
- createElement('span', { key: 'x', className: 'dsws_titleText' }, item.title || translate(t, 'panel.untitled')),
569
- createElement('span', { key: 'tag', className: 'dsws_tag' }, typeLabel(t, item.type)),
570
- // The session clock — the very field the 「时间」 ordering sorts by and
571
- // the one the title rows already show. Ordering rows by a time the
572
- // rows never display is illegible: you cannot tell what the sort did.
573
- createElement('span', { key: 'time', className: 'dsws_tag' }, fmtTime(item.updatedAt)),
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
- createElement('span', { key: 's', className: 'dsws_snippet' }, item.snippet || translate(t, 'panel.noText')),
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
+ }