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/README.en.md +24 -19
- package/README.md +24 -19
- package/assets/screenshot-session-autoarch.png +0 -0
- package/assets/screenshot-session-details.png +0 -0
- package/assets/screenshot-session-settings.png +0 -0
- package/assets/screenshot-session-starred.png +0 -0
- package/assets/screenshot-session-storage.png +0 -0
- package/assets/screenshot-session-trash.png +0 -0
- package/lib/client.js +258 -7
- package/lib/client.js.map +2 -2
- package/lib/index.js +584 -29
- package/lib/index.js.map +4 -4
- package/package.json +1 -1
- package/src/auto-archive.js +168 -0
- package/src/client/index.jsx +266 -10
- package/src/client/logic.js +6 -0
- package/src/index.js +238 -5
- package/src/markdown.js +175 -0
- package/src/star-index.js +109 -0
- package/src/storage-stats.js +94 -0
- package/assets/screenshot-session-settingsmenu.png +0 -0
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.
|
|
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
|
+
}
|
package/src/client/index.jsx
CHANGED
|
@@ -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
|
-
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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
|
-
{
|
|
622
|
-
<div className="archv-empty">{query || workspaceFilter !== 'all' ? '没有匹配的会话。请调整搜索词或工作区筛选。' : filter === 'archived' ? '目前没有归档会话。在“全部”里选中会话点“归档”即可收纳进来。' : filter === 'active' ? '目前没有活动会话。' : '暂无可管理的会话。'}</div>
|
|
623
|
-
) :
|
|
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
|
package/src/client/logic.js
CHANGED
|
@@ -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
|
+
}
|