dsh-sessions-manager 3.2.2 → 3.4.0

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-sessions-manager",
3
3
  "description": "DSH 设置面板会话管理器:归档 / 恢复 / 彻底删除 / 移动到其他工作区,带工作区标签与会话日期;统一「会话管理」面板。Session manager for the DeepSeek Harness settings panel — archive / restore / permanently delete / move sessions across workspaces, with workspace tags & session dates in one unified panel.",
4
- "version": "3.2.2",
4
+ "version": "3.4.0",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "exports": {
@@ -0,0 +1,168 @@
1
+ // Durable "auto-archive" settings (schema v4) + the pure candidate rule.
2
+ //
3
+ // Auto-archive hides conversations that have been idle for N days. It is OFF
4
+ // by default: archiving rewrites durable workspace state, so the plugin must
5
+ // never touch a conversation the user has not asked it to.
6
+ //
7
+ // Deliberately mirrors the star index (src/star-index.js): version field,
8
+ // defensive coercion of whatever is on disk, atomic write (tmp + rename) and a
9
+ // single chained mutation queue. The candidate rule lives here as a pure
10
+ // function so it can be tested without a DSH host.
11
+ import { mkdir, rename, writeFile } from 'node:fs/promises'
12
+ import { readFileSync } from 'node:fs'
13
+ import { homedir } from 'node:os'
14
+ import { join } from 'node:path'
15
+
16
+ // v4 keeps clear of the recycle bin's v1/v2 and the star index's v3, so a
17
+ // copied or mixed-up file can never be silently accepted as another store.
18
+ export const AUTO_ARCHIVE_SCHEMA_VERSION = 4
19
+
20
+ // Allowed idle windows. 0 = disabled. Deliberately coarse: a free-form number
21
+ // would let a typo schedule archiving "tomorrow" for every conversation.
22
+ export const INACTIVE_DAY_OPTIONS = Object.freeze([0, 30, 60, 90])
23
+
24
+ const DAY_MS = 86400000
25
+ // Re-run at most once per day: the sweep is triggered by panel reads, and a
26
+ // user flipping settings back and forth must not archive in a loop.
27
+ export const RUN_INTERVAL_MS = DAY_MS
28
+
29
+ const DEFAULT_DIR = join(homedir(), '.dsh', 'sessions-manager')
30
+
31
+ /**
32
+ * Coerce anything on disk (or nothing at all) into a valid v4 store.
33
+ */
34
+ export function normalizeAutoArchiveStore(raw) {
35
+ const source = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {}
36
+ const settings = source.settings && typeof source.settings === 'object' ? source.settings : {}
37
+ const inactiveDays = INACTIVE_DAY_OPTIONS.includes(settings.inactiveDays) ? settings.inactiveDays : 0
38
+ return {
39
+ schemaVersion: AUTO_ARCHIVE_SCHEMA_VERSION,
40
+ settings: {
41
+ inactiveDays,
42
+ // Starred sessions are an explicit "keep" mark, so they are skipped
43
+ // unless the user opts out.
44
+ skipStarred: settings.skipStarred !== false,
45
+ },
46
+ lastRunAt: Number.isFinite(source.lastRunAt) ? source.lastRunAt : null,
47
+ lastArchivedCount: Number.isInteger(source.lastArchivedCount) && source.lastArchivedCount >= 0 ? source.lastArchivedCount : 0,
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Which sessions should be auto-archived right now? Pure — no I/O, no host.
53
+ *
54
+ * The rule is intentionally conservative: anything we cannot prove is idle
55
+ * (unknown last-activity, already archived, starred, currently open) is left
56
+ * alone. A wrong archive is a visible regression; a missed one is invisible.
57
+ *
58
+ * @param {Array<{sessionId: string, archived?: boolean, starred?: boolean,
59
+ * updatedAt?: number|null}>} items
60
+ * @param {object} options
61
+ * @param {number} options.inactiveDays - Idle window in days (0 disables).
62
+ * @param {number} [options.now] - Reference timestamp (tests inject it).
63
+ * @param {boolean} [options.skipStarred=true] - Keep starred sessions.
64
+ * @param {string|null} [options.activeSessionId] - Never archive the open one.
65
+ * @returns {string[]} Session ids to archive.
66
+ */
67
+ export function pickInactiveCandidates(items, options = {}) {
68
+ const days = options.inactiveDays
69
+ if (!INACTIVE_DAY_OPTIONS.includes(days) || days === 0) return []
70
+ const now = Number.isFinite(options.now) ? options.now : Date.now()
71
+ const cutoff = now - days * DAY_MS
72
+ const skipStarred = options.skipStarred !== false
73
+ const activeId = options.activeSessionId != null ? String(options.activeSessionId) : null
74
+ const list = Array.isArray(items) ? items : []
75
+
76
+ const out = []
77
+ const seen = new Set()
78
+ for (const item of list) {
79
+ if (!item || item.sessionId == null) continue
80
+ const id = String(item.sessionId)
81
+ if (seen.has(id)) continue
82
+ if (item.archived) continue
83
+ if (skipStarred && item.starred) continue
84
+ if (activeId !== null && id === activeId) continue
85
+ const updatedAt = Number(item.updatedAt)
86
+ // No usable timestamp → cannot prove it is idle → leave it alone.
87
+ if (!Number.isFinite(updatedAt) || updatedAt <= 0) continue
88
+ if (updatedAt < cutoff) { seen.add(id); out.push(id) }
89
+ }
90
+ return out
91
+ }
92
+
93
+ /**
94
+ * Open the auto-archive settings store.
95
+ * @param {object} [options]
96
+ * @param {string} [options.dir] - Directory holding the index (tests inject a temp dir).
97
+ * @param {string} [options.indexPath] - Full index path, overriding `dir`.
98
+ */
99
+ export function createAutoArchiveStore(options = {}) {
100
+ const dir = options.dir || process.env.DSH_SESSIONS_MANAGER_AUTO_ARCHIVE_DIR || DEFAULT_DIR
101
+ const indexPath = options.indexPath || join(dir, 'auto-archive.json')
102
+ let mutation = Promise.resolve()
103
+
104
+ async function read() {
105
+ try {
106
+ return normalizeAutoArchiveStore(JSON.parse(readFileSync(indexPath, 'utf8')))
107
+ } catch {
108
+ return normalizeAutoArchiveStore(null)
109
+ }
110
+ }
111
+
112
+ async function write(store) {
113
+ await mkdir(dir, { recursive: true })
114
+ const tmp = join(dir, `.auto-archive-${process.pid}-${Date.now()}.tmp`)
115
+ await writeFile(tmp, JSON.stringify(normalizeAutoArchiveStore(store), null, 2), { encoding: 'utf8', mode: 0o600 })
116
+ await rename(tmp, indexPath)
117
+ }
118
+
119
+ function mutate(mutator) {
120
+ const operation = mutation.then(async () => {
121
+ const store = await read()
122
+ const result = await mutator(store)
123
+ await write(store)
124
+ return result
125
+ })
126
+ mutation = operation.catch(() => {})
127
+ return operation
128
+ }
129
+
130
+ /**
131
+ * Merge a partial settings patch.
132
+ * @param {{inactiveDays?: number, skipStarred?: boolean}} patch
133
+ * @returns {Promise<object>} The store's settings after the change.
134
+ */
135
+ function update(patch = {}) {
136
+ return mutate((store) => {
137
+ if (Object.prototype.hasOwnProperty.call(patch, 'inactiveDays')) {
138
+ const days = Number(patch.inactiveDays)
139
+ if (!INACTIVE_DAY_OPTIONS.includes(days)) {
140
+ const error = new Error(`inactiveDays 仅支持 ${INACTIVE_DAY_OPTIONS.join('、')}`)
141
+ error.status = 400
142
+ throw error
143
+ }
144
+ store.settings.inactiveDays = days
145
+ }
146
+ if (Object.prototype.hasOwnProperty.call(patch, 'skipStarred')) {
147
+ store.settings.skipStarred = !!patch.skipStarred
148
+ }
149
+ return store.settings
150
+ })
151
+ }
152
+
153
+ /** Record that a sweep ran, so the once-a-day throttle can skip the next one. */
154
+ function recordRun(count, at = Date.now()) {
155
+ return mutate((store) => {
156
+ store.lastRunAt = at
157
+ store.lastArchivedCount = Number.isInteger(count) && count >= 0 ? count : 0
158
+ return store
159
+ })
160
+ }
161
+
162
+ /** True when a sweep already ran within RUN_INTERVAL_MS. */
163
+ function isFresh(store, now = Date.now()) {
164
+ return Number.isFinite(store && store.lastRunAt) && (now - store.lastRunAt) < RUN_INTERVAL_MS
165
+ }
166
+
167
+ return { read, write, mutate, update, recordRun, isFresh, indexPath, dir }
168
+ }
@@ -11,7 +11,7 @@
11
11
  */
12
12
 
13
13
  import React, { useEffect, useMemo, useRef, useState } from 'react'
14
- import { canDropOnWorkspace, dotStateFor, sessionForNodes, workspaceForNodes } from './logic.js'
14
+ import { canDropOnWorkspace, dotStateFor, sessionForNodes, starredOf, workspaceForNodes } from './logic.js'
15
15
 
16
16
  export const inject = ['slots']
17
17
 
@@ -55,6 +55,11 @@ const CSS = `
55
55
  .archv-id{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:10.5px;color:var(--dsw-alias-label-tertiary);flex:none}
56
56
  .archv-dot{color:var(--dsw-alias-border-l3);flex:none}
57
57
  .archv-check{width:15px;height:15px;accent-color:var(--dsw-alias-state-business-primary);flex:none;cursor:pointer}
58
+ .archv-star{appearance:none;width:24px;height:24px;flex:none;display:inline-flex;align-items:center;justify-content:center;border:none;background:0 0;border-radius:7px;cursor:pointer;color:var(--dsw-alias-label-tertiary);transition:color .15s ease,background-color .15s ease}
59
+ .archv-star:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-secondary)}
60
+ .archv-star svg{fill:none;stroke:currentColor;stroke-width:1.5;stroke-linejoin:round}
61
+ .archv-star-on,.archv-star-on:hover{color:var(--dsw-alias-state-business-primary)}
62
+ .archv-star-on svg{fill:currentColor}
58
63
  .archv-body{flex:1;min-width:0;display:flex;align-items:center;gap:12px}
59
64
  .archv-actions{display:flex;gap:8px;flex:none;flex-wrap:nowrap;justify-content:flex-end}
60
65
  .archv-btn{appearance:none;min-height:32px;padding:0 12px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-fill-subtle);color:var(--dsw-alias-label-secondary);border-radius:9px;font-size:12px;font-weight:500;cursor:pointer;white-space:nowrap;display:inline-flex;align-items:center;justify-content:center;gap:6px;text-align:center;transition:background-color .15s ease,border-color .15s ease,color .15s ease}
@@ -100,6 +105,9 @@ const CSS = `
100
105
  .dtl-k{font-size:11px;color:var(--dsw-alias-label-tertiary)}
101
106
  .dtl-v{font-size:12px;color:var(--dsw-alias-label-primary);word-break:break-all}
102
107
  .dtl-sec{margin-top:12px}
108
+ .dtl-export{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
109
+ .dtl-export .archv-btn{text-decoration:none}
110
+ .dtl-note{margin-top:8px;font-size:11px;color:var(--dsw-alias-label-tertiary);line-height:1.5}
103
111
  .dtl-sec-t{font-size:11px;font-weight:600;color:var(--dsw-alias-label-secondary);text-transform:uppercase;letter-spacing:.03em;margin-bottom:6px}
104
112
  .dtl-tags{display:flex;flex-wrap:wrap;gap:6px}
105
113
  .dtl-tag{display:inline-flex;font-size:11px;color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-fill-elevated);border:1px solid var(--dsw-alias-border-l2);border-radius:var(--dsm-radius-tag);padding:2px 8px}
@@ -212,7 +220,9 @@ function SessionPanel({ workspacesSvc }) {
212
220
  const [sessions, setSessions] = useState(null)
213
221
  const [workspaces, setWorkspaces] = useState([])
214
222
  const initialPrefs = useRef(loadPanelPrefs()).current
215
- const [filter, setFilter] = useState(() => ['all', 'active', 'archived', 'trash'].includes(initialPrefs.filter) ? initialPrefs.filter : 'all')
223
+ // 注意:'storage' 已从可持久化取值中移除(它不再是视图),旧版残留的
224
+ // filter='storage' 会自动回落到 'all',避免落到一个已不存在的界面。
225
+ const [filter, setFilter] = useState(() => ['all', 'active', 'archived', 'starred', 'trash'].includes(initialPrefs.filter) ? initialPrefs.filter : 'all')
216
226
  const [query, setQuery] = useState('')
217
227
  const [workspaceFilter, setWorkspaceFilter] = useState(() => initialPrefs.workspaceFilter || 'all')
218
228
  const [sortBy, setSortBy] = useState(() => ['newest', 'oldest', 'title'].includes(initialPrefs.sortBy) ? initialPrefs.sortBy : 'newest')
@@ -235,8 +245,23 @@ function SessionPanel({ workspacesSvc }) {
235
245
  const [details, setDetails] = useState({})
236
246
  const [openDetails, setOpenDetails] = useState(null)
237
247
  const [detailsLoading, setDetailsLoading] = useState(null)
248
+ const [mdBusy, setMdBusy] = useState(null)
249
+ const [zipOk, setZipOk] = useState(true)
250
+ const [storage, setStorage] = useState(null)
251
+ const [storageBusy, setStorageBusy] = useState(false)
252
+ // 存储统计要 stat 每条会话日志,开销不小:面板默认收起、按需加载,
253
+ // 且展开状态刻意不持久化——否则每次打开设置面板都会触发一次全量扫描。
254
+ const [storageOpen, setStorageOpen] = useState(false)
255
+ const [storageError, setStorageError] = useState(null)
256
+ const [aa, setAa] = useState({ settings: { inactiveDays: 0, skipStarred: true }, lastRunAt: null, lastArchivedCount: 0 })
257
+ const [aaOpen, setAaOpen] = useState(false)
258
+ const [aaBusy, setAaBusy] = useState(false)
259
+ const zipChecked = useRef(false)
238
260
  const [openMenu, setOpenMenu] = useState(null)
239
261
  const timer = useRef(null)
262
+ // 存储面板关着时会话集合若发生变化,标脏;下次展开时再刷新,
263
+ // 既不会显示过期数字,也避免每次 refresh 都白扫一遍全量日志。
264
+ const storageDirty = useRef(false)
240
265
  const menuRef = useRef(null)
241
266
  const dialogRef = useRef(null)
242
267
 
@@ -264,6 +289,9 @@ function SessionPanel({ workspacesSvc }) {
264
289
  setConfirmBatch(false)
265
290
  if (!targetWs && works.items && works.items.length) setTargetWs(works.items[0].workspaceId)
266
291
  loadTrash()
292
+ // 会话集合变了:面板开着就同步刷新;关着则只标脏,等展开时再刷。
293
+ if (storageOpen) loadStorage()
294
+ else storageDirty.current = true
267
295
  })
268
296
  .catch((e) => setError(String((e && e.message) || e)))
269
297
  }
@@ -278,6 +306,60 @@ function SessionPanel({ workspacesSvc }) {
278
306
  try { localStorage.setItem(PANEL_PREFS_KEY, JSON.stringify({ filter, workspaceFilter, sortBy })) } catch (e) {}
279
307
  }, [filter, workspaceFilter, sortBy])
280
308
 
309
+ // 自动归档设置随面板加载一次(host 侧读取即触发每日检查)。
310
+ // 存储统计刻意不在这里预取:它要 stat 每条日志,只在用户展开面板时才算。
311
+ useEffect(() => {
312
+ loadAutoArchive()
313
+ // eslint-disable-next-line react-hooks/exhaustive-deps
314
+ }, [])
315
+
316
+ // 收藏切换:乐观更新 + 失败回滚(star 是高频轻操作,不等网络往返)。
317
+ const toggleStar = async (it) => {
318
+ const next = !it.starred
319
+ setSessions((s) => s && s.map((x) => (x.sessionId === it.sessionId ? { ...x, starred: next } : x)))
320
+ try {
321
+ await postJSON('/archived-sessions/star/set', { sessionId: it.sessionId, starred: next })
322
+ } catch (e) {
323
+ setSessions((s) => s && s.map((x) => (x.sessionId === it.sessionId ? { ...x, starred: !next } : x)))
324
+ showToast('收藏失败:' + String((e && e.message) || e))
325
+ }
326
+ }
327
+
328
+ // Markdown 导出:自有路由(/archived-sessions/export-md),blob 触发下载。
329
+ const exportMarkdown = async (it) => {
330
+ if (mdBusy) return
331
+ setMdBusy(it.sessionId)
332
+ try {
333
+ const res = await fetch('/archived-sessions/export-md?sessionId=' + encodeURIComponent(it.sessionId))
334
+ if (!res.ok) throw new Error('HTTP ' + res.status)
335
+ const blob = await res.blob()
336
+ const url = URL.createObjectURL(blob)
337
+ const a = document.createElement('a')
338
+ a.href = url
339
+ a.download = 'dsh-session-' + it.sessionId + '.md'
340
+ document.body.appendChild(a)
341
+ a.click()
342
+ a.remove()
343
+ URL.revokeObjectURL(url)
344
+ showToast('已导出 Markdown')
345
+ } catch (e) {
346
+ showToast('导出失败:' + String((e && e.message) || e))
347
+ } finally {
348
+ setMdBusy(null)
349
+ }
350
+ }
351
+
352
+ // 官方 ZIP 导出预检(一次性):后端不支持 raw artifacts 时返回 501,
353
+ // 此时隐藏 ZIP 入口只留 Markdown(全局缓存,后端能力不会中途变)。
354
+ // 探针用非法 id 走 HEAD:命中 501 = 不支持;404/400 = 路由活着且支持。
355
+ useEffect(() => {
356
+ if (openDetails === null || zipChecked.current) return
357
+ zipChecked.current = true
358
+ fetch('/api/session.export?sessionId=probe&includeDescendants=false', { method: 'HEAD' })
359
+ .then((res) => setZipOk(res.status !== 501))
360
+ .catch(() => setZipOk(true))
361
+ }, [openDetails])
362
+
281
363
  // Close the ⋯ menu on outside click / Escape (no full-screen backdrop).
282
364
  useEffect(() => {
283
365
  if (openMenu === null) return
@@ -314,9 +396,10 @@ function SessionPanel({ workspacesSvc }) {
314
396
 
315
397
  const archivedList = sessions ? sessions.filter((x) => x.archived) : []
316
398
  const activeList = sessions ? sessions.filter((x) => !x.archived) : []
399
+ const starredList = starredOf(sessions)
317
400
  const list = useMemo(() => {
318
401
  if (filter === 'trash') return []
319
- const base = filter === 'archived' ? archivedList : filter === 'active' ? activeList : sessions || []
402
+ const base = filter === 'archived' ? archivedList : filter === 'active' ? activeList : filter === 'starred' ? starredList : sessions || []
320
403
  const needle = query.trim().toLocaleLowerCase()
321
404
  const filtered = base.filter((item) => {
322
405
  if (workspaceFilter !== 'all' && (item.workspacePath || '') !== workspaceFilter) return false
@@ -330,6 +413,8 @@ function SessionPanel({ workspacesSvc }) {
330
413
  })
331
414
  }, [sessions, filter, query, workspaceFilter, sortBy])
332
415
  const selIds = Object.keys(selected).filter((k) => selected[k])
416
+ // 「回收站」是独立视图,不共用会话列表。
417
+ const showSessionList = filter !== 'trash'
333
418
 
334
419
  const toggle = (id) => setSelected((s) => ({ ...s, [id]: !s[id] }))
335
420
  const clearSel = () => setSelected({})
@@ -386,6 +471,47 @@ function SessionPanel({ workspacesSvc }) {
386
471
  .catch((e) => { setTrashBusy(null); setError(String((e && e.message) || e)) })
387
472
  }
388
473
 
474
+ // 存储占用分析:只读聚合(按工作区排行 + 最大的会话)。按需调用,不在面板加载时预取。
475
+ const loadStorage = () => {
476
+ setStorageBusy(true)
477
+ setStorageError(null)
478
+ postJSON('/archived-sessions/storage', { topN: 10 })
479
+ .then((r) => { setStorageBusy(false); setStorage(r); storageDirty.current = false })
480
+ // 错误留在面板内自行重试,不冒泡成整个设置面板的错误条。
481
+ .catch((e) => { setStorageBusy(false); setStorageError(String((e && e.message) || e)) })
482
+ }
483
+
484
+ // 自动归档设置。一次纯读取会顺带让 host 跑一遍每日检查(host 侧按天节流)。
485
+ const applyAa = (r) => setAa({ settings: r.settings || { inactiveDays: 0, skipStarred: true }, lastRunAt: r.lastRunAt ?? null, lastArchivedCount: r.lastArchivedCount || 0 })
486
+
487
+ const loadAutoArchive = () => {
488
+ postJSON('/archived-sessions/auto-archive/settings', {})
489
+ .then(applyAa)
490
+ .catch(() => {})
491
+ }
492
+
493
+ const updateAutoArchive = (patch) => {
494
+ if (aaBusy) return
495
+ setAaBusy(true)
496
+ postJSON('/archived-sessions/auto-archive/settings', patch)
497
+ .then((r) => { setAaBusy(false); applyAa(r); showToast('已更新自动归档策略') })
498
+ .catch((e) => { setAaBusy(false); setError(String((e && e.message) || e)) })
499
+ }
500
+
501
+ const runAutoArchive = () => {
502
+ if (aaBusy) return
503
+ setAaBusy(true)
504
+ postJSON('/archived-sessions/auto-archive/run', {})
505
+ .then((r) => {
506
+ setAaBusy(false)
507
+ applyAa(r)
508
+ const n = r.archived || 0
509
+ if (r.skipped === 'disabled') showToast('自动归档未启用')
510
+ else { showToast(`已自动归档 ${n} 个会话`); if (n > 0) refresh() }
511
+ })
512
+ .catch((e) => { setAaBusy(false); setError(String((e && e.message) || e)) })
513
+ }
514
+
389
515
  const restoreTrash = (sid) => {
390
516
  if (trashBusy) return
391
517
  setTrashBusy(sid)
@@ -569,10 +695,101 @@ function SessionPanel({ workspacesSvc }) {
569
695
  <button type="button" role="tab" aria-selected={filter === 'all'} className={'sess-fbtn' + (filter === 'all' ? ' sess-fbtn-on' : '')} onClick={() => { setFilter('all'); clearSel(); setConfirmBatch(false) }}>全部 ({sessions.length})</button>
570
696
  <button type="button" role="tab" aria-selected={filter === 'active'} className={'sess-fbtn' + (filter === 'active' ? ' sess-fbtn-on' : '')} onClick={() => { setFilter('active'); clearSel(); setConfirmBatch(false) }}>活动 ({activeList.length})</button>
571
697
  <button type="button" role="tab" aria-selected={filter === 'archived'} className={'sess-fbtn' + (filter === 'archived' ? ' sess-fbtn-on' : '')} onClick={() => { setFilter('archived'); clearSel(); setConfirmBatch(false) }}>已归档 ({archivedList.length})</button>
698
+ <button type="button" role="tab" aria-selected={filter === 'starred'} className={'sess-fbtn' + (filter === 'starred' ? ' sess-fbtn-on' : '')} onClick={() => { setFilter('starred'); clearSel(); setConfirmBatch(false) }}>已收藏 ({starredList.length})</button>
572
699
  <button type="button" role="tab" aria-selected={filter === 'trash'} className={'sess-fbtn' + (filter === 'trash' ? ' sess-fbtn-on' : '')} onClick={() => { setFilter('trash'); clearSel(); setConfirmBatch(false) }}>回收站 ({trash.length})</button>
573
700
  </div>
574
701
 
575
- {filter !== 'trash' && (
702
+ {/* 维护栏是面板级工具,与当前查看哪一组会话无关,故所有视图都显示。 */}
703
+ <div className="maint-bar">
704
+ <button type="button" className={'archv-btn' + (aa.settings.inactiveDays ? ' archv-go' : '')} aria-expanded={aaOpen} onClick={() => setAaOpen(!aaOpen)}>
705
+ 自动归档{aa.settings.inactiveDays ? `:${aa.settings.inactiveDays} 天未活跃` : ':未启用'}
706
+ </button>
707
+ {aa.lastRunAt ? <span className="maint-note">上次检查 {fmtDate(aa.lastRunAt)},归档 {aa.lastArchivedCount} 个</span> : <span className="maint-note">尚未检查</span>}
708
+ <button type="button" className="archv-btn maint-bar-right" aria-expanded={storageOpen} onClick={() => { const next = !storageOpen; setStorageOpen(next); if (next && (!storage || storageDirty.current) && !storageBusy) loadStorage() }}>
709
+ 存储占用{storage ? ` · ${fmtBytes(storage.totalBytes) || '0 B'}` : ''}
710
+ </button>
711
+ </div>
712
+ {aaOpen && (
713
+ <div className="mv-sheet" aria-label="自动归档设置">
714
+ <div className="mv-sheet-head">
715
+ <h3 className="mv-sheet-title">自动归档</h3>
716
+ <button type="button" className="mv-sheet-close" aria-label="关闭" onClick={() => setAaOpen(false)}>×</button>
717
+ </div>
718
+ <div className="mv-field">
719
+ <label className="mv-field-label" htmlFor="dsm-aa-days">将多久未活跃的会话自动归档</label>
720
+ <select id="dsm-aa-days" value={aa.settings.inactiveDays} disabled={aaBusy} onChange={(e) => updateAutoArchive({ inactiveDays: Number(e.target.value) })}>
721
+ <option value="0">不自动归档</option>
722
+ <option value="30">30 天未活跃</option>
723
+ <option value="60">60 天未活跃</option>
724
+ <option value="90">90 天未活跃</option>
725
+ </select>
726
+ </div>
727
+ <label className="aa-check">
728
+ <input type="checkbox" checked={aa.settings.skipStarred !== false} disabled={aaBusy} onChange={(e) => updateAutoArchive({ skipStarred: e.target.checked })} />
729
+ 跳过已收藏的会话
730
+ </label>
731
+ <div className="mv-foot">
732
+ <button type="button" className="archv-btn" disabled={aaBusy || !aa.settings.inactiveDays} onClick={runAutoArchive}>{aaBusy ? '检查中…' : '立即检查'}</button>
733
+ </div>
734
+ <div className="dtl-note">
735
+ 自动归档只是把会话收进「已归档」,不删除任何数据,随时可恢复。当前正在使用的会话永远不会被自动归档。检查在打开本面板时触发,每天最多一次。
736
+ </div>
737
+ </div>
738
+ )}
739
+ {storageOpen && (
740
+ <div className="mv-sheet" aria-label="存储占用">
741
+ <div className="mv-sheet-head">
742
+ <h3 className="mv-sheet-title">存储占用</h3>
743
+ <div className="mv-sheet-actions">
744
+ <button type="button" className="archv-btn" disabled={storageBusy} onClick={loadStorage}>{storageBusy ? '统计中…' : '重新统计'}</button>
745
+ <button type="button" className="mv-sheet-close" aria-label="关闭" onClick={() => setStorageOpen(false)}>×</button>
746
+ </div>
747
+ </div>
748
+ {storageError ? (
749
+ <div className="archv-err" role="alert">
750
+ <span>{storageError}</span>
751
+ <button type="button" className="archv-errretry" onClick={loadStorage}>重试</button>
752
+ </div>
753
+ ) : !storage ? (
754
+ <div className="archv-empty">统计中…</div>
755
+ ) : storage.sessionCount === 0 ? (
756
+ <div className="archv-empty">暂无会话,没有可统计的存储占用。</div>
757
+ ) : (
758
+ <>
759
+ <div className="dsm-storage-sum">
760
+ 共 {fmtBytes(storage.totalBytes) || '0 B'} · {storage.sessionCount} 个会话{storage.unknownSessions ? ` · ${storage.unknownSessions} 个大小未知` : ''}
761
+ </div>
762
+ <div className="dsm-storage-list">
763
+ {storage.workspaces.map((w) => (
764
+ <div className="dsm-storage-row" key={w.key}>
765
+ <span className="dsm-storage-name" title={w.path || '未分组'}>{w.title || (w.path ? pathName(w.path) : '未分组')}</span>
766
+ <span className="dsm-storage-bar" aria-hidden="true"><span className="dsm-storage-fill" style={{ width: `${Math.round((w.share || 0) * 100)}%` }} /></span>
767
+ <span className="dsm-storage-size">{fmtBytes(w.bytes) || '—'}</span>
768
+ <span className="dsm-storage-count">{w.sessions} 个</span>
769
+ </div>
770
+ ))}
771
+ </div>
772
+ {storage.top.length > 0 && (
773
+ <div className="dtl-sec">
774
+ <div className="dtl-sec-t">占用最大的会话</div>
775
+ <div className="dsm-storage-list">
776
+ {storage.top.map((s) => (
777
+ <div className="dsm-storage-row" key={s.sessionId}>
778
+ <span className="dsm-storage-name" title={s.sessionId}>{s.title || s.sessionId}</span>
779
+ <span className="dsm-storage-size">{fmtBytes(s.sizeBytes) || '—'}</span>
780
+ <span className="dsm-storage-count">{s.workspaceTitle || (s.workspacePath ? pathName(s.workspacePath) : '未分组')}</span>
781
+ </div>
782
+ ))}
783
+ </div>
784
+ </div>
785
+ )}
786
+ <div className="dtl-note">统计的是会话日志文件的磁盘占用(压缩后的实际大小),只读,不修改任何数据。</div>
787
+ </>
788
+ )}
789
+ </div>
790
+ )}
791
+
792
+ {showSessionList && (
576
793
  <>
577
794
  <div className="sess-tools" aria-label="查找和整理会话">
578
795
  <div className="sess-field">
@@ -593,13 +810,13 @@ function SessionPanel({ workspacesSvc }) {
593
810
  </select>
594
811
  </div>
595
812
  </div>
596
- <div className="sess-results" role="status">显示 {list.length} 个会话{query || workspaceFilter !== 'all' ? `,共 ${filter === 'archived' ? archivedList.length : filter === 'active' ? activeList.length : sessions.length} 个` : ''}</div>
813
+ <div className="sess-results" role="status">显示 {list.length} 个会话{query || workspaceFilter !== 'all' ? `,共 ${filter === 'archived' ? archivedList.length : filter === 'active' ? activeList.length : filter === 'starred' ? starredList.length : sessions.length} 个` : ''}</div>
597
814
  </>
598
815
  )}
599
816
 
600
- {filter !== 'trash' && list.length > 0 && (
817
+ {showSessionList && list.length > 0 && (
601
818
  <div className="sess-batch">
602
- <span className="sess-btntext">{selIds.length ? `已选 ${selIds.length} 项` : (filter === 'archived' ? `共 ${archivedList.length} 个归档会话` : `共 ${sessions.length} 个会话(活动 ${activeList.length} / 已归档 ${archivedList.length})`)}</span>
819
+ <span className="sess-btntext">{selIds.length ? `已选 ${selIds.length} 项` : (filter === 'archived' ? `共 ${archivedList.length} 个归档会话` : filter === 'starred' ? `共 ${starredList.length} 个收藏会话` : `共 ${sessions.length} 个会话(活动 ${activeList.length} / 已归档 ${archivedList.length})`)}</span>
603
820
  <button type="button" className="archv-btn" disabled={list.length === 0} onClick={selectAll}>全选</button>
604
821
  {selIds.length > 0 && (
605
822
  <>
@@ -618,9 +835,9 @@ function SessionPanel({ workspacesSvc }) {
618
835
  </div>
619
836
  )}
620
837
 
621
- {filter !== 'trash' && list.length === 0 ? (
622
- <div className="archv-empty">{query || workspaceFilter !== 'all' ? '没有匹配的会话。请调整搜索词或工作区筛选。' : filter === 'archived' ? '目前没有归档会话。在“全部”里选中会话点“归档”即可收纳进来。' : filter === 'active' ? '目前没有活动会话。' : '暂无可管理的会话。'}</div>
623
- ) : filter !== 'trash' ? (
838
+ {showSessionList && list.length === 0 ? (
839
+ <div className="archv-empty">{query || workspaceFilter !== 'all' ? '没有匹配的会话。请调整搜索词或工作区筛选。' : filter === 'archived' ? '目前没有归档会话。在“全部”里选中会话点“归档”即可收纳进来。' : filter === 'active' ? '目前没有活动会话。' : filter === 'starred' ? '还没有收藏的会话。点击会话左侧的星标即可收藏。' : '暂无可管理的会话。'}</div>
840
+ ) : showSessionList ? (
624
841
  <div className="archv-list" role="list">
625
842
  {list.map((it) => {
626
843
  const date = fmtDate(it.createdAt)
@@ -635,6 +852,16 @@ function SessionPanel({ workspacesSvc }) {
635
852
  onChange={() => toggle(it.sessionId)}
636
853
  aria-label={'选择 ' + (it.title || it.sessionId)}
637
854
  />
855
+ <button
856
+ type="button"
857
+ className={'archv-star' + (it.starred ? ' archv-star-on' : '')}
858
+ aria-pressed={!!it.starred}
859
+ aria-label={(it.starred ? '取消收藏 ' : '收藏 ') + (it.title || it.sessionId)}
860
+ title={it.starred ? '取消收藏' : '收藏'}
861
+ onClick={(e) => { e.stopPropagation(); toggleStar(it) }}
862
+ >
863
+ <svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true" focusable="false"><path d="M12 2.5l2.9 5.9 6.6.9-4.8 4.6 1.2 6.5-5.9-3.1-5.9 3.1 1.2-6.5L2.5 9.3l6.6-.9z" /></svg>
864
+ </button>
638
865
  <div className="archv-body">
639
866
  <div className="archv-main">
640
867
  <div className="archv-name" title={it.title || ''}>{it.title || '(无标题)'}</div>
@@ -746,6 +973,20 @@ function SessionPanel({ workspacesSvc }) {
746
973
  </div>
747
974
  </div>
748
975
  )}
976
+ <div className="dtl-sec">
977
+ <div className="dtl-sec-t">导出</div>
978
+ <div className="dtl-export">
979
+ <a
980
+ className="archv-btn"
981
+ href={`/api/session.export?sessionId=${encodeURIComponent(it.sessionId)}&includeDescendants=true`}
982
+ onClick={(e) => e.stopPropagation()}
983
+ style={zipOk ? undefined : { pointerEvents: 'none', opacity: 0.45 }}
984
+ title={zipOk ? '含子会话与附件,由 DSH 提供' : '当前持久化后端不支持原始日志导出'}
985
+ >下载原始日志 (ZIP)</a>
986
+ <button type="button" className="archv-btn" disabled={mdBusy === it.sessionId} onClick={() => exportMarkdown(it)}>{mdBusy === it.sessionId ? '生成中…' : '导出 Markdown'}</button>
987
+ </div>
988
+ <div className="dtl-note">ZIP 含子会话与附件,由 DSH 提供 · Markdown 为本插件生成的可读对话记录</div>
989
+ </div>
749
990
  </div>
750
991
  )
751
992
  })()}
@@ -876,6 +1117,21 @@ const SIDEBAR_AUG_CSS = `
876
1117
  .dsm-trash-date{font-size:11px;color:var(--dsw-alias-label-tertiary);flex:none;white-space:nowrap}
877
1118
  .dsm-trash-actions{display:flex;gap:6px;flex:none}.dsm-trash-actions .archv-btn{min-width:72px}
878
1119
  .dsm-trash-empty{font-size:12px;color:var(--dsw-alias-label-tertiary);padding:6px 2px}
1120
+ .dsm-storage-sum{font-size:12px;color:var(--dsw-alias-label-secondary);margin-bottom:10px}
1121
+ .dsm-storage-list{display:flex;flex-direction:column;gap:6px}
1122
+ .dsm-storage-row{display:flex;align-items:center;gap:10px;padding:8px 10px;border:1px solid var(--dsw-alias-border-l2);border-radius:9px;background:var(--dsw-alias-fill-elevated)}
1123
+ .dsm-storage-name{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12.5px;color:var(--dsw-alias-label-primary)}
1124
+ .dsm-storage-bar{flex:0 0 96px;height:6px;border-radius:999px;background:var(--dsw-alias-fill-subtle);overflow:hidden}
1125
+ .dsm-storage-fill{display:block;height:100%;border-radius:999px;background:var(--dsw-alias-state-business-primary)}
1126
+ .dsm-storage-size{font-size:12px;color:var(--dsw-alias-label-secondary);flex:none;white-space:nowrap}
1127
+ .dsm-storage-count{font-size:11px;color:var(--dsw-alias-label-tertiary);flex:none;white-space:nowrap;max-width:32%;overflow:hidden;text-overflow:ellipsis}
1128
+ .maint-bar{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin:0 0 8px}
1129
+ .maint-bar-right{margin-left:auto}
1130
+ .maint-note{font-size:11px;color:var(--dsw-alias-label-tertiary)}
1131
+ .mv-sheet-actions{display:flex;align-items:center;gap:6px}
1132
+ .aa-check{display:flex;align-items:center;gap:8px;font-size:12px;color:var(--dsw-alias-label-secondary);cursor:pointer}
1133
+ .aa-check input{width:15px;height:15px;accent-color:var(--dsw-alias-state-business-primary);cursor:pointer;flex:none}
1134
+ @media (max-width:640px){.dsm-storage-bar{display:none}.dsm-storage-count{max-width:40%}}
879
1135
  `
880
1136
 
881
1137
  // Shared status-dot state so the ⋯-menu "标记未读" action can toggle the
@@ -51,3 +51,9 @@ export function workspaceForNodes(nodes) {
51
51
  }
52
52
  return null
53
53
  }
54
+
55
+ // 收藏过滤:返回已收藏子集。star 是用户标记,与 DSH 的活动/归档状态正交
56
+ // (可叠加),所以这里不做任何状态联合判断,只认 starred 字段。
57
+ export function starredOf(items) {
58
+ return (items || []).filter((item) => item && item.starred)
59
+ }