dsh-sessions-manager 3.2.1 → 3.3.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 +12 -1
- package/README.md +12 -1
- package/lib/client.js +147 -32
- package/lib/client.js.map +3 -3
- package/lib/index.js +299 -24
- package/lib/index.js.map +4 -4
- package/package.json +1 -1
- package/src/client/index.jsx +96 -34
- package/src/client/logic.js +59 -0
- package/src/index.js +77 -0
- package/src/markdown.js +175 -0
- package/src/star-index.js +109 -0
package/src/client/index.jsx
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import React, { useEffect, useMemo, useRef, useState } from 'react'
|
|
14
|
+
import { canDropOnWorkspace, dotStateFor, sessionForNodes, starredOf, workspaceForNodes } from './logic.js'
|
|
14
15
|
|
|
15
16
|
export const inject = ['slots']
|
|
16
17
|
|
|
@@ -54,6 +55,11 @@ const CSS = `
|
|
|
54
55
|
.archv-id{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:10.5px;color:var(--dsw-alias-label-tertiary);flex:none}
|
|
55
56
|
.archv-dot{color:var(--dsw-alias-border-l3);flex:none}
|
|
56
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}
|
|
57
63
|
.archv-body{flex:1;min-width:0;display:flex;align-items:center;gap:12px}
|
|
58
64
|
.archv-actions{display:flex;gap:8px;flex:none;flex-wrap:nowrap;justify-content:flex-end}
|
|
59
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}
|
|
@@ -99,6 +105,9 @@ const CSS = `
|
|
|
99
105
|
.dtl-k{font-size:11px;color:var(--dsw-alias-label-tertiary)}
|
|
100
106
|
.dtl-v{font-size:12px;color:var(--dsw-alias-label-primary);word-break:break-all}
|
|
101
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}
|
|
102
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}
|
|
103
112
|
.dtl-tags{display:flex;flex-wrap:wrap;gap:6px}
|
|
104
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}
|
|
@@ -211,7 +220,7 @@ function SessionPanel({ workspacesSvc }) {
|
|
|
211
220
|
const [sessions, setSessions] = useState(null)
|
|
212
221
|
const [workspaces, setWorkspaces] = useState([])
|
|
213
222
|
const initialPrefs = useRef(loadPanelPrefs()).current
|
|
214
|
-
const [filter, setFilter] = useState(() => ['all', 'active', 'archived', 'trash'].includes(initialPrefs.filter) ? initialPrefs.filter : 'all')
|
|
223
|
+
const [filter, setFilter] = useState(() => ['all', 'active', 'archived', 'starred', 'trash'].includes(initialPrefs.filter) ? initialPrefs.filter : 'all')
|
|
215
224
|
const [query, setQuery] = useState('')
|
|
216
225
|
const [workspaceFilter, setWorkspaceFilter] = useState(() => initialPrefs.workspaceFilter || 'all')
|
|
217
226
|
const [sortBy, setSortBy] = useState(() => ['newest', 'oldest', 'title'].includes(initialPrefs.sortBy) ? initialPrefs.sortBy : 'newest')
|
|
@@ -234,6 +243,9 @@ function SessionPanel({ workspacesSvc }) {
|
|
|
234
243
|
const [details, setDetails] = useState({})
|
|
235
244
|
const [openDetails, setOpenDetails] = useState(null)
|
|
236
245
|
const [detailsLoading, setDetailsLoading] = useState(null)
|
|
246
|
+
const [mdBusy, setMdBusy] = useState(null)
|
|
247
|
+
const [zipOk, setZipOk] = useState(true)
|
|
248
|
+
const zipChecked = useRef(false)
|
|
237
249
|
const [openMenu, setOpenMenu] = useState(null)
|
|
238
250
|
const timer = useRef(null)
|
|
239
251
|
const menuRef = useRef(null)
|
|
@@ -277,6 +289,53 @@ function SessionPanel({ workspacesSvc }) {
|
|
|
277
289
|
try { localStorage.setItem(PANEL_PREFS_KEY, JSON.stringify({ filter, workspaceFilter, sortBy })) } catch (e) {}
|
|
278
290
|
}, [filter, workspaceFilter, sortBy])
|
|
279
291
|
|
|
292
|
+
// 收藏切换:乐观更新 + 失败回滚(star 是高频轻操作,不等网络往返)。
|
|
293
|
+
const toggleStar = async (it) => {
|
|
294
|
+
const next = !it.starred
|
|
295
|
+
setSessions((s) => s && s.map((x) => (x.sessionId === it.sessionId ? { ...x, starred: next } : x)))
|
|
296
|
+
try {
|
|
297
|
+
await postJSON('/archived-sessions/star/set', { sessionId: it.sessionId, starred: next })
|
|
298
|
+
} catch (e) {
|
|
299
|
+
setSessions((s) => s && s.map((x) => (x.sessionId === it.sessionId ? { ...x, starred: !next } : x)))
|
|
300
|
+
showToast('收藏失败:' + String((e && e.message) || e))
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Markdown 导出:自有路由(/archived-sessions/export-md),blob 触发下载。
|
|
305
|
+
const exportMarkdown = async (it) => {
|
|
306
|
+
if (mdBusy) return
|
|
307
|
+
setMdBusy(it.sessionId)
|
|
308
|
+
try {
|
|
309
|
+
const res = await fetch('/archived-sessions/export-md?sessionId=' + encodeURIComponent(it.sessionId))
|
|
310
|
+
if (!res.ok) throw new Error('HTTP ' + res.status)
|
|
311
|
+
const blob = await res.blob()
|
|
312
|
+
const url = URL.createObjectURL(blob)
|
|
313
|
+
const a = document.createElement('a')
|
|
314
|
+
a.href = url
|
|
315
|
+
a.download = 'dsh-session-' + it.sessionId + '.md'
|
|
316
|
+
document.body.appendChild(a)
|
|
317
|
+
a.click()
|
|
318
|
+
a.remove()
|
|
319
|
+
URL.revokeObjectURL(url)
|
|
320
|
+
showToast('已导出 Markdown')
|
|
321
|
+
} catch (e) {
|
|
322
|
+
showToast('导出失败:' + String((e && e.message) || e))
|
|
323
|
+
} finally {
|
|
324
|
+
setMdBusy(null)
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// 官方 ZIP 导出预检(一次性):后端不支持 raw artifacts 时返回 501,
|
|
329
|
+
// 此时隐藏 ZIP 入口只留 Markdown(全局缓存,后端能力不会中途变)。
|
|
330
|
+
// 探针用非法 id 走 HEAD:命中 501 = 不支持;404/400 = 路由活着且支持。
|
|
331
|
+
useEffect(() => {
|
|
332
|
+
if (openDetails === null || zipChecked.current) return
|
|
333
|
+
zipChecked.current = true
|
|
334
|
+
fetch('/api/session.export?sessionId=probe&includeDescendants=false', { method: 'HEAD' })
|
|
335
|
+
.then((res) => setZipOk(res.status !== 501))
|
|
336
|
+
.catch(() => setZipOk(true))
|
|
337
|
+
}, [openDetails])
|
|
338
|
+
|
|
280
339
|
// Close the ⋯ menu on outside click / Escape (no full-screen backdrop).
|
|
281
340
|
useEffect(() => {
|
|
282
341
|
if (openMenu === null) return
|
|
@@ -313,9 +372,10 @@ function SessionPanel({ workspacesSvc }) {
|
|
|
313
372
|
|
|
314
373
|
const archivedList = sessions ? sessions.filter((x) => x.archived) : []
|
|
315
374
|
const activeList = sessions ? sessions.filter((x) => !x.archived) : []
|
|
375
|
+
const starredList = starredOf(sessions)
|
|
316
376
|
const list = useMemo(() => {
|
|
317
377
|
if (filter === 'trash') return []
|
|
318
|
-
const base = filter === 'archived' ? archivedList : filter === 'active' ? activeList : sessions || []
|
|
378
|
+
const base = filter === 'archived' ? archivedList : filter === 'active' ? activeList : filter === 'starred' ? starredList : sessions || []
|
|
319
379
|
const needle = query.trim().toLocaleLowerCase()
|
|
320
380
|
const filtered = base.filter((item) => {
|
|
321
381
|
if (workspaceFilter !== 'all' && (item.workspacePath || '') !== workspaceFilter) return false
|
|
@@ -568,6 +628,7 @@ function SessionPanel({ workspacesSvc }) {
|
|
|
568
628
|
<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>
|
|
569
629
|
<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>
|
|
570
630
|
<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>
|
|
631
|
+
<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>
|
|
571
632
|
<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>
|
|
572
633
|
</div>
|
|
573
634
|
|
|
@@ -592,13 +653,13 @@ function SessionPanel({ workspacesSvc }) {
|
|
|
592
653
|
</select>
|
|
593
654
|
</div>
|
|
594
655
|
</div>
|
|
595
|
-
<div className="sess-results" role="status">显示 {list.length} 个会话{query || workspaceFilter !== 'all' ? `,共 ${filter === 'archived' ? archivedList.length : filter === 'active' ? activeList.length : sessions.length} 个` : ''}</div>
|
|
656
|
+
<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>
|
|
596
657
|
</>
|
|
597
658
|
)}
|
|
598
659
|
|
|
599
660
|
{filter !== 'trash' && list.length > 0 && (
|
|
600
661
|
<div className="sess-batch">
|
|
601
|
-
<span className="sess-btntext">{selIds.length ? `已选 ${selIds.length} 项` : (filter === 'archived' ? `共 ${archivedList.length} 个归档会话` : `共 ${sessions.length} 个会话(活动 ${activeList.length} / 已归档 ${archivedList.length})`)}</span>
|
|
662
|
+
<span className="sess-btntext">{selIds.length ? `已选 ${selIds.length} 项` : (filter === 'archived' ? `共 ${archivedList.length} 个归档会话` : filter === 'starred' ? `共 ${starredList.length} 个收藏会话` : `共 ${sessions.length} 个会话(活动 ${activeList.length} / 已归档 ${archivedList.length})`)}</span>
|
|
602
663
|
<button type="button" className="archv-btn" disabled={list.length === 0} onClick={selectAll}>全选</button>
|
|
603
664
|
{selIds.length > 0 && (
|
|
604
665
|
<>
|
|
@@ -618,7 +679,7 @@ function SessionPanel({ workspacesSvc }) {
|
|
|
618
679
|
)}
|
|
619
680
|
|
|
620
681
|
{filter !== 'trash' && list.length === 0 ? (
|
|
621
|
-
<div className="archv-empty">{query || workspaceFilter !== 'all' ? '没有匹配的会话。请调整搜索词或工作区筛选。' : filter === 'archived' ? '目前没有归档会话。在“全部”里选中会话点“归档”即可收纳进来。' : filter === 'active' ? '目前没有活动会话。' : '暂无可管理的会话。'}</div>
|
|
682
|
+
<div className="archv-empty">{query || workspaceFilter !== 'all' ? '没有匹配的会话。请调整搜索词或工作区筛选。' : filter === 'archived' ? '目前没有归档会话。在“全部”里选中会话点“归档”即可收纳进来。' : filter === 'active' ? '目前没有活动会话。' : filter === 'starred' ? '还没有收藏的会话。点击会话左侧的星标即可收藏。' : '暂无可管理的会话。'}</div>
|
|
622
683
|
) : filter !== 'trash' ? (
|
|
623
684
|
<div className="archv-list" role="list">
|
|
624
685
|
{list.map((it) => {
|
|
@@ -634,6 +695,16 @@ function SessionPanel({ workspacesSvc }) {
|
|
|
634
695
|
onChange={() => toggle(it.sessionId)}
|
|
635
696
|
aria-label={'选择 ' + (it.title || it.sessionId)}
|
|
636
697
|
/>
|
|
698
|
+
<button
|
|
699
|
+
type="button"
|
|
700
|
+
className={'archv-star' + (it.starred ? ' archv-star-on' : '')}
|
|
701
|
+
aria-pressed={!!it.starred}
|
|
702
|
+
aria-label={(it.starred ? '取消收藏 ' : '收藏 ') + (it.title || it.sessionId)}
|
|
703
|
+
title={it.starred ? '取消收藏' : '收藏'}
|
|
704
|
+
onClick={(e) => { e.stopPropagation(); toggleStar(it) }}
|
|
705
|
+
>
|
|
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>
|
|
707
|
+
</button>
|
|
637
708
|
<div className="archv-body">
|
|
638
709
|
<div className="archv-main">
|
|
639
710
|
<div className="archv-name" title={it.title || ''}>{it.title || '(无标题)'}</div>
|
|
@@ -745,6 +816,20 @@ function SessionPanel({ workspacesSvc }) {
|
|
|
745
816
|
</div>
|
|
746
817
|
</div>
|
|
747
818
|
)}
|
|
819
|
+
<div className="dtl-sec">
|
|
820
|
+
<div className="dtl-sec-t">导出</div>
|
|
821
|
+
<div className="dtl-export">
|
|
822
|
+
<a
|
|
823
|
+
className="archv-btn"
|
|
824
|
+
href={`/api/session.export?sessionId=${encodeURIComponent(it.sessionId)}&includeDescendants=true`}
|
|
825
|
+
onClick={(e) => e.stopPropagation()}
|
|
826
|
+
style={zipOk ? undefined : { pointerEvents: 'none', opacity: 0.45 }}
|
|
827
|
+
title={zipOk ? '含子会话与附件,由 DSH 提供' : '当前持久化后端不支持原始日志导出'}
|
|
828
|
+
>下载原始日志 (ZIP)</a>
|
|
829
|
+
<button type="button" className="archv-btn" disabled={mdBusy === it.sessionId} onClick={() => exportMarkdown(it)}>{mdBusy === it.sessionId ? '生成中…' : '导出 Markdown'}</button>
|
|
830
|
+
</div>
|
|
831
|
+
<div className="dtl-note">ZIP 含子会话与附件,由 DSH 提供 · Markdown 为本插件生成的可读对话记录</div>
|
|
832
|
+
</div>
|
|
748
833
|
</div>
|
|
749
834
|
)
|
|
750
835
|
})()}
|
|
@@ -1263,15 +1348,8 @@ function installSidebarStatusDots() {
|
|
|
1263
1348
|
const sd = row.querySelector('[data-state]')
|
|
1264
1349
|
if (sd) sd.style.display = 'none'
|
|
1265
1350
|
let dot = row.querySelector('[' + DOT + ']')
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
else if (sd) {
|
|
1269
|
-
const st = sd.getAttribute('data-state')
|
|
1270
|
-
if (st === 'running') color = COLOR.running
|
|
1271
|
-
else if (st === 'warning') color = COLOR.feedback
|
|
1272
|
-
else if (st === 'error') color = COLOR.error
|
|
1273
|
-
else if (st === 'done') color = (activeId === id) ? null : COLOR.done
|
|
1274
|
-
}
|
|
1351
|
+
const state = dotStateFor({ manualUnread: manualUnread.has(id), dataState: sd ? sd.getAttribute('data-state') : '', isActive: activeId === id })
|
|
1352
|
+
const color = state ? COLOR[state] : null
|
|
1275
1353
|
if (!color) { if (dot) dot.remove(); return }
|
|
1276
1354
|
if (!dot) {
|
|
1277
1355
|
dot = document.createElement('span')
|
|
@@ -1331,28 +1409,12 @@ function installSidebarWorkspaceDrag() {
|
|
|
1331
1409
|
}
|
|
1332
1410
|
return out
|
|
1333
1411
|
}
|
|
1334
|
-
const sessionForRow = (row) =>
|
|
1335
|
-
for (const node of fiberNodes(row)) {
|
|
1336
|
-
const id = node && node.id != null ? String(node.id) : ''
|
|
1337
|
-
if (sessions.has(id)) return sessions.get(id)
|
|
1338
|
-
// The live DSH row is authoritative even before the async sidebar-state
|
|
1339
|
-
// refresh finishes. Workspace groups use `workspaceId`, never `id`.
|
|
1340
|
-
if (id && node.workspaceId == null && (node.title != null || node.updatedAt != null || node.blank != null)) {
|
|
1341
|
-
return { sessionId: id, title: node.title || '', workspacePath: null }
|
|
1342
|
-
}
|
|
1343
|
-
}
|
|
1344
|
-
return null
|
|
1345
|
-
}
|
|
1412
|
+
const sessionForRow = (row) => sessionForNodes(fiberNodes(row), sessions)
|
|
1346
1413
|
const workspaceForRow = (row) => {
|
|
1347
1414
|
// A session row's fiber chain also contains its parent workspace node.
|
|
1348
1415
|
// Reject it explicitly so only the visible workspace header is a drop zone.
|
|
1349
1416
|
if (sessionForRow(row)) return null
|
|
1350
|
-
|
|
1351
|
-
if (group.workspaceId != null && typeof group.cwd === 'string' && group.cwd) {
|
|
1352
|
-
return { workspaceId: String(group.workspaceId), path: group.cwd, title: group.label || group.cwd }
|
|
1353
|
-
}
|
|
1354
|
-
}
|
|
1355
|
-
return null
|
|
1417
|
+
return workspaceForNodes(fiberNodes(row))
|
|
1356
1418
|
}
|
|
1357
1419
|
const eventRow = (event) => {
|
|
1358
1420
|
for (const item of event.composedPath ? event.composedPath() : []) {
|
|
@@ -1404,7 +1466,7 @@ function installSidebarWorkspaceDrag() {
|
|
|
1404
1466
|
const row = eventRow(event)
|
|
1405
1467
|
const target = row && workspaceForRow(row)
|
|
1406
1468
|
if (!dragging || !target || moving) return
|
|
1407
|
-
if (dragging
|
|
1469
|
+
if (!canDropOnWorkspace(dragging, target)) return
|
|
1408
1470
|
event.preventDefault()
|
|
1409
1471
|
event.stopPropagation()
|
|
1410
1472
|
if (event.dataTransfer) event.dataTransfer.dropEffect = 'move'
|
|
@@ -1415,7 +1477,7 @@ function installSidebarWorkspaceDrag() {
|
|
|
1415
1477
|
const row = eventRow(event)
|
|
1416
1478
|
const item = dragging
|
|
1417
1479
|
const target = row && workspaceForRow(row)
|
|
1418
|
-
if (!item || !target || moving || (item
|
|
1480
|
+
if (!item || !target || moving || !canDropOnWorkspace(item, target)) return
|
|
1419
1481
|
event.preventDefault()
|
|
1420
1482
|
event.stopPropagation()
|
|
1421
1483
|
moving = true
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// dsh-sessions-manager — client 纯判定逻辑。
|
|
2
|
+
//
|
|
3
|
+
// 侧栏增强里最容易随 DSH 上游变化出回归的三块判定抽到这里:
|
|
4
|
+
// 状态点语义、拖拽可放置校验、fiber node → 会话/工作区识别。
|
|
5
|
+
// 本模块不碰 DOM,node --test 直接可测(tests/client-logic.test.js)。
|
|
6
|
+
|
|
7
|
+
// 状态点语义:manual 未读最高优先;done 在当前查看的行上不亮绿
|
|
8
|
+
// (读过即视为已读)。返回逻辑态名,颜色映射留在 UI 层。
|
|
9
|
+
// 历史回归:DSH 曾把 running 报为 ongoing(9766476),上游枚举变化要盯这里。
|
|
10
|
+
export function dotStateFor({ manualUnread = false, dataState = '', isActive = false } = {}) {
|
|
11
|
+
if (manualUnread) return 'manual'
|
|
12
|
+
switch (dataState) {
|
|
13
|
+
case 'running': return 'running'
|
|
14
|
+
case 'warning': return 'feedback'
|
|
15
|
+
case 'error': return 'error'
|
|
16
|
+
case 'done': return isActive ? null : 'done'
|
|
17
|
+
default: return null
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// 拖拽迁移前置校验:同工作区拦截(workspacePath 相等即拒绝)。
|
|
22
|
+
// 无 workspacePath 的会话(如侧栏 live 行尚未同步)放行,由 host 最终裁决。
|
|
23
|
+
export function canDropOnWorkspace(item, target) {
|
|
24
|
+
if (!item || !target) return false
|
|
25
|
+
if (item.workspacePath && target.path === item.workspacePath) return false
|
|
26
|
+
return true
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// 从 React fiber 链收集到的 node 数组识别会话行。knownSessions 是
|
|
30
|
+
// host /archived-sessions/sessions 的权威表;live 行(还没同步到权威表)
|
|
31
|
+
// 用启发式兜底:有 id、不是工作区分组(无 workspaceId)、且带标题/时间。
|
|
32
|
+
export function sessionForNodes(nodes, knownSessions) {
|
|
33
|
+
for (const node of nodes || []) {
|
|
34
|
+
const id = node && node.id != null ? String(node.id) : ''
|
|
35
|
+
if (knownSessions.has(id)) return knownSessions.get(id)
|
|
36
|
+
// DSH 的行节点有 id;工作区分组用 workspaceId、从不用裸 id。
|
|
37
|
+
if (id && node.workspaceId == null && (node.title != null || node.updatedAt != null || node.blank != null)) {
|
|
38
|
+
return { sessionId: id, title: node.title || '', workspacePath: null }
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return null
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// 从 fiber node 数组识别可见的工作区标题行(可放置目标)。
|
|
45
|
+
// 会话行的 fiber 链也会带出父工作区节点,调用方须先用 sessionForNodes 拦截。
|
|
46
|
+
export function workspaceForNodes(nodes) {
|
|
47
|
+
for (const group of nodes || []) {
|
|
48
|
+
if (group && group.workspaceId != null && typeof group.cwd === 'string' && group.cwd) {
|
|
49
|
+
return { workspaceId: String(group.workspaceId), path: group.cwd, title: group.label || group.cwd }
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return null
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// 收藏过滤:返回已收藏子集。star 是用户标记,与 DSH 的活动/归档状态正交
|
|
56
|
+
// (可叠加),所以这里不做任何状态联合判断,只认 starred 字段。
|
|
57
|
+
export function starredOf(items) {
|
|
58
|
+
return (items || []).filter((item) => item && item.starred)
|
|
59
|
+
}
|
package/src/index.js
CHANGED
|
@@ -12,6 +12,8 @@ import { basename, dirname, isAbsolute, join } from 'node:path'
|
|
|
12
12
|
import { readFileSync } from 'node:fs'
|
|
13
13
|
import { homedir } from 'node:os'
|
|
14
14
|
import { rewriteFrame0Cwd } from './zstd-frame.js'
|
|
15
|
+
import { renderSessionMarkdown } from './markdown.js'
|
|
16
|
+
import { createStarIndex } from './star-index.js'
|
|
15
17
|
|
|
16
18
|
export const name = 'dsh-sessions-manager'
|
|
17
19
|
export const inject = ['webServer', 'workspaceRegistry', 'sessionPersistence', 'sessionQuery', 'storageDomain']
|
|
@@ -226,6 +228,21 @@ export function apply(ctx) {
|
|
|
226
228
|
return operation
|
|
227
229
|
}
|
|
228
230
|
|
|
231
|
+
// ---- Starred sessions (收藏, schema v3) -----------------------------------
|
|
232
|
+
// User marks, kept in the plugin's own index (never touches DSH logs). Stars
|
|
233
|
+
// survive archive & soft-delete — both are reversible — and are dropped only
|
|
234
|
+
// when the session is really gone (purge, or externally removed; the latter
|
|
235
|
+
// is caught by gcStars during list builds).
|
|
236
|
+
const stars = createStarIndex()
|
|
237
|
+
async function gcStars(validIds) {
|
|
238
|
+
try {
|
|
239
|
+
const store = await stars.read()
|
|
240
|
+
const valid = new Set(validIds.map(String))
|
|
241
|
+
const gone = store.starredSessionIds.filter((id) => !valid.has(id))
|
|
242
|
+
if (gone.length) await stars.removeIds(gone)
|
|
243
|
+
} catch (e) { /* best-effort */ }
|
|
244
|
+
}
|
|
245
|
+
|
|
229
246
|
// Soft-delete one session: record it in the recycle-bin index but KEEP its
|
|
230
247
|
// log in the original workspace directory. Moving the file out (and detaching
|
|
231
248
|
// it from the workspace) orphaned the session into DSH's "未分组" group and
|
|
@@ -364,6 +381,7 @@ export function apply(ctx) {
|
|
|
364
381
|
purged = true
|
|
365
382
|
})
|
|
366
383
|
if (!purged) throw new Error('彻底删除失败')
|
|
384
|
+
stars.removeIds([sid]).catch(() => {})
|
|
367
385
|
return { ok: true, purged: true }
|
|
368
386
|
}
|
|
369
387
|
|
|
@@ -644,9 +662,11 @@ export function apply(ctx) {
|
|
|
644
662
|
async function allSessionItems() {
|
|
645
663
|
let materialized = new Set()
|
|
646
664
|
let live = ctx.get('sessions')
|
|
665
|
+
let headersOk = false
|
|
647
666
|
try {
|
|
648
667
|
const headers = await sp.list()
|
|
649
668
|
materialized = new Set(headers.map((h) => String(h.id)))
|
|
669
|
+
headersOk = true
|
|
650
670
|
} catch (e) { /* best-effort */ }
|
|
651
671
|
const ids = []
|
|
652
672
|
try { for (const header of await sp.list()) ids.push(String(header.id)) } catch (e) { /* ignore */ }
|
|
@@ -668,6 +688,12 @@ export function apply(ctx) {
|
|
|
668
688
|
const res2 = await Promise.all(visibleIds.slice(i, i + CHUNK).map(resolveOne))
|
|
669
689
|
for (const it of res2) items.push({ ...it, archived: currentArchived.has(it.sessionId) })
|
|
670
690
|
}
|
|
691
|
+
// Annotate stars; GC only when we have a trustworthy id baseline, so a
|
|
692
|
+
// failing sp.list() can never wipe the whole index.
|
|
693
|
+
let starredSet = new Set()
|
|
694
|
+
try { starredSet = new Set((await stars.read()).starredSessionIds) } catch (e) {}
|
|
695
|
+
for (const it of items) it.starred = starredSet.has(String(it.sessionId))
|
|
696
|
+
if (headersOk) await gcStars(ids)
|
|
671
697
|
return items
|
|
672
698
|
}
|
|
673
699
|
|
|
@@ -1049,6 +1075,57 @@ export function apply(ctx) {
|
|
|
1049
1075
|
},
|
|
1050
1076
|
}))
|
|
1051
1077
|
|
|
1078
|
+
// Star / unstar one or many sessions (收藏, schema v3).
|
|
1079
|
+
disposers.push(ctx.webServer.register({
|
|
1080
|
+
kind: 'exact',
|
|
1081
|
+
path: '/archived-sessions/star/set',
|
|
1082
|
+
handler: async (req, res) => {
|
|
1083
|
+
try {
|
|
1084
|
+
const body = await readJsonBody(req)
|
|
1085
|
+
const starred = !!(body && body.starred)
|
|
1086
|
+
let ids = parseIds(body)
|
|
1087
|
+
if ((!ids || ids.length === 0) && body && typeof body.sessionId === 'string') {
|
|
1088
|
+
ids = isSafeSessionId(body.sessionId) ? [body.sessionId] : null
|
|
1089
|
+
}
|
|
1090
|
+
if (!ids || ids.length === 0) return json(res, { ok: false, error: 'missing sessionId' }, 400)
|
|
1091
|
+
const starredSessionIds = await stars.setStarred(ids, starred)
|
|
1092
|
+
json(res, { ok: true, starredSessionIds })
|
|
1093
|
+
} catch (e) {
|
|
1094
|
+
json(res, { ok: false, error: String((e && e.message) || e) }, errorStatus(e))
|
|
1095
|
+
}
|
|
1096
|
+
},
|
|
1097
|
+
}))
|
|
1098
|
+
|
|
1099
|
+
// Human-readable Markdown export (one session). Raw-log ZIP export is
|
|
1100
|
+
// dsh's own GET /api/session.export — we deliberately do not duplicate it
|
|
1101
|
+
// (see reports/HANDOFF-dsh-sessions-manager-roadmap.md §2.4).
|
|
1102
|
+
disposers.push(ctx.webServer.register({
|
|
1103
|
+
kind: 'exact',
|
|
1104
|
+
path: '/archived-sessions/export-md',
|
|
1105
|
+
handler: async (req, res) => {
|
|
1106
|
+
try {
|
|
1107
|
+
const url = new URL(req.url, 'http://localhost')
|
|
1108
|
+
const sid = url.searchParams.get('sessionId')
|
|
1109
|
+
requireSessionId(sid)
|
|
1110
|
+
const r = await sp.readFrom(sid, 0)
|
|
1111
|
+
if (!r || !r.meta) {
|
|
1112
|
+
const error = new Error('无法读取该会话的日志')
|
|
1113
|
+
error.status = 404
|
|
1114
|
+
throw error
|
|
1115
|
+
}
|
|
1116
|
+
const md = renderSessionMarkdown({ ...r.meta, id: sid }, r.events || [])
|
|
1117
|
+
res.writeHead(200, {
|
|
1118
|
+
'content-type': 'text/markdown; charset=utf-8',
|
|
1119
|
+
'content-disposition': `attachment; filename="dsh-session-${sid}.md"`,
|
|
1120
|
+
'cache-control': 'no-store',
|
|
1121
|
+
})
|
|
1122
|
+
res.end(md)
|
|
1123
|
+
} catch (e) {
|
|
1124
|
+
json(res, { error: String((e && e.message) || e) }, errorStatus(e))
|
|
1125
|
+
}
|
|
1126
|
+
},
|
|
1127
|
+
}))
|
|
1128
|
+
|
|
1052
1129
|
disposers.push(ctx.webServer.register({
|
|
1053
1130
|
kind: 'exact',
|
|
1054
1131
|
path: '/archived-sessions/sidebar-state',
|
package/src/markdown.js
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// Render a session log as human-readable Markdown.
|
|
2
|
+
//
|
|
3
|
+
// Pure: no DOM, no I/O, no dsh imports. Everything it needs arrives as
|
|
4
|
+
// arguments, so the renderer is unit-testable without a running host.
|
|
5
|
+
//
|
|
6
|
+
// Field shapes below were read off real session logs (2026-09-01), not guessed:
|
|
7
|
+
// user/message data.content = [{ type:'text', text } | { type:'image', ... }]
|
|
8
|
+
// assistant/message data.message.content = [{ type:'text'|'reasoning'|'tool-call', ... }]
|
|
9
|
+
// tool/call data.{name, arguments} (arguments is a JSON *string*)
|
|
10
|
+
// tool/result data.message.content = [{ type:'tool-result', content:[{type:'text',text}] }]
|
|
11
|
+
// Note the asymmetry: user text lives at data.content, assistant text one level
|
|
12
|
+
// deeper at data.message.content. Streaming deltas (`assistant/chunk`,
|
|
13
|
+
// `text-chunks`, `reasoning-chunks`) are never rendered — `assistant/message`
|
|
14
|
+
// already carries the final text for each step.
|
|
15
|
+
|
|
16
|
+
const MAX_TOOL_ARG = 200
|
|
17
|
+
|
|
18
|
+
function isoTime(value) {
|
|
19
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return null
|
|
20
|
+
try { return new Date(value).toISOString() } catch { return null }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function yamlString(value) {
|
|
24
|
+
return `"${String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\r?\n/g, '\\n')}"`
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function blocksOf(value) {
|
|
28
|
+
return Array.isArray(value) ? value.filter((b) => b && typeof b === 'object') : []
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Join the text blocks of a content array; image blocks are counted separately.
|
|
32
|
+
function textFromBlocks(blocks) {
|
|
33
|
+
const parts = []
|
|
34
|
+
for (const block of blocks) {
|
|
35
|
+
if (block.type === 'text' && typeof block.text === 'string') parts.push(block.text)
|
|
36
|
+
}
|
|
37
|
+
return parts.join('\n\n').trim()
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function imageCountOf(blocks) {
|
|
41
|
+
let count = 0
|
|
42
|
+
for (const block of blocks) if (block.type === 'image') count++
|
|
43
|
+
return count
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function reasoningFromBlocks(blocks) {
|
|
47
|
+
const parts = []
|
|
48
|
+
for (const block of blocks) {
|
|
49
|
+
if (block.type === 'reasoning' && typeof block.text === 'string' && block.text.trim()) parts.push(block.text.trim())
|
|
50
|
+
}
|
|
51
|
+
return parts.join('\n\n')
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// A short, human-usable summary of one tool call's arguments.
|
|
55
|
+
export function summarizeToolArguments(name, rawArguments) {
|
|
56
|
+
let parsed = null
|
|
57
|
+
if (typeof rawArguments === 'string') {
|
|
58
|
+
try { parsed = JSON.parse(rawArguments) } catch { parsed = null }
|
|
59
|
+
} else if (rawArguments && typeof rawArguments === 'object') {
|
|
60
|
+
parsed = rawArguments
|
|
61
|
+
}
|
|
62
|
+
if (parsed === null) return typeof rawArguments === 'string' ? rawArguments.slice(0, MAX_TOOL_ARG) : ''
|
|
63
|
+
if (typeof parsed !== 'object') return String(parsed).slice(0, MAX_TOOL_ARG)
|
|
64
|
+
const preferred = ['command', 'file_path', 'path', 'query', 'url', 'pattern']
|
|
65
|
+
for (const key of preferred) {
|
|
66
|
+
if (typeof parsed[key] === 'string' && parsed[key].trim()) return parsed[key]
|
|
67
|
+
}
|
|
68
|
+
const keys = Object.keys(parsed)
|
|
69
|
+
if (keys.length === 0) return ''
|
|
70
|
+
const rest = {}
|
|
71
|
+
for (const key of keys.slice(0, 6)) {
|
|
72
|
+
const value = parsed[key]
|
|
73
|
+
rest[key] = typeof value === 'string' ? value : JSON.stringify(value)
|
|
74
|
+
}
|
|
75
|
+
return JSON.stringify(rest).slice(0, MAX_TOOL_ARG)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Render one session as Markdown.
|
|
80
|
+
* @param {object} meta - Session header (`{ id, cwd, createdAt, title? }`).
|
|
81
|
+
* @param {Array<object>} events - Session events as stored in the log.
|
|
82
|
+
* @param {object} [options]
|
|
83
|
+
* @param {boolean} [options.includeReasoning=false] - Emit assistant reasoning blocks.
|
|
84
|
+
* @param {boolean} [options.includeToolResults=false] - Emit tool results.
|
|
85
|
+
* @param {number} [options.exportedAt] - Override the export timestamp (tests).
|
|
86
|
+
* @returns {string} Markdown document.
|
|
87
|
+
*/
|
|
88
|
+
export function renderSessionMarkdown(meta, events, options = {}) {
|
|
89
|
+
const includeReasoning = options.includeReasoning === true
|
|
90
|
+
const includeToolResults = options.includeToolResults === true
|
|
91
|
+
const header = meta && typeof meta === 'object' ? meta : {}
|
|
92
|
+
const list = Array.isArray(events) ? events : []
|
|
93
|
+
|
|
94
|
+
// The last session/title event wins — DSH may retitle a session later on.
|
|
95
|
+
let title = typeof header.title === 'string' && header.title.trim() ? header.title.trim() : null
|
|
96
|
+
for (const ev of list) {
|
|
97
|
+
const data = ev && ev.data
|
|
98
|
+
if (ev && ev.type === 'session/title' && data && typeof data.title === 'string' && data.title.trim()) {
|
|
99
|
+
title = data.title.trim()
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const front = ['---']
|
|
104
|
+
if (title) front.push(`title: ${yamlString(title)}`)
|
|
105
|
+
if (typeof header.id === 'string' && header.id) front.push(`sessionId: ${yamlString(header.id)}`)
|
|
106
|
+
if (typeof header.cwd === 'string' && header.cwd) front.push(`cwd: ${yamlString(header.cwd)}`)
|
|
107
|
+
const created = isoTime(header.createdAt)
|
|
108
|
+
if (created) front.push(`createdAt: ${created}`)
|
|
109
|
+
const exported = isoTime(options.exportedAt)
|
|
110
|
+
if (exported) front.push(`exportedAt: ${exported}`)
|
|
111
|
+
front.push('---')
|
|
112
|
+
|
|
113
|
+
const out = [front.join('\n')]
|
|
114
|
+
if (title) out.push('', `# ${title}`)
|
|
115
|
+
|
|
116
|
+
let turn = null
|
|
117
|
+
for (const ev of list) {
|
|
118
|
+
if (!ev || typeof ev !== 'object') continue
|
|
119
|
+
const data = ev.data && typeof ev.data === 'object' ? ev.data : {}
|
|
120
|
+
const type = ev.type
|
|
121
|
+
|
|
122
|
+
if (type === 'turn/start') {
|
|
123
|
+
const next = Number.isInteger(data.turn) ? data.turn : null
|
|
124
|
+
if (next !== null && next !== turn) {
|
|
125
|
+
turn = next
|
|
126
|
+
out.push('', `## 第 ${turn} 轮`)
|
|
127
|
+
}
|
|
128
|
+
continue
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (type === 'user/message') {
|
|
132
|
+
const blocks = blocksOf(data.content)
|
|
133
|
+
const text = textFromBlocks(blocks)
|
|
134
|
+
const images = imageCountOf(blocks)
|
|
135
|
+
if (!text && images === 0) continue
|
|
136
|
+
out.push('', '### 用户', '')
|
|
137
|
+
if (text) out.push(text)
|
|
138
|
+
for (let i = 0; i < images; i++) out.push('', ``)
|
|
139
|
+
continue
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (type === 'assistant/message') {
|
|
143
|
+
const message = data.message && typeof data.message === 'object' ? data.message : {}
|
|
144
|
+
const blocks = blocksOf(message.content)
|
|
145
|
+
const text = textFromBlocks(blocks)
|
|
146
|
+
const reasoning = includeReasoning ? reasoningFromBlocks(blocks) : ''
|
|
147
|
+
if (!text && !reasoning) continue
|
|
148
|
+
out.push('', '### 助手', '')
|
|
149
|
+
if (reasoning) out.push('> 思考:' + reasoning.split('\n').join('\n> '), '')
|
|
150
|
+
if (text) out.push(text)
|
|
151
|
+
continue
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (type === 'tool/call') {
|
|
155
|
+
const name = typeof data.name === 'string' && data.name ? data.name : 'tool'
|
|
156
|
+
const summary = summarizeToolArguments(name, data.arguments)
|
|
157
|
+
out.push('', `### 工具调用:\`${name}\``, '')
|
|
158
|
+
out.push(summary ? '```\n' + summary + '\n```' : '(无参数)')
|
|
159
|
+
continue
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (type === 'tool/result' && includeToolResults) {
|
|
163
|
+
const message = data.message && typeof data.message === 'object' ? data.message : {}
|
|
164
|
+
const blocks = blocksOf(message.content)
|
|
165
|
+
let text = ''
|
|
166
|
+
for (const block of blocks) {
|
|
167
|
+
if (block.type === 'tool-result') text = textFromBlocks(blocksOf(block.content))
|
|
168
|
+
}
|
|
169
|
+
if (text) out.push('', '<details><summary>工具结果</summary>', '', '```\n' + text.slice(0, 2000) + '\n```', '', '</details>')
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
out.push('')
|
|
174
|
+
return out.join('\n')
|
|
175
|
+
}
|