dsh-manage-session 2.0.3 → 2.0.5

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.
Files changed (43) hide show
  1. package/CHANGELOG.ja.md +33 -33
  2. package/CHANGELOG.ko.md +33 -33
  3. package/CHANGELOG.md +33 -33
  4. package/INSTALL.ja.md +164 -164
  5. package/INSTALL.ko.md +164 -164
  6. package/INSTALL.md +164 -164
  7. package/INSTALL.zh.md +164 -164
  8. package/LICENSE +21 -21
  9. package/README.en.md +76 -62
  10. package/README.ja.md +53 -39
  11. package/README.ko.md +52 -38
  12. package/README.md +18 -4
  13. package/cordis.patch.yml +19 -19
  14. package/dsh.plugin.json +2 -2
  15. package/locale/de.json +6 -6
  16. package/locale/en.json +6 -6
  17. package/locale/es.json +6 -6
  18. package/locale/fr.json +6 -6
  19. package/locale/it.json +6 -6
  20. package/locale/ja.json +6 -6
  21. package/locale/ko.json +6 -6
  22. package/locale/ru.json +6 -6
  23. package/locale/zh.json +6 -6
  24. package/package.json +9 -9
  25. package/screenshots.json +3 -3
  26. package/src/client/bridge-scope.ts +202 -202
  27. package/src/client/card.tsx +154 -154
  28. package/src/client/host-api.ts +61 -61
  29. package/src/client/index.ts +594 -594
  30. package/src/client/locales.ts +893 -893
  31. package/src/client/manage-locales.ts +178 -178
  32. package/src/client/manage.tsx +430 -430
  33. package/src/client/panel.tsx +131 -131
  34. package/src/config.ts +43 -43
  35. package/src/host/health/attribution.ts +118 -118
  36. package/src/host/health/lossless.ts +128 -128
  37. package/src/host/health/source-kind.ts +350 -350
  38. package/src/host/history/archive-source.ts +250 -250
  39. package/src/host/history/archive.ts +249 -249
  40. package/src/host/history/favorites.ts +71 -71
  41. package/src/host/history/purge.ts +370 -370
  42. package/src/host/registry/archive-registry.ts +317 -317
  43. package/src/host/settings-bridge.ts +222 -222
@@ -1,430 +1,430 @@
1
- /**
2
- * 批量管理台(workspace 视角)—— 管家面板「管理」页签的主体。
3
- *
4
- * beta.5 评审:管理是管家职能,不该出现在搜索面板的模式切换里;本组件从
5
- * search/panel.tsx 抽出,挂在 StewardPanel 第三页签。样式自带注入(幂等),
6
- * 与搜索面板是否启用解耦。
7
- *
8
- * 数据面:list-sessions 读统一索引语料(活跃+归档,archived 随行);
9
- * 动作面:session-history-archive / prune / purge(写官方存储文件,重启后
10
- * 侧栏完全生效——宿主半在成功路径上已同步联动索引)。
11
- */
12
- import { createElement, useEffect, useMemo, useState, type ReactElement } from 'react'
13
- import { callSteward, callStewardItems, type HostSessionItem } from './host-api.ts'
14
- import { translate, type LocaleKey } from './manage-locales.ts'
15
-
16
- /** 管理台字典面(search 子域命名空间的键集)。 */
17
- export type ManageTranslate = (key: LocaleKey, params?: Record<string, unknown>) => string
18
-
19
- /** 样式(幂等注入;类名沿用 dmss_ 前缀,与搜索面板共享视觉语言)。 */
20
- function injectManageStyles(): () => void {
21
- if (typeof document === 'undefined') return () => {}
22
- if (document.querySelector('style[data-plugin-css="dsh-manage-session/manage"]') !== null) return () => {}
23
- const tag = document.createElement('style')
24
- tag.dataset.plugin = 'dsh-manage-session'
25
- tag.dataset.pluginCss = 'dsh-manage-session/manage'
26
- tag.textContent = `
27
- .dmss_manageRoot{display:flex;flex-direction:column;flex:1 1 auto;min-height:0;padding:0}
28
- .dmss_manageList{flex:1 1 auto;min-height:0;overflow-y:auto;margin:8px 0 0;padding:0 6px 10px}
29
- .dmss_group{margin-bottom:10px}
30
- .dmss_groupHead{position:sticky;top:0;z-index:2;display:flex;align-items:center;gap:8px;padding:6px 8px;background:var(--dsw-specific-tip);border-bottom:1px solid var(--dsw-alias-border-l2);cursor:pointer;user-select:none}
31
- .dmss_groupTitle{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px;font-weight:600;color:var(--dsw-alias-label-secondary)}
32
- .dmss_groupCount{flex:none;color:var(--dsw-alias-label-caption);font-size:11px;font-variant-numeric:tabular-nums}
33
- /* 会话卡片网格:一行三张(1/3 宽),窄屏降两列 / 单列。 */
34
- .dmss_grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px;padding:8px}
35
- @media (max-width:1180px){.dmss_grid{grid-template-columns:repeat(2,minmax(0,1fr))}}
36
- @media (max-width:760px){.dmss_grid{grid-template-columns:minmax(0,1fr)}}
37
- /* 卡片本体:标题行 + 工作区元信息两行,整块可点(打开会话)。 */
38
- .dmss_row{box-sizing:border-box;display:flex;flex-direction:column;align-items:stretch;gap:4px;min-width:0;height:auto;margin:0;padding:9px 11px;border:1px solid var(--dsw-alias-border-l2);border-radius:12px;background:var(--dsw-alias-bg-layer-3,rgba(127,127,127,.05));color:var(--dsw-alias-label-primary);font:inherit;text-align:left;cursor:pointer;transition:border-color .14s ease-out,background-color .14s ease-out}
39
- .dmss_row:hover{border-color:var(--dsw-alias-state-business-primary);background:var(--dsw-alias-interactive-bg-hover)}
40
- .dmss_rowTitle{display:flex;align-items:center;gap:6px;min-width:0;width:100%}
41
- .dmss_titleText{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:13px;line-height:18px;font-weight:500}
42
- .dmss_tag{flex:none;color:var(--dsw-alias-label-caption);font-size:11px;line-height:16px;white-space:nowrap;font-variant-numeric:tabular-nums}
43
- .dmss_meta{display:block;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:16px}
44
- .dmss_check{flex:none;display:inline-flex;align-items:center}
45
- .dmss_check input{width:13px;height:13px;accent-color:var(--dsw-alias-state-business-primary);cursor:pointer}
46
- .dmss_tagArch{flex:none;color:var(--dsw-alias-state-warn-label,var(--dsw-alias-label-caption));font-size:10px;line-height:16px;border:1px solid currentColor;border-radius:999px;padding:0 6px;white-space:nowrap}
47
- .dmss_batchBar{flex:none;display:flex;align-items:center;gap:8px;flex-wrap:wrap;padding:8px 10px;border-top:1px solid var(--dsw-alias-border-l1)}
48
- .dmss_batchInfo{flex:1 1 auto;min-width:0;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px}
49
- .dmss_actBtn{height:26px;box-sizing:border-box;border:1px solid var(--dsw-alias-border-l2);background:transparent;color:var(--dsw-alias-label-primary);cursor:pointer;border-radius:8px;padding:0 10px;font-size:12px;line-height:24px;white-space:nowrap}
50
- .dmss_actBtn:hover{background:var(--dsw-alias-interactive-bg-hover)}
51
- .dmss_actBtn:disabled{cursor:not-allowed;opacity:.5}
52
- .dmss_btnDanger{border-color:var(--dsw-alias-state-error-primary);color:var(--dsw-alias-state-error-primary)}
53
- .dmss_btnDangerSolid{background:var(--dsw-alias-state-error-primary);border-color:var(--dsw-alias-state-error-primary);color:#fff;font-weight:600}
54
- .dmss_btnDangerSolid:hover{background:var(--dsw-alias-state-error-primary);filter:brightness(1.08);color:#fff}
55
- .dmss_manageHint{color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px;padding:8px 10px 0}
56
- .dmss_chips{display:flex;align-items:center;gap:6px;padding:8px 10px 0;flex:none;flex-wrap:wrap}
57
- .dmss_chip{height:24px;box-sizing:border-box;border:1px solid var(--dsw-alias-border-l2);background:transparent;color:var(--dsw-alias-label-secondary);cursor:pointer;border-radius:999px;padding:0 10px;font-size:12px;font-weight:500;line-height:22px;white-space:nowrap}
58
- .dmss_chip:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
59
- .dmss_chipActive{background:var(--dsw-alias-state-business-primary);border-color:var(--dsw-alias-state-business-primary);color:var(--dsw-alias-label-primary)}
60
- .dmss_search{height:24px;box-sizing:border-box;border:1px solid var(--dsw-alias-border-l2);background:transparent;color:var(--dsw-alias-label-primary);border-radius:8px;padding:0 8px;font-size:12px;line-height:22px}
61
- .dmss_search:focus{outline:none;border-color:var(--dsw-alias-state-business-primary)}
62
- .dmss_status{color:var(--dsw-alias-label-tertiary);padding:10px 8px 8px;font-size:12px;line-height:18px}
63
- .dmss_empty{color:var(--dsw-alias-label-tertiary);padding:10px 8px 8px;font-size:12px;line-height:18px}
64
- .dmss_error{color:var(--dsw-alias-state-error-primary);padding:8px;font-size:12px;line-height:18px}
65
- `
66
- document.head.appendChild(tag)
67
- return () => {
68
- if (tag.parentNode !== null) tag.parentNode.removeChild(tag)
69
- }
70
- }
71
-
72
- /** 批量管理台主体(自持语料/勾选/批量状态)。 */
73
- export function ManageConsole({ t, open }: { t?: ManageTranslate; open: (sessionId: string) => void }): ReactElement {
74
- const [sessions, setSessions] = useState<HostSessionItem[] | null>(null)
75
- const [sessionsError, setSessionsError] = useState<string | null>(null)
76
- const [selected, setSelected] = useState<ReadonlySet<string>>(new Set())
77
- const [batch, setBatch] = useState<{ busy: boolean; message: string; ok: boolean }>({ busy: false, message: '', ok: true })
78
- const [confirmPurge, setConfirmPurge] = useState(false)
79
- // 检索域过滤(全部/活跃/归档)——与搜索面板同一组 chip 语义。
80
- const [domain, setDomain] = useState<'all' | 'active' | 'archived'>('all')
81
- // 收藏(R6):host 侧 JSON 域,挂载取一次,星标切换就地更新。
82
- const [favorites, setFavorites] = useState<ReadonlySet<string>>(new Set())
83
- const [favoritesOnly, setFavoritesOnly] = useState(false)
84
- // workspace 定位(Quest 板布局):'' = 全部;选中的组单独显示。文本框过滤标题/路径。
85
- const [workspaceFilter, setWorkspaceFilter] = useState('')
86
- const [textFilter, setTextFilter] = useState('')
87
- const [showAllWorkspaces, setShowAllWorkspaces] = useState(false)
88
- // 分组收拢:勾选的组键(cwd);定位 chip 选中时该组自动展开。
89
- const [collapsedGroups, setCollapsedGroups] = useState<ReadonlySet<string>>(new Set())
90
-
91
- // 收藏集合:挂载取一次。
92
- useEffect(() => {
93
- let cancelled = false
94
- void callSteward<{ favorites?: string[] }>('session-history-favorites-list', {}).then((res) => {
95
- if (cancelled || !res.ok || !Array.isArray(res.favorites)) return
96
- setFavorites(new Set(res.favorites))
97
- })
98
- return () => { cancelled = true }
99
- }, [])
100
-
101
- /** 星标切换:写 host 收藏域,就地更新本地集合。 */
102
- const toggleFavorite = (sessionId: string): void => {
103
- const next = !favorites.has(sessionId)
104
- setFavorites(prev => {
105
- const copy = new Set(prev)
106
- if (next) copy.add(sessionId)
107
- else copy.delete(sessionId)
108
- return copy
109
- })
110
- void callSteward('session-history-favorite-set', { sessionId, favorite: next })
111
- }
112
-
113
- // 语料:挂载取一次;批量成功后 setSessions(null) 重取(宿主半已联动索引)。
114
- useEffect(() => {
115
- if (sessions !== null) return
116
- let cancelled = false
117
- callStewardItems<HostSessionItem>('session-catalog', {}).then((res) => {
118
- if (cancelled) return
119
- if (res.ok) { setSessions(res.items); setSelected(new Set()); setConfirmPurge(false) }
120
- else setSessionsError(res.error ?? '读取会话列表失败')
121
- })
122
- return () => { cancelled = true }
123
- }, [sessions])
124
-
125
- // workspace(cwd)分组,未分组排最后;域过滤(archived 缺省按活跃对待,兼容旧宿主半)。
126
- const manageGroups = useMemo<{ cwd: string; items: HostSessionItem[] }[]>(() => {
127
- if (sessions === null) return []
128
- const byCwd = new Map<string, HostSessionItem[]>()
129
- for (const item of sessions) {
130
- if (domain === 'active' && item.archived === true) continue
131
- if (domain === 'archived' && item.archived !== true) continue
132
- if (favoritesOnly && !favorites.has(item.sessionId)) continue
133
- if (workspaceFilter !== '' && item.cwd !== workspaceFilter) continue
134
- const list = byCwd.get(item.cwd)
135
- if (list === undefined) byCwd.set(item.cwd, [item])
136
- else list.push(item)
137
- }
138
- return [...byCwd.entries()]
139
- .sort((a, b) => (a[0] === '' ? 1 : 0) - (b[0] === '' ? 1 : 0) || a[0].localeCompare(b[0]))
140
- .map(([cwd, items]) => ({ cwd, items }))
141
- }, [sessions, domain, favoritesOnly, favorites, workspaceFilter])
142
-
143
- /** workspace chip 数据:全部/各 workspace 计数,按计数降序,未分组最后。 */
144
- const workspaceChips = useMemo<{ cwd: string; label: string; count: number }[]>(() => {
145
- if (sessions === null) return []
146
- const counts = new Map<string, number>()
147
- for (const item of sessions) {
148
- if (domain === 'active' && item.archived === true) continue
149
- if (domain === 'archived' && item.archived !== true) continue
150
- if (favoritesOnly && !favorites.has(item.sessionId)) continue
151
- counts.set(item.cwd, (counts.get(item.cwd) ?? 0) + 1)
152
- }
153
- return [...counts.entries()]
154
- .sort((a, b) => (a[0] === '' ? 1 : 0) - (b[0] === '' ? 1 : 0) || (b[1] - a[1]))
155
- .map(([cwd, count]) => ({
156
- cwd,
157
- count,
158
- label: cwd === '' ? translate(t, 'manage.group.nocwd') : (cwd.split(/[\/]/).pop() ?? cwd),
159
- }))
160
- }, [sessions, domain, favoritesOnly, favorites, t])
161
-
162
- /** chip 折叠:默认最多 8 个,超出折叠进「还有 N 个」。 */
163
- const visibleChips = useMemo(() => {
164
- if (showAllWorkspaces) return { chips: workspaceChips, hidden: 0 }
165
- return { chips: workspaceChips.slice(0, 8), hidden: Math.max(0, workspaceChips.length - 8) }
166
- }, [workspaceChips, showAllWorkspaces])
167
-
168
- /** 文本过滤:标题/cwd 子串,大小写折叠。 */
169
- const matchesText = (item: HostSessionItem): boolean => {
170
- const needle = textFilter.trim().toLowerCase()
171
- if (needle === '') return true
172
- return item.title.toLowerCase().includes(needle) || item.cwd.toLowerCase().includes(needle)
173
- }
174
-
175
- const toggleCollapsed = (key: string): void => {
176
- setCollapsedGroups(prev => {
177
- const next = new Set(prev)
178
- if (next.has(key)) next.delete(key)
179
- else next.add(key)
180
- return next
181
- })
182
- }
183
-
184
- const setAllCollapsed = (collapsed: boolean): void => {
185
- setCollapsedGroups(collapsed ? new Set(manageGroups.map(group => group.cwd)) : new Set())
186
- }
187
-
188
- // 定位 chip 选中某 workspace 时自动展开该组。
189
- useEffect(() => {
190
- if (workspaceFilter === '') return
191
- setCollapsedGroups(prev => {
192
- if (!prev.has(workspaceFilter)) return prev
193
- const next = new Set(prev)
194
- next.delete(workspaceFilter)
195
- return next
196
- })
197
- }, [workspaceFilter])
198
-
199
- /** 批量动作;删除两步确认。 */
200
- const runBatch = (kind: 'archive' | 'unarchive' | 'purge'): void => {
201
- const ids = [...selected]
202
- if (ids.length === 0 || batch.busy) return
203
- if (kind === 'purge' && !confirmPurge) { setConfirmPurge(true); return }
204
- setConfirmPurge(false)
205
- setBatch({ busy: true, message: '', ok: true })
206
- const method = kind === 'archive'
207
- ? 'session-history-archive'
208
- : kind === 'unarchive' ? 'session-history-prune' : 'session-history-purge'
209
- void callSteward<{ added?: number; removed?: number; purged?: number; requiresRestart?: boolean }>(method, { sessionIds: ids }).then((res) => {
210
- const base = !res.ok
211
- ? translate(t, 'manage.done.error', { error: res.error ?? '?' })
212
- : kind === 'archive'
213
- ? translate(t, 'manage.done.archive', { n: res.added ?? ids.length })
214
- : kind === 'unarchive'
215
- ? translate(t, 'manage.done.unarchive', { n: res.removed ?? ids.length })
216
- : translate(t, 'manage.done.purge', { n: res.purged ?? ids.length })
217
- // 及时性如实呈现:内存路径即时生效;文件降级路径才需要重启。
218
- const message = res.ok === true && res.requiresRestart === true
219
- ? `${base} ${translate(t, 'manage.done.restart')}`
220
- : base
221
- setBatch({ busy: false, message, ok: res.ok === true })
222
- if (res.ok) {
223
- setSelected(new Set())
224
- setSessions(null)
225
- }
226
- })
227
- }
228
-
229
- const toggleOne = (sessionId: string): void => {
230
- setSelected(prev => {
231
- const next = new Set(prev)
232
- if (next.has(sessionId)) next.delete(sessionId)
233
- else next.add(sessionId)
234
- return next
235
- })
236
- }
237
-
238
- const toggleGroup = (items: readonly HostSessionItem[]): void => {
239
- setSelected(prev => {
240
- const next = new Set(prev)
241
- const allIn = items.every(item => next.has(item.sessionId))
242
- for (const item of items) {
243
- if (allIn) next.delete(item.sessionId)
244
- else next.add(item.sessionId)
245
- }
246
- return next
247
- })
248
- }
249
-
250
- useEffect(() => { injectManageStyles() }, [])
251
-
252
- if (sessionsError !== null) {
253
- return createElement('div', { className: 'dmss_error' }, sessionsError)
254
- }
255
- if (sessions === null) {
256
- return createElement('div', { className: 'dmss_status' }, translate(t, 'panel.loadingSessions'))
257
- }
258
- if (sessions !== null && manageGroups.length === 0 && (workspaceFilter !== '' || textFilter.trim() !== '' || domain !== 'all' || favoritesOnly)) {
259
- return createElement('div', { className: 'dmss_empty' }, translate(t, 'panel.noMatch'))
260
- }
261
- if (manageGroups.length === 0) {
262
- return createElement('div', { className: 'dmss_empty' }, translate(t, 'panel.noSessions'))
263
- }
264
-
265
- const children: ReactElement[] = [
266
- createElement('input', {
267
- key: 'search',
268
- className: 'dmss_search',
269
- style: { margin: '8px 10px 0', flex: 'none' },
270
- type: 'text',
271
- placeholder: translate(t, 'manage.search'),
272
- value: textFilter,
273
- onChange: (e: { target: { value: string } }) => { setTextFilter(e.target.value) },
274
- }),
275
- createElement('div', { key: 'wsChips', className: 'dmss_chips', role: 'group', 'aria-label': translate(t, 'workspace.label') }, [
276
- createElement('span', { key: 'label', style: { flex: 'none', fontSize: '12px', color: 'var(--dsw-alias-label-caption)' } }, translate(t, 'workspace.label')),
277
- createElement('button', {
278
- key: 'all',
279
- type: 'button',
280
- className: `dmss_chip${workspaceFilter === '' ? ' dmss_chipActive' : ''}`,
281
- 'aria-pressed': workspaceFilter === '',
282
- onClick: () => { setWorkspaceFilter('') },
283
- }, `${translate(t, 'domain.all')} (${sessions?.length ?? 0})`),
284
- ...visibleChips.chips.map(chip => createElement('button', {
285
- key: chip.cwd === '' ? '(nocwd)' : chip.cwd,
286
- type: 'button',
287
- className: `dmss_chip${workspaceFilter === chip.cwd ? ' dmss_chipActive' : ''}`,
288
- 'aria-pressed': workspaceFilter === chip.cwd,
289
- onClick: () => { setWorkspaceFilter(current => (current === chip.cwd ? '' : chip.cwd)) },
290
- }, `${chip.label} (${chip.count})`)),
291
- visibleChips.hidden > 0 && createElement('button', {
292
- key: 'more', type: 'button', className: 'dmss_chip',
293
- onClick: () => { setShowAllWorkspaces(true) },
294
- }, translate(t, 'workspace.more', { n: visibleChips.hidden })),
295
- showAllWorkspaces && workspaceChips.length > 8 && createElement('button', {
296
- key: 'less', type: 'button', className: 'dmss_chip',
297
- onClick: () => { setShowAllWorkspaces(false) },
298
- }, translate(t, 'workspace.less')),
299
- createElement('span', { key: 'gap', style: { flex: 1 } }),
300
- manageGroups.length > 1 && createElement('button', {
301
- key: 'collapseAll', type: 'button', className: 'dmss_chip',
302
- onClick: () => { setAllCollapsed(true) },
303
- }, translate(t, 'group.collapseAll')),
304
- manageGroups.length > 1 && createElement('button', {
305
- key: 'expandAll', type: 'button', className: 'dmss_chip',
306
- onClick: () => { setAllCollapsed(false) },
307
- }, translate(t, 'group.expandAll')),
308
- ]),
309
- createElement('div', { key: 'chips', className: 'dmss_chips', role: 'group', 'aria-label': translate(t, 'domain.all') }, [
310
- ...(['all', 'active', 'archived'] as const).map(id => createElement('button', {
311
- key: id,
312
- type: 'button',
313
- className: `dmss_chip${domain === id ? ' dmss_chipActive' : ''}`,
314
- 'aria-pressed': domain === id,
315
- onClick: () => { setDomain(id) },
316
- }, translate(t, `domain.${id}` as LocaleKey))),
317
- createElement('button', {
318
- key: 'favorites',
319
- type: 'button',
320
- className: `dmss_chip${favoritesOnly ? ' dmss_chipActive' : ''}`,
321
- 'aria-pressed': favoritesOnly,
322
- onClick: () => { setFavoritesOnly(v => !v) },
323
- }, `★ ${translate(t, 'filter.favorites')}`),
324
- ]),
325
- createElement('div', { key: 'hint', className: 'dmss_manageHint' }, [
326
- translate(t, 'manage.hint'),
327
- createElement('span', { key: 'sep', style: { display: 'block', marginTop: '2px' } }, translate(t, 'manage.restartHint')),
328
- ]),
329
- createElement('div', { key: 'groups', className: 'dmss_manageList' },
330
- ...manageGroups.map(group => createElement('div', { key: group.cwd === '' ? '(nocwd)' : group.cwd, className: 'dmss_group' }, [
331
- createElement('div', {
332
- key: 'head',
333
- className: 'dmss_groupHead',
334
- title: translate(t, 'group.collapse'),
335
- onClick: () => { toggleCollapsed(group.cwd) },
336
- }, [
337
- createElement('span', { key: 'chev', style: { flex: 'none', fontSize: '10px', color: 'var(--dsw-alias-label-caption)', transition: 'transform .15s', transform: collapsedGroups.has(group.cwd) ? 'rotate(0deg)' : 'rotate(90deg)' } }, '▶'),
338
- createElement('span', { key: 'check', className: 'dmss_check' },
339
- createElement('input', {
340
- type: 'checkbox',
341
- checked: group.items.length > 0 && group.items.every(item => selected.has(item.sessionId)),
342
- onChange: () => { toggleGroup(group.items) },
343
- onClick: (e: { stopPropagation: () => void }) => { e.stopPropagation() },
344
- title: translate(t, 'manage.selectGroup'),
345
- })),
346
- createElement('span', { key: 'title', className: 'dmss_groupTitle' }, group.cwd === '' ? translate(t, 'manage.group.nocwd') : group.cwd),
347
- createElement('span', { key: 'count', className: 'dmss_groupCount' }, `${group.items.filter(item => matchesText(item)).length}/${group.items.length}`),
348
- ]),
349
- ...(collapsedGroups.has(group.cwd) ? [] : [createElement('div', {
350
- key: 'grid',
351
- className: 'dmss_grid',
352
- }, group.items.filter(item => matchesText(item)).map(item => createElement('div', {
353
- key: item.sessionId,
354
- role: 'button',
355
- tabIndex: 0,
356
- className: 'dmss_row',
357
- title: item.title || translate(t, 'panel.untitled'),
358
- onClick: () => { open(item.sessionId) },
359
- onKeyDown: (e: { key: string }) => { if (e.key === 'Enter' || e.key === ' ') open(item.sessionId) },
360
- }, [
361
- createElement('span', { key: 'line', className: 'dmss_rowTitle' }, [
362
- createElement('span', { key: 'check', className: 'dmss_check' },
363
- createElement('input', {
364
- type: 'checkbox',
365
- checked: selected.has(item.sessionId),
366
- onChange: () => { toggleOne(item.sessionId) },
367
- onClick: (e: { stopPropagation: () => void }) => { e.stopPropagation() },
368
- })),
369
- createElement('button', {
370
- key: 'star',
371
- type: 'button',
372
- className: 'dmss_check',
373
- title: translate(t, favorites.has(item.sessionId) ? 'star.on' : 'star.off'),
374
- style: { border: 'none', background: 'transparent', cursor: 'pointer', fontSize: '13px', lineHeight: 1, padding: '0 2px', color: favorites.has(item.sessionId) ? 'var(--dsw-alias-state-warn-label, #d97706)' : 'var(--dsw-alias-label-caption)' },
375
- onClick: (e: { stopPropagation: () => void }) => { e.stopPropagation(); toggleFavorite(item.sessionId) },
376
- }, favorites.has(item.sessionId) ? '★' : '☆'),
377
- createElement('span', { key: 'x', className: 'dmss_titleText' }, item.title || translate(t, 'panel.untitled')),
378
- item.archived === true && createElement('span', { key: 'arch', className: 'dmss_tagArch' }, translate(t, 'tag.archived')),
379
- createElement('span', { key: 'tag', className: 'dmss_tag' }, fmtTime(item.updatedAt)),
380
- ]),
381
- createElement('span', { key: 'meta', className: 'dmss_meta' }, item.cwd),
382
- ])))]),
383
- ])),
384
- ),
385
- ]
386
-
387
- if (selected.size > 0) {
388
- children.push(createElement('div', { key: 'batch', className: 'dmss_batchBar' }, [
389
- createElement('span', { key: 'info', className: 'dmss_batchInfo' }, batch.message !== ''
390
- ? batch.message
391
- : translate(t, 'manage.selected', { n: selected.size })),
392
- createElement('button', {
393
- key: 'archive', type: 'button', className: 'dmss_actBtn', disabled: batch.busy,
394
- onClick: () => { runBatch('archive') },
395
- }, translate(t, 'manage.batch.archive')),
396
- createElement('button', {
397
- key: 'unarchive', type: 'button', className: 'dmss_actBtn', disabled: batch.busy,
398
- onClick: () => { runBatch('unarchive') },
399
- }, translate(t, 'manage.batch.unarchive')),
400
- createElement('button', {
401
- key: 'purge', type: 'button',
402
- // 两步确认的第二步:红底白字实心(对齐宿主原生危险按钮,用户定标
403
- // 2026-10-20)——白字不赌主题令牌(tip 色在亮色主题下对比度不足)。
404
- className: `dmss_actBtn${confirmPurge ? ' dmss_btnDangerSolid' : ' dmss_btnDanger'}`, disabled: batch.busy,
405
- onClick: () => { runBatch('purge') },
406
- }, confirmPurge ? translate(t, 'manage.batch.confirm') : translate(t, 'manage.batch.delete')),
407
- batch.busy && createElement('span', { key: 'busy', className: 'dmss_batchInfo' }, translate(t, 'manage.batch.working')),
408
- ]))
409
- }
410
-
411
- return createElement('div', { className: 'dmss_manageRoot', style: { display: 'flex', flexDirection: 'column', flex: '1 1 auto', minHeight: 0 } }, children)
412
- }
413
-
414
- /** Format an epoch-ms timestamp: today → HH:mm, else YYYY-MM-DD HH:mm. */
415
- function fmtTime(ms: number): string {
416
- if (!ms || typeof ms !== 'number') return ''
417
- try {
418
- const d = new Date(ms)
419
- const now = new Date()
420
- const pad = (n: number): string => String(n).padStart(2, '0')
421
- const sameDay = d.getFullYear() === now.getFullYear()
422
- && d.getMonth() === now.getMonth()
423
- && d.getDate() === now.getDate()
424
- const time = `${pad(d.getHours())}:${pad(d.getMinutes())}`
425
- if (sameDay) return time
426
- return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${time}`
427
- } catch {
428
- return ''
429
- }
430
- }
1
+ /**
2
+ * 批量管理台(workspace 视角)—— 管家面板「管理」页签的主体。
3
+ *
4
+ * beta.5 评审:管理是管家职能,不该出现在搜索面板的模式切换里;本组件从
5
+ * search/panel.tsx 抽出,挂在 StewardPanel 第三页签。样式自带注入(幂等),
6
+ * 与搜索面板是否启用解耦。
7
+ *
8
+ * 数据面:list-sessions 读统一索引语料(活跃+归档,archived 随行);
9
+ * 动作面:session-history-archive / prune / purge(写官方存储文件,重启后
10
+ * 侧栏完全生效——宿主半在成功路径上已同步联动索引)。
11
+ */
12
+ import { createElement, useEffect, useMemo, useState, type ReactElement } from 'react'
13
+ import { callSteward, callStewardItems, type HostSessionItem } from './host-api.ts'
14
+ import { translate, type LocaleKey } from './manage-locales.ts'
15
+
16
+ /** 管理台字典面(search 子域命名空间的键集)。 */
17
+ export type ManageTranslate = (key: LocaleKey, params?: Record<string, unknown>) => string
18
+
19
+ /** 样式(幂等注入;类名沿用 dmss_ 前缀,与搜索面板共享视觉语言)。 */
20
+ function injectManageStyles(): () => void {
21
+ if (typeof document === 'undefined') return () => {}
22
+ if (document.querySelector('style[data-plugin-css="dsh-manage-session/manage"]') !== null) return () => {}
23
+ const tag = document.createElement('style')
24
+ tag.dataset.plugin = 'dsh-manage-session'
25
+ tag.dataset.pluginCss = 'dsh-manage-session/manage'
26
+ tag.textContent = `
27
+ .dmss_manageRoot{display:flex;flex-direction:column;flex:1 1 auto;min-height:0;padding:0}
28
+ .dmss_manageList{flex:1 1 auto;min-height:0;overflow-y:auto;margin:8px 0 0;padding:0 6px 10px}
29
+ .dmss_group{margin-bottom:10px}
30
+ .dmss_groupHead{position:sticky;top:0;z-index:2;display:flex;align-items:center;gap:8px;padding:6px 8px;background:var(--dsw-specific-tip);border-bottom:1px solid var(--dsw-alias-border-l2);cursor:pointer;user-select:none}
31
+ .dmss_groupTitle{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px;font-weight:600;color:var(--dsw-alias-label-secondary)}
32
+ .dmss_groupCount{flex:none;color:var(--dsw-alias-label-caption);font-size:11px;font-variant-numeric:tabular-nums}
33
+ /* 会话卡片网格:一行三张(1/3 宽),窄屏降两列 / 单列。 */
34
+ .dmss_grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px;padding:8px}
35
+ @media (max-width:1180px){.dmss_grid{grid-template-columns:repeat(2,minmax(0,1fr))}}
36
+ @media (max-width:760px){.dmss_grid{grid-template-columns:minmax(0,1fr)}}
37
+ /* 卡片本体:标题行 + 工作区元信息两行,整块可点(打开会话)。 */
38
+ .dmss_row{box-sizing:border-box;display:flex;flex-direction:column;align-items:stretch;gap:4px;min-width:0;height:auto;margin:0;padding:9px 11px;border:1px solid var(--dsw-alias-border-l2);border-radius:12px;background:var(--dsw-alias-bg-layer-3,rgba(127,127,127,.05));color:var(--dsw-alias-label-primary);font:inherit;text-align:left;cursor:pointer;transition:border-color .14s ease-out,background-color .14s ease-out}
39
+ .dmss_row:hover{border-color:var(--dsw-alias-state-business-primary);background:var(--dsw-alias-interactive-bg-hover)}
40
+ .dmss_rowTitle{display:flex;align-items:center;gap:6px;min-width:0;width:100%}
41
+ .dmss_titleText{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:13px;line-height:18px;font-weight:500}
42
+ .dmss_tag{flex:none;color:var(--dsw-alias-label-caption);font-size:11px;line-height:16px;white-space:nowrap;font-variant-numeric:tabular-nums}
43
+ .dmss_meta{display:block;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:16px}
44
+ .dmss_check{flex:none;display:inline-flex;align-items:center}
45
+ .dmss_check input{width:13px;height:13px;accent-color:var(--dsw-alias-state-business-primary);cursor:pointer}
46
+ .dmss_tagArch{flex:none;color:var(--dsw-alias-state-warn-label,var(--dsw-alias-label-caption));font-size:10px;line-height:16px;border:1px solid currentColor;border-radius:999px;padding:0 6px;white-space:nowrap}
47
+ .dmss_batchBar{flex:none;display:flex;align-items:center;gap:8px;flex-wrap:wrap;padding:8px 10px;border-top:1px solid var(--dsw-alias-border-l1)}
48
+ .dmss_batchInfo{flex:1 1 auto;min-width:0;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px}
49
+ .dmss_actBtn{height:26px;box-sizing:border-box;border:1px solid var(--dsw-alias-border-l2);background:transparent;color:var(--dsw-alias-label-primary);cursor:pointer;border-radius:8px;padding:0 10px;font-size:12px;line-height:24px;white-space:nowrap}
50
+ .dmss_actBtn:hover{background:var(--dsw-alias-interactive-bg-hover)}
51
+ .dmss_actBtn:disabled{cursor:not-allowed;opacity:.5}
52
+ .dmss_btnDanger{border-color:var(--dsw-alias-state-error-primary);color:var(--dsw-alias-state-error-primary)}
53
+ .dmss_btnDangerSolid{background:var(--dsw-alias-state-error-primary);border-color:var(--dsw-alias-state-error-primary);color:#fff;font-weight:600}
54
+ .dmss_btnDangerSolid:hover{background:var(--dsw-alias-state-error-primary);filter:brightness(1.08);color:#fff}
55
+ .dmss_manageHint{color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px;padding:8px 10px 0}
56
+ .dmss_chips{display:flex;align-items:center;gap:6px;padding:8px 10px 0;flex:none;flex-wrap:wrap}
57
+ .dmss_chip{height:24px;box-sizing:border-box;border:1px solid var(--dsw-alias-border-l2);background:transparent;color:var(--dsw-alias-label-secondary);cursor:pointer;border-radius:999px;padding:0 10px;font-size:12px;font-weight:500;line-height:22px;white-space:nowrap}
58
+ .dmss_chip:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
59
+ .dmss_chipActive{background:var(--dsw-alias-state-business-primary);border-color:var(--dsw-alias-state-business-primary);color:var(--dsw-alias-label-primary)}
60
+ .dmss_search{height:24px;box-sizing:border-box;border:1px solid var(--dsw-alias-border-l2);background:transparent;color:var(--dsw-alias-label-primary);border-radius:8px;padding:0 8px;font-size:12px;line-height:22px}
61
+ .dmss_search:focus{outline:none;border-color:var(--dsw-alias-state-business-primary)}
62
+ .dmss_status{color:var(--dsw-alias-label-tertiary);padding:10px 8px 8px;font-size:12px;line-height:18px}
63
+ .dmss_empty{color:var(--dsw-alias-label-tertiary);padding:10px 8px 8px;font-size:12px;line-height:18px}
64
+ .dmss_error{color:var(--dsw-alias-state-error-primary);padding:8px;font-size:12px;line-height:18px}
65
+ `
66
+ document.head.appendChild(tag)
67
+ return () => {
68
+ if (tag.parentNode !== null) tag.parentNode.removeChild(tag)
69
+ }
70
+ }
71
+
72
+ /** 批量管理台主体(自持语料/勾选/批量状态)。 */
73
+ export function ManageConsole({ t, open }: { t?: ManageTranslate; open: (sessionId: string) => void }): ReactElement {
74
+ const [sessions, setSessions] = useState<HostSessionItem[] | null>(null)
75
+ const [sessionsError, setSessionsError] = useState<string | null>(null)
76
+ const [selected, setSelected] = useState<ReadonlySet<string>>(new Set())
77
+ const [batch, setBatch] = useState<{ busy: boolean; message: string; ok: boolean }>({ busy: false, message: '', ok: true })
78
+ const [confirmPurge, setConfirmPurge] = useState(false)
79
+ // 检索域过滤(全部/活跃/归档)——与搜索面板同一组 chip 语义。
80
+ const [domain, setDomain] = useState<'all' | 'active' | 'archived'>('all')
81
+ // 收藏(R6):host 侧 JSON 域,挂载取一次,星标切换就地更新。
82
+ const [favorites, setFavorites] = useState<ReadonlySet<string>>(new Set())
83
+ const [favoritesOnly, setFavoritesOnly] = useState(false)
84
+ // workspace 定位(Quest 板布局):'' = 全部;选中的组单独显示。文本框过滤标题/路径。
85
+ const [workspaceFilter, setWorkspaceFilter] = useState('')
86
+ const [textFilter, setTextFilter] = useState('')
87
+ const [showAllWorkspaces, setShowAllWorkspaces] = useState(false)
88
+ // 分组收拢:勾选的组键(cwd);定位 chip 选中时该组自动展开。
89
+ const [collapsedGroups, setCollapsedGroups] = useState<ReadonlySet<string>>(new Set())
90
+
91
+ // 收藏集合:挂载取一次。
92
+ useEffect(() => {
93
+ let cancelled = false
94
+ void callSteward<{ favorites?: string[] }>('session-history-favorites-list', {}).then((res) => {
95
+ if (cancelled || !res.ok || !Array.isArray(res.favorites)) return
96
+ setFavorites(new Set(res.favorites))
97
+ })
98
+ return () => { cancelled = true }
99
+ }, [])
100
+
101
+ /** 星标切换:写 host 收藏域,就地更新本地集合。 */
102
+ const toggleFavorite = (sessionId: string): void => {
103
+ const next = !favorites.has(sessionId)
104
+ setFavorites(prev => {
105
+ const copy = new Set(prev)
106
+ if (next) copy.add(sessionId)
107
+ else copy.delete(sessionId)
108
+ return copy
109
+ })
110
+ void callSteward('session-history-favorite-set', { sessionId, favorite: next })
111
+ }
112
+
113
+ // 语料:挂载取一次;批量成功后 setSessions(null) 重取(宿主半已联动索引)。
114
+ useEffect(() => {
115
+ if (sessions !== null) return
116
+ let cancelled = false
117
+ callStewardItems<HostSessionItem>('session-catalog', {}).then((res) => {
118
+ if (cancelled) return
119
+ if (res.ok) { setSessions(res.items); setSelected(new Set()); setConfirmPurge(false) }
120
+ else setSessionsError(res.error ?? '读取会话列表失败')
121
+ })
122
+ return () => { cancelled = true }
123
+ }, [sessions])
124
+
125
+ // workspace(cwd)分组,未分组排最后;域过滤(archived 缺省按活跃对待,兼容旧宿主半)。
126
+ const manageGroups = useMemo<{ cwd: string; items: HostSessionItem[] }[]>(() => {
127
+ if (sessions === null) return []
128
+ const byCwd = new Map<string, HostSessionItem[]>()
129
+ for (const item of sessions) {
130
+ if (domain === 'active' && item.archived === true) continue
131
+ if (domain === 'archived' && item.archived !== true) continue
132
+ if (favoritesOnly && !favorites.has(item.sessionId)) continue
133
+ if (workspaceFilter !== '' && item.cwd !== workspaceFilter) continue
134
+ const list = byCwd.get(item.cwd)
135
+ if (list === undefined) byCwd.set(item.cwd, [item])
136
+ else list.push(item)
137
+ }
138
+ return [...byCwd.entries()]
139
+ .sort((a, b) => (a[0] === '' ? 1 : 0) - (b[0] === '' ? 1 : 0) || a[0].localeCompare(b[0]))
140
+ .map(([cwd, items]) => ({ cwd, items }))
141
+ }, [sessions, domain, favoritesOnly, favorites, workspaceFilter])
142
+
143
+ /** workspace chip 数据:全部/各 workspace 计数,按计数降序,未分组最后。 */
144
+ const workspaceChips = useMemo<{ cwd: string; label: string; count: number }[]>(() => {
145
+ if (sessions === null) return []
146
+ const counts = new Map<string, number>()
147
+ for (const item of sessions) {
148
+ if (domain === 'active' && item.archived === true) continue
149
+ if (domain === 'archived' && item.archived !== true) continue
150
+ if (favoritesOnly && !favorites.has(item.sessionId)) continue
151
+ counts.set(item.cwd, (counts.get(item.cwd) ?? 0) + 1)
152
+ }
153
+ return [...counts.entries()]
154
+ .sort((a, b) => (a[0] === '' ? 1 : 0) - (b[0] === '' ? 1 : 0) || (b[1] - a[1]))
155
+ .map(([cwd, count]) => ({
156
+ cwd,
157
+ count,
158
+ label: cwd === '' ? translate(t, 'manage.group.nocwd') : (cwd.split(/[\/]/).pop() ?? cwd),
159
+ }))
160
+ }, [sessions, domain, favoritesOnly, favorites, t])
161
+
162
+ /** chip 折叠:默认最多 8 个,超出折叠进「还有 N 个」。 */
163
+ const visibleChips = useMemo(() => {
164
+ if (showAllWorkspaces) return { chips: workspaceChips, hidden: 0 }
165
+ return { chips: workspaceChips.slice(0, 8), hidden: Math.max(0, workspaceChips.length - 8) }
166
+ }, [workspaceChips, showAllWorkspaces])
167
+
168
+ /** 文本过滤:标题/cwd 子串,大小写折叠。 */
169
+ const matchesText = (item: HostSessionItem): boolean => {
170
+ const needle = textFilter.trim().toLowerCase()
171
+ if (needle === '') return true
172
+ return item.title.toLowerCase().includes(needle) || item.cwd.toLowerCase().includes(needle)
173
+ }
174
+
175
+ const toggleCollapsed = (key: string): void => {
176
+ setCollapsedGroups(prev => {
177
+ const next = new Set(prev)
178
+ if (next.has(key)) next.delete(key)
179
+ else next.add(key)
180
+ return next
181
+ })
182
+ }
183
+
184
+ const setAllCollapsed = (collapsed: boolean): void => {
185
+ setCollapsedGroups(collapsed ? new Set(manageGroups.map(group => group.cwd)) : new Set())
186
+ }
187
+
188
+ // 定位 chip 选中某 workspace 时自动展开该组。
189
+ useEffect(() => {
190
+ if (workspaceFilter === '') return
191
+ setCollapsedGroups(prev => {
192
+ if (!prev.has(workspaceFilter)) return prev
193
+ const next = new Set(prev)
194
+ next.delete(workspaceFilter)
195
+ return next
196
+ })
197
+ }, [workspaceFilter])
198
+
199
+ /** 批量动作;删除两步确认。 */
200
+ const runBatch = (kind: 'archive' | 'unarchive' | 'purge'): void => {
201
+ const ids = [...selected]
202
+ if (ids.length === 0 || batch.busy) return
203
+ if (kind === 'purge' && !confirmPurge) { setConfirmPurge(true); return }
204
+ setConfirmPurge(false)
205
+ setBatch({ busy: true, message: '', ok: true })
206
+ const method = kind === 'archive'
207
+ ? 'session-history-archive'
208
+ : kind === 'unarchive' ? 'session-history-prune' : 'session-history-purge'
209
+ void callSteward<{ added?: number; removed?: number; purged?: number; requiresRestart?: boolean }>(method, { sessionIds: ids }).then((res) => {
210
+ const base = !res.ok
211
+ ? translate(t, 'manage.done.error', { error: res.error ?? '?' })
212
+ : kind === 'archive'
213
+ ? translate(t, 'manage.done.archive', { n: res.added ?? ids.length })
214
+ : kind === 'unarchive'
215
+ ? translate(t, 'manage.done.unarchive', { n: res.removed ?? ids.length })
216
+ : translate(t, 'manage.done.purge', { n: res.purged ?? ids.length })
217
+ // 及时性如实呈现:内存路径即时生效;文件降级路径才需要重启。
218
+ const message = res.ok === true && res.requiresRestart === true
219
+ ? `${base} ${translate(t, 'manage.done.restart')}`
220
+ : base
221
+ setBatch({ busy: false, message, ok: res.ok === true })
222
+ if (res.ok) {
223
+ setSelected(new Set())
224
+ setSessions(null)
225
+ }
226
+ })
227
+ }
228
+
229
+ const toggleOne = (sessionId: string): void => {
230
+ setSelected(prev => {
231
+ const next = new Set(prev)
232
+ if (next.has(sessionId)) next.delete(sessionId)
233
+ else next.add(sessionId)
234
+ return next
235
+ })
236
+ }
237
+
238
+ const toggleGroup = (items: readonly HostSessionItem[]): void => {
239
+ setSelected(prev => {
240
+ const next = new Set(prev)
241
+ const allIn = items.every(item => next.has(item.sessionId))
242
+ for (const item of items) {
243
+ if (allIn) next.delete(item.sessionId)
244
+ else next.add(item.sessionId)
245
+ }
246
+ return next
247
+ })
248
+ }
249
+
250
+ useEffect(() => { injectManageStyles() }, [])
251
+
252
+ if (sessionsError !== null) {
253
+ return createElement('div', { className: 'dmss_error' }, sessionsError)
254
+ }
255
+ if (sessions === null) {
256
+ return createElement('div', { className: 'dmss_status' }, translate(t, 'panel.loadingSessions'))
257
+ }
258
+ if (sessions !== null && manageGroups.length === 0 && (workspaceFilter !== '' || textFilter.trim() !== '' || domain !== 'all' || favoritesOnly)) {
259
+ return createElement('div', { className: 'dmss_empty' }, translate(t, 'panel.noMatch'))
260
+ }
261
+ if (manageGroups.length === 0) {
262
+ return createElement('div', { className: 'dmss_empty' }, translate(t, 'panel.noSessions'))
263
+ }
264
+
265
+ const children: ReactElement[] = [
266
+ createElement('input', {
267
+ key: 'search',
268
+ className: 'dmss_search',
269
+ style: { margin: '8px 10px 0', flex: 'none' },
270
+ type: 'text',
271
+ placeholder: translate(t, 'manage.search'),
272
+ value: textFilter,
273
+ onChange: (e: { target: { value: string } }) => { setTextFilter(e.target.value) },
274
+ }),
275
+ createElement('div', { key: 'wsChips', className: 'dmss_chips', role: 'group', 'aria-label': translate(t, 'workspace.label') }, [
276
+ createElement('span', { key: 'label', style: { flex: 'none', fontSize: '12px', color: 'var(--dsw-alias-label-caption)' } }, translate(t, 'workspace.label')),
277
+ createElement('button', {
278
+ key: 'all',
279
+ type: 'button',
280
+ className: `dmss_chip${workspaceFilter === '' ? ' dmss_chipActive' : ''}`,
281
+ 'aria-pressed': workspaceFilter === '',
282
+ onClick: () => { setWorkspaceFilter('') },
283
+ }, `${translate(t, 'domain.all')} (${sessions?.length ?? 0})`),
284
+ ...visibleChips.chips.map(chip => createElement('button', {
285
+ key: chip.cwd === '' ? '(nocwd)' : chip.cwd,
286
+ type: 'button',
287
+ className: `dmss_chip${workspaceFilter === chip.cwd ? ' dmss_chipActive' : ''}`,
288
+ 'aria-pressed': workspaceFilter === chip.cwd,
289
+ onClick: () => { setWorkspaceFilter(current => (current === chip.cwd ? '' : chip.cwd)) },
290
+ }, `${chip.label} (${chip.count})`)),
291
+ visibleChips.hidden > 0 && createElement('button', {
292
+ key: 'more', type: 'button', className: 'dmss_chip',
293
+ onClick: () => { setShowAllWorkspaces(true) },
294
+ }, translate(t, 'workspace.more', { n: visibleChips.hidden })),
295
+ showAllWorkspaces && workspaceChips.length > 8 && createElement('button', {
296
+ key: 'less', type: 'button', className: 'dmss_chip',
297
+ onClick: () => { setShowAllWorkspaces(false) },
298
+ }, translate(t, 'workspace.less')),
299
+ createElement('span', { key: 'gap', style: { flex: 1 } }),
300
+ manageGroups.length > 1 && createElement('button', {
301
+ key: 'collapseAll', type: 'button', className: 'dmss_chip',
302
+ onClick: () => { setAllCollapsed(true) },
303
+ }, translate(t, 'group.collapseAll')),
304
+ manageGroups.length > 1 && createElement('button', {
305
+ key: 'expandAll', type: 'button', className: 'dmss_chip',
306
+ onClick: () => { setAllCollapsed(false) },
307
+ }, translate(t, 'group.expandAll')),
308
+ ]),
309
+ createElement('div', { key: 'chips', className: 'dmss_chips', role: 'group', 'aria-label': translate(t, 'domain.all') }, [
310
+ ...(['all', 'active', 'archived'] as const).map(id => createElement('button', {
311
+ key: id,
312
+ type: 'button',
313
+ className: `dmss_chip${domain === id ? ' dmss_chipActive' : ''}`,
314
+ 'aria-pressed': domain === id,
315
+ onClick: () => { setDomain(id) },
316
+ }, translate(t, `domain.${id}` as LocaleKey))),
317
+ createElement('button', {
318
+ key: 'favorites',
319
+ type: 'button',
320
+ className: `dmss_chip${favoritesOnly ? ' dmss_chipActive' : ''}`,
321
+ 'aria-pressed': favoritesOnly,
322
+ onClick: () => { setFavoritesOnly(v => !v) },
323
+ }, `★ ${translate(t, 'filter.favorites')}`),
324
+ ]),
325
+ createElement('div', { key: 'hint', className: 'dmss_manageHint' }, [
326
+ translate(t, 'manage.hint'),
327
+ createElement('span', { key: 'sep', style: { display: 'block', marginTop: '2px' } }, translate(t, 'manage.restartHint')),
328
+ ]),
329
+ createElement('div', { key: 'groups', className: 'dmss_manageList' },
330
+ ...manageGroups.map(group => createElement('div', { key: group.cwd === '' ? '(nocwd)' : group.cwd, className: 'dmss_group' }, [
331
+ createElement('div', {
332
+ key: 'head',
333
+ className: 'dmss_groupHead',
334
+ title: translate(t, 'group.collapse'),
335
+ onClick: () => { toggleCollapsed(group.cwd) },
336
+ }, [
337
+ createElement('span', { key: 'chev', style: { flex: 'none', fontSize: '10px', color: 'var(--dsw-alias-label-caption)', transition: 'transform .15s', transform: collapsedGroups.has(group.cwd) ? 'rotate(0deg)' : 'rotate(90deg)' } }, '▶'),
338
+ createElement('span', { key: 'check', className: 'dmss_check' },
339
+ createElement('input', {
340
+ type: 'checkbox',
341
+ checked: group.items.length > 0 && group.items.every(item => selected.has(item.sessionId)),
342
+ onChange: () => { toggleGroup(group.items) },
343
+ onClick: (e: { stopPropagation: () => void }) => { e.stopPropagation() },
344
+ title: translate(t, 'manage.selectGroup'),
345
+ })),
346
+ createElement('span', { key: 'title', className: 'dmss_groupTitle' }, group.cwd === '' ? translate(t, 'manage.group.nocwd') : group.cwd),
347
+ createElement('span', { key: 'count', className: 'dmss_groupCount' }, `${group.items.filter(item => matchesText(item)).length}/${group.items.length}`),
348
+ ]),
349
+ ...(collapsedGroups.has(group.cwd) ? [] : [createElement('div', {
350
+ key: 'grid',
351
+ className: 'dmss_grid',
352
+ }, group.items.filter(item => matchesText(item)).map(item => createElement('div', {
353
+ key: item.sessionId,
354
+ role: 'button',
355
+ tabIndex: 0,
356
+ className: 'dmss_row',
357
+ title: item.title || translate(t, 'panel.untitled'),
358
+ onClick: () => { open(item.sessionId) },
359
+ onKeyDown: (e: { key: string }) => { if (e.key === 'Enter' || e.key === ' ') open(item.sessionId) },
360
+ }, [
361
+ createElement('span', { key: 'line', className: 'dmss_rowTitle' }, [
362
+ createElement('span', { key: 'check', className: 'dmss_check' },
363
+ createElement('input', {
364
+ type: 'checkbox',
365
+ checked: selected.has(item.sessionId),
366
+ onChange: () => { toggleOne(item.sessionId) },
367
+ onClick: (e: { stopPropagation: () => void }) => { e.stopPropagation() },
368
+ })),
369
+ createElement('button', {
370
+ key: 'star',
371
+ type: 'button',
372
+ className: 'dmss_check',
373
+ title: translate(t, favorites.has(item.sessionId) ? 'star.on' : 'star.off'),
374
+ style: { border: 'none', background: 'transparent', cursor: 'pointer', fontSize: '13px', lineHeight: 1, padding: '0 2px', color: favorites.has(item.sessionId) ? 'var(--dsw-alias-state-warn-label, #d97706)' : 'var(--dsw-alias-label-caption)' },
375
+ onClick: (e: { stopPropagation: () => void }) => { e.stopPropagation(); toggleFavorite(item.sessionId) },
376
+ }, favorites.has(item.sessionId) ? '★' : '☆'),
377
+ createElement('span', { key: 'x', className: 'dmss_titleText' }, item.title || translate(t, 'panel.untitled')),
378
+ item.archived === true && createElement('span', { key: 'arch', className: 'dmss_tagArch' }, translate(t, 'tag.archived')),
379
+ createElement('span', { key: 'tag', className: 'dmss_tag' }, fmtTime(item.updatedAt)),
380
+ ]),
381
+ createElement('span', { key: 'meta', className: 'dmss_meta' }, item.cwd),
382
+ ])))]),
383
+ ])),
384
+ ),
385
+ ]
386
+
387
+ if (selected.size > 0) {
388
+ children.push(createElement('div', { key: 'batch', className: 'dmss_batchBar' }, [
389
+ createElement('span', { key: 'info', className: 'dmss_batchInfo' }, batch.message !== ''
390
+ ? batch.message
391
+ : translate(t, 'manage.selected', { n: selected.size })),
392
+ createElement('button', {
393
+ key: 'archive', type: 'button', className: 'dmss_actBtn', disabled: batch.busy,
394
+ onClick: () => { runBatch('archive') },
395
+ }, translate(t, 'manage.batch.archive')),
396
+ createElement('button', {
397
+ key: 'unarchive', type: 'button', className: 'dmss_actBtn', disabled: batch.busy,
398
+ onClick: () => { runBatch('unarchive') },
399
+ }, translate(t, 'manage.batch.unarchive')),
400
+ createElement('button', {
401
+ key: 'purge', type: 'button',
402
+ // 两步确认的第二步:红底白字实心(对齐宿主原生危险按钮,用户定标
403
+ // 2026-10-20)——白字不赌主题令牌(tip 色在亮色主题下对比度不足)。
404
+ className: `dmss_actBtn${confirmPurge ? ' dmss_btnDangerSolid' : ' dmss_btnDanger'}`, disabled: batch.busy,
405
+ onClick: () => { runBatch('purge') },
406
+ }, confirmPurge ? translate(t, 'manage.batch.confirm') : translate(t, 'manage.batch.delete')),
407
+ batch.busy && createElement('span', { key: 'busy', className: 'dmss_batchInfo' }, translate(t, 'manage.batch.working')),
408
+ ]))
409
+ }
410
+
411
+ return createElement('div', { className: 'dmss_manageRoot', style: { display: 'flex', flexDirection: 'column', flex: '1 1 auto', minHeight: 0 } }, children)
412
+ }
413
+
414
+ /** Format an epoch-ms timestamp: today → HH:mm, else YYYY-MM-DD HH:mm. */
415
+ function fmtTime(ms: number): string {
416
+ if (!ms || typeof ms !== 'number') return ''
417
+ try {
418
+ const d = new Date(ms)
419
+ const now = new Date()
420
+ const pad = (n: number): string => String(n).padStart(2, '0')
421
+ const sameDay = d.getFullYear() === now.getFullYear()
422
+ && d.getMonth() === now.getMonth()
423
+ && d.getDate() === now.getDate()
424
+ const time = `${pad(d.getHours())}:${pad(d.getMinutes())}`
425
+ if (sameDay) return time
426
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${time}`
427
+ } catch {
428
+ return ''
429
+ }
430
+ }