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.
@@ -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
- let color = null
1267
- if (manualUnread.has(id)) color = COLOR.manual
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
- for (const group of fiberNodes(row)) {
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.workspacePath && target.path === dragging.workspacePath) return
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.workspacePath && target.path === item.workspacePath)) return
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',
@@ -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('', `![图片 ${i + 1}](attachment)`)
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
+ }