dsh-sessions-manager 3.3.0 → 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.3.0",
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
+ }
@@ -220,6 +220,8 @@ function SessionPanel({ workspacesSvc }) {
220
220
  const [sessions, setSessions] = useState(null)
221
221
  const [workspaces, setWorkspaces] = useState([])
222
222
  const initialPrefs = useRef(loadPanelPrefs()).current
223
+ // 注意:'storage' 已从可持久化取值中移除(它不再是视图),旧版残留的
224
+ // filter='storage' 会自动回落到 'all',避免落到一个已不存在的界面。
223
225
  const [filter, setFilter] = useState(() => ['all', 'active', 'archived', 'starred', 'trash'].includes(initialPrefs.filter) ? initialPrefs.filter : 'all')
224
226
  const [query, setQuery] = useState('')
225
227
  const [workspaceFilter, setWorkspaceFilter] = useState(() => initialPrefs.workspaceFilter || 'all')
@@ -245,9 +247,21 @@ function SessionPanel({ workspacesSvc }) {
245
247
  const [detailsLoading, setDetailsLoading] = useState(null)
246
248
  const [mdBusy, setMdBusy] = useState(null)
247
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)
248
259
  const zipChecked = useRef(false)
249
260
  const [openMenu, setOpenMenu] = useState(null)
250
261
  const timer = useRef(null)
262
+ // 存储面板关着时会话集合若发生变化,标脏;下次展开时再刷新,
263
+ // 既不会显示过期数字,也避免每次 refresh 都白扫一遍全量日志。
264
+ const storageDirty = useRef(false)
251
265
  const menuRef = useRef(null)
252
266
  const dialogRef = useRef(null)
253
267
 
@@ -275,6 +289,9 @@ function SessionPanel({ workspacesSvc }) {
275
289
  setConfirmBatch(false)
276
290
  if (!targetWs && works.items && works.items.length) setTargetWs(works.items[0].workspaceId)
277
291
  loadTrash()
292
+ // 会话集合变了:面板开着就同步刷新;关着则只标脏,等展开时再刷。
293
+ if (storageOpen) loadStorage()
294
+ else storageDirty.current = true
278
295
  })
279
296
  .catch((e) => setError(String((e && e.message) || e)))
280
297
  }
@@ -289,6 +306,13 @@ function SessionPanel({ workspacesSvc }) {
289
306
  try { localStorage.setItem(PANEL_PREFS_KEY, JSON.stringify({ filter, workspaceFilter, sortBy })) } catch (e) {}
290
307
  }, [filter, workspaceFilter, sortBy])
291
308
 
309
+ // 自动归档设置随面板加载一次(host 侧读取即触发每日检查)。
310
+ // 存储统计刻意不在这里预取:它要 stat 每条日志,只在用户展开面板时才算。
311
+ useEffect(() => {
312
+ loadAutoArchive()
313
+ // eslint-disable-next-line react-hooks/exhaustive-deps
314
+ }, [])
315
+
292
316
  // 收藏切换:乐观更新 + 失败回滚(star 是高频轻操作,不等网络往返)。
293
317
  const toggleStar = async (it) => {
294
318
  const next = !it.starred
@@ -389,6 +413,8 @@ function SessionPanel({ workspacesSvc }) {
389
413
  })
390
414
  }, [sessions, filter, query, workspaceFilter, sortBy])
391
415
  const selIds = Object.keys(selected).filter((k) => selected[k])
416
+ // 「回收站」是独立视图,不共用会话列表。
417
+ const showSessionList = filter !== 'trash'
392
418
 
393
419
  const toggle = (id) => setSelected((s) => ({ ...s, [id]: !s[id] }))
394
420
  const clearSel = () => setSelected({})
@@ -445,6 +471,47 @@ function SessionPanel({ workspacesSvc }) {
445
471
  .catch((e) => { setTrashBusy(null); setError(String((e && e.message) || e)) })
446
472
  }
447
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
+
448
515
  const restoreTrash = (sid) => {
449
516
  if (trashBusy) return
450
517
  setTrashBusy(sid)
@@ -632,7 +699,97 @@ function SessionPanel({ workspacesSvc }) {
632
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>
633
700
  </div>
634
701
 
635
- {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 && (
636
793
  <>
637
794
  <div className="sess-tools" aria-label="查找和整理会话">
638
795
  <div className="sess-field">
@@ -657,7 +814,7 @@ function SessionPanel({ workspacesSvc }) {
657
814
  </>
658
815
  )}
659
816
 
660
- {filter !== 'trash' && list.length > 0 && (
817
+ {showSessionList && list.length > 0 && (
661
818
  <div className="sess-batch">
662
819
  <span className="sess-btntext">{selIds.length ? `已选 ${selIds.length} 项` : (filter === 'archived' ? `共 ${archivedList.length} 个归档会话` : filter === 'starred' ? `共 ${starredList.length} 个收藏会话` : `共 ${sessions.length} 个会话(活动 ${activeList.length} / 已归档 ${archivedList.length})`)}</span>
663
820
  <button type="button" className="archv-btn" disabled={list.length === 0} onClick={selectAll}>全选</button>
@@ -678,9 +835,9 @@ function SessionPanel({ workspacesSvc }) {
678
835
  </div>
679
836
  )}
680
837
 
681
- {filter !== 'trash' && list.length === 0 ? (
838
+ {showSessionList && list.length === 0 ? (
682
839
  <div className="archv-empty">{query || workspaceFilter !== 'all' ? '没有匹配的会话。请调整搜索词或工作区筛选。' : filter === 'archived' ? '目前没有归档会话。在“全部”里选中会话点“归档”即可收纳进来。' : filter === 'active' ? '目前没有活动会话。' : filter === 'starred' ? '还没有收藏的会话。点击会话左侧的星标即可收藏。' : '暂无可管理的会话。'}</div>
683
- ) : filter !== 'trash' ? (
840
+ ) : showSessionList ? (
684
841
  <div className="archv-list" role="list">
685
842
  {list.map((it) => {
686
843
  const date = fmtDate(it.createdAt)
@@ -703,7 +860,7 @@ function SessionPanel({ workspacesSvc }) {
703
860
  title={it.starred ? '取消收藏' : '收藏'}
704
861
  onClick={(e) => { e.stopPropagation(); toggleStar(it) }}
705
862
  >
706
- <svg viewBox="0 0 24 24" width="15" height="15" 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>
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>
707
864
  </button>
708
865
  <div className="archv-body">
709
866
  <div className="archv-main">
@@ -960,6 +1117,21 @@ const SIDEBAR_AUG_CSS = `
960
1117
  .dsm-trash-date{font-size:11px;color:var(--dsw-alias-label-tertiary);flex:none;white-space:nowrap}
961
1118
  .dsm-trash-actions{display:flex;gap:6px;flex:none}.dsm-trash-actions .archv-btn{min-width:72px}
962
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%}}
963
1135
  `
964
1136
 
965
1137
  // Shared status-dot state so the ⋯-menu "标记未读" action can toggle the