@tobycaimf/dsh-archived-sessions 1.0.0 → 1.0.2

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.
@@ -1,10 +1,11 @@
1
1
  /**
2
- * dsh-archived-sessions — browser half: renders the "归档会话" Settings
3
- * section (settings.section list slot). It talks to the host half's
4
- * /archived-sessions/* JSON routes (list / restore / delete) by fetch,
5
- * showing title, session date, and a workspace tag (handles deleted
6
- * workspaces). All DOM/runtime wiring failures are logged, never thrown — a
7
- * thrown plugin apply takes down the whole web-shell boot.
2
+ * dsh-archived-sessions — browser half: renders a single "会话管理" settings
3
+ * section (settings.section list slot) that unifies archived-session
4
+ * management and cross-workspace moving. It talks to the host half's
5
+ * /archived-sessions/* JSON routes by fetch, showing every conversation with
6
+ * its archive state and offering archive / restore / delete / move (and batch)
7
+ * actions. All DOM/runtime wiring failures are logged, never thrown — a thrown
8
+ * plugin apply takes down the whole web-shell boot.
8
9
  *
9
10
  * @module dsh-archived-sessions/client
10
11
  */
@@ -21,25 +22,36 @@ const CSS = `
21
22
  .archv-sub{font-size:12px;line-height:1.55;color:var(--dsw-alias-label-tertiary);margin:0 0 12px;max-width:64ch}
22
23
  .archv-err{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:9px 12px;border:1px solid color-mix(in srgb,var(--dsw-alias-state-error-primary) 40%,transparent);background:color-mix(in srgb,var(--dsw-alias-state-error-primary) 8%,transparent);border-radius:10px;color:var(--dsw-alias-state-error-primary);font-size:12px;margin-bottom:10px}
23
24
  .archv-errretry{appearance:none;border:1px solid color-mix(in srgb,var(--dsw-alias-state-error-primary) 45%,transparent);background:transparent;color:inherit;border-radius:8px;padding:4px 10px;font-size:11px;cursor:pointer;flex:none}
24
- .archv-errretry:focus-visible,.archv-btn:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary);outline-offset:2px}
25
+ .sess-filter{display:flex;align-items:center;gap:6px;margin:0 0 12px}
26
+ .sess-fbtn{appearance:none;min-height:30px;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:999px;font-size:12px;font-weight:500;cursor:pointer}
27
+ .sess-fbtn:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
28
+ .sess-fbtn-on{background:var(--dsw-alias-interactive-bg-active);color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-border-l3)}
29
+ .sess-batch{display:flex;align-items:center;gap:8px;flex-wrap:wrap;padding:8px 2px 4px;margin-bottom:4px}
30
+ .sess-btntext{font-size:12px;color:var(--dsw-alias-label-tertiary);flex:none}
25
31
  .archv-list{display:flex;flex-direction:column;gap:8px}
26
- .archv-card{display:flex;align-items:center;justify-content:space-between;gap:14px;padding:12px 14px;border:1px solid var(--dsw-alias-border-l2);border-radius:12px;background:var(--dsw-alias-fill-elevated);transition:border-color .15s ease,background-color .15s ease}
32
+ .archv-card{display:flex;flex-direction:column;align-items:stretch;gap:0;padding:12px 14px;border:1px solid var(--dsw-alias-border-l2);border-radius:12px;background:var(--dsw-alias-fill-elevated);transition:border-color .15s ease,background-color .15s ease}
27
33
  .archv-card:hover{border-color:var(--dsw-alias-border-l4)}
34
+ .archv-card-exp{border-color:var(--dsw-alias-border-l4);background:var(--dsw-alias-bg-layer-1)}
35
+ .archv-row{display:flex;align-items:center;gap:14px;width:100%;min-width:0}
28
36
  .archv-main{min-width:0;display:flex;flex-direction:column;gap:4px}
29
37
  .archv-name{font-size:13px;font-weight:550;color:var(--dsw-alias-label-primary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
30
38
  .archv-meta{display:flex;align-items:center;gap:7px;flex-wrap:wrap;min-width:0}
31
39
  .archv-wtag{display:inline-flex;align-items:center;gap:4px;max-width:100%;font-size:11px;font-weight:500;line-height:1;color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-fill-subtle);border:1px solid var(--dsw-alias-border-l2);border-radius:999px;padding:3px 8px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
32
40
  .archv-wgone{border-style:dashed;color:var(--dsw-alias-state-error-primary);border-color:color-mix(in srgb,var(--dsw-alias-state-error-primary) 55%,transparent);background:color-mix(in srgb,var(--dsw-alias-state-error-primary) 7%,transparent)}
41
+ .archv-active{color:var(--dsw-alias-state-success-primary);border-color:color-mix(in srgb,var(--dsw-alias-state-success-primary) 45%,transparent);background:color-mix(in srgb,var(--dsw-alias-state-success-primary) 8%,transparent)}
33
42
  .archv-date{font-size:11px;color:var(--dsw-alias-label-tertiary);white-space:nowrap;flex:none}
34
43
  .archv-id{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:10.5px;color:var(--dsw-alias-label-tertiary);flex:none}
35
44
  .archv-dot{color:var(--dsw-alias-border-l3);flex:none}
36
- .archv-actions{display:flex;gap:8px;flex:none}
45
+ .archv-check{width:15px;height:15px;accent-color:var(--dsw-alias-state-business-primary);flex:none;cursor:pointer}
46
+ .archv-body{flex:1;min-width:0;display:flex;align-items:center;gap:12px}
47
+ .archv-actions{display:flex;gap:8px;flex:none;flex-wrap:wrap;justify-content:flex-end}
37
48
  .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;display:inline-flex;align-items:center;gap:6px;transition:background-color .15s ease,border-color .15s ease,color .15s ease}
38
49
  .archv-btn:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
39
50
  .archv-btn:disabled{opacity:.5;cursor:default}
40
- .archv-restore{min-width:62px;justify-content:center}
41
51
  .archv-del{color:var(--dsw-alias-state-error-primary);border-color:color-mix(in srgb,var(--dsw-alias-state-error-primary) 45%,transparent)}
42
52
  .archv-del:hover:not(:disabled){background:color-mix(in srgb,var(--dsw-alias-state-error-primary) 10%,transparent);color:var(--dsw-alias-state-error-primary)}
53
+ .archv-go{color:var(--dsw-alias-state-business-primary);border-color:color-mix(in srgb,var(--dsw-alias-state-business-primary) 45%,transparent)}
54
+ .archv-go:hover:not(:disabled){background:color-mix(in srgb,var(--dsw-alias-state-business-primary) 10%,transparent);color:var(--dsw-alias-state-business-primary)}
43
55
  .archv-empty{display:flex;align-items:center;gap:10px;padding:20px 14px;border:1px dashed var(--dsw-alias-border-l3);border-radius:12px;color:var(--dsw-alias-label-tertiary);font-size:13px;line-height:1.5}
44
56
  .archv-skel{display:flex;flex-direction:column;gap:8px}
45
57
  .archv-skel-card{height:58px;border-radius:12px;background:var(--dsw-alias-fill-subtle);position:relative;overflow:hidden}
@@ -50,7 +62,24 @@ const CSS = `
50
62
  @keyframes archv-rot{to{transform:rotate(360deg)}}
51
63
  @keyframes archv-pop{from{opacity:0;transform:translateX(-50%) translateY(10px)}}
52
64
  @media (prefers-reduced-motion:reduce){.archv-skel-card::after{animation:none}.archv-card,.archv-btn{transition:none}.archv-status,.archv-spin{animation:none}}
53
- @media (max-width:560px){.archv-card{flex-direction:column;align-items:stretch;gap:10px}.archv-actions{justify-content:flex-end}}
65
+ @media (max-width:640px){.archv-card{flex-direction:column;align-items:stretch;gap:10px}.archv-actions{justify-content:flex-end}}
66
+ .mv-sheet{width:100%;box-sizing:border-box;display:flex;flex-direction:column;gap:12px;margin-top:12px;padding:14px;border:1px solid var(--dsw-alias-border-l2);border-left:3px solid var(--dsw-alias-state-business-primary);border-radius:10px;background:var(--dsw-alias-fill-subtle)}
67
+ .mv-sheet-head{display:flex;align-items:center;justify-content:space-between;gap:10px}
68
+ .mv-sheet-title{font-size:13px;font-weight:600;color:var(--dsw-alias-label-primary);margin:0}
69
+ .mv-sheet-close{appearance:none;width:26px;height:26px;color:var(--dsw-alias-label-secondary);background:0 0;border:none;border-radius:7px;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;font-size:16px;line-height:1}
70
+ .mv-sheet-close:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
71
+ .mv-seg{display:flex;gap:2px;padding:2px;background:var(--dsw-alias-fill-elevated);border:1px solid var(--dsw-alias-border-l2);border-radius:10px;width:100%}
72
+ .mv-segbtn{appearance:none;flex:1;min-height:30px;padding:0 12px;border:none;background:transparent;color:var(--dsw-alias-label-secondary);border-radius:8px;font-size:12px;font-weight:500;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;gap:6px}
73
+ .mv-segbtn:hover:not(:disabled){color:var(--dsw-alias-label-primary)}
74
+ .mv-segbtn-on{background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);box-shadow:0 1px 2px rgb(0 0 0/.08)}
75
+ .mv-field{display:flex;flex-direction:column;gap:6px}
76
+ .mv-field label.mv-field-label{font-size:12px;font-weight:500;color:var(--dsw-alias-label-secondary)}
77
+ .mv-field select,.mv-field input[type=text]{box-sizing:border-box;appearance:none;width:100%;min-height:34px;padding:0 10px;border:1px solid var(--dsw-alias-border-l2);border-radius:9px;background:var(--dsw-alias-fill-elevated);color:var(--dsw-alias-label-primary);font-size:12px;font-family:inherit}
78
+ .mv-field select:focus-visible,.mv-field input[type=text]:focus-visible,.mv-sheet-close:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary);outline-offset:1px}
79
+ .mv-browse-row{display:flex;align-items:center;gap:8px}
80
+ .mv-browse-row input[type=text]{flex:1;min-width:0}
81
+ .mv-foot{display:flex;justify-content:flex-end;align-items:center;gap:8px;margin-top:2px}
82
+ @media (max-width:640px){.archv-row{flex-wrap:wrap}.mv-sheet{padding:12px}}
54
83
  `
55
84
 
56
85
  function fmtDate(iso) {
@@ -81,12 +110,69 @@ async function postJSON(path, body) {
81
110
  return data
82
111
  }
83
112
 
84
- function ArchivedPanel() {
85
- const [items, setItems] = useState(null)
113
+ // Left-nav icon swap: DSH's settings shell renders a shared fallback gear for
114
+ // every custom section and settings.section has no per-section icon field.
115
+ // Like dsh-better-sidebar, we mark our own row by matching its visible label
116
+ // text, then CSS swaps the gear for an archive-box glyph. The marker owns no
117
+ // shell structure and is removed on fiber disposal (HMR-safe, ships with the
118
+ // plugin).
119
+ const NAV_CSS = `
120
+ [data-dsh-nav-sessions] > svg:first-child { display: none; }
121
+ [data-dsh-nav-sessions]::before {
122
+ content: ''; flex: none; width: 16px; height: 16px; background: currentColor;
123
+ -webkit-mask: url("data:image/svg+xml,%3Csvg%20xmlns%3D'http://www.w3.org/2000/svg'%20width%3D'24'%20height%3D'24'%20viewBox%3D'0%200%2024%2024'%20fill%3D'none'%20stroke%3D'black'%20stroke-width%3D'2'%20stroke-linecap%3D'round'%20stroke-linejoin%3D'round'%3E%3Cpath%20d%3D'M21%208v13H3V8'/%3E%3Cpath%20d%3D'M1%203h22v5H1z'/%3E%3Cpath%20d%3D'M10%2012h4'/%3E%3C/svg%3E") center / contain no-repeat;
124
+ mask: url("data:image/svg+xml,%3Csvg%20xmlns%3D'http://www.w3.org/2000/svg'%20width%3D'24'%20height%3D'24'%20viewBox%3D'0%200%2024%2024'%20fill%3D'none'%20stroke%3D'black'%20stroke-width%3D'2'%20stroke-linecap%3D'round'%20stroke-linejoin%3D'round'%3E%3Cpath%20d%3D'M21%208v13H3V8'/%3E%3Cpath%20d%3D'M1%203h22v5H1z'/%3E%3Cpath%20d%3D'M10%2012h4'/%3E%3C/svg%3E") center / contain no-repeat;
125
+ }
126
+ `
127
+
128
+ function markSettingsNav() {
129
+ const LABEL = '会话管理'
130
+ let disposed = false
131
+ const sync = () => {
132
+ if (disposed) return
133
+ const buttons = document.querySelectorAll('[role="dialog"] nav button')
134
+ for (const b of buttons) {
135
+ const t = (b.textContent || '').trim()
136
+ if (t === LABEL) b.setAttribute('data-dsh-nav-sessions', '')
137
+ else b.removeAttribute('data-dsh-nav-sessions')
138
+ }
139
+ }
140
+ sync()
141
+ const obs = new MutationObserver(sync)
142
+ obs.observe(document.body, { childList: true, subtree: true, characterData: true })
143
+ return () => {
144
+ disposed = true
145
+ obs.disconnect()
146
+ document.querySelectorAll('[data-dsh-nav-sessions]').forEach((e) => e.removeAttribute('data-dsh-nav-sessions'))
147
+ }
148
+ }
149
+
150
+ function installSettingsNavIcons(ctx) {
151
+ const styleEl = document.createElement('style')
152
+ styleEl.textContent = NAV_CSS
153
+ document.head.appendChild(styleEl)
154
+ const dispose = markSettingsNav()
155
+ ctx.effect(() => () => {
156
+ dispose()
157
+ if (styleEl.parentNode) styleEl.parentNode.removeChild(styleEl)
158
+ })
159
+ }
160
+
161
+ function SessionPanel({ workspacesSvc }) {
162
+ const [sessions, setSessions] = useState(null)
163
+ const [workspaces, setWorkspaces] = useState([])
164
+ const [filter, setFilter] = useState('all') // 'all' | 'archived'
165
+ const [selected, setSelected] = useState({})
166
+ const [confirmDel, setConfirmDel] = useState(null)
167
+ const [confirmBatch, setConfirmBatch] = useState(false)
86
168
  const [busy, setBusy] = useState(null)
87
- const [confirmId, setConfirmId] = useState(null)
169
+ const [openMove, setOpenMove] = useState(null)
170
+ const [moveMode, setMoveMode] = useState('existing')
171
+ const [targetWs, setTargetWs] = useState('')
172
+ const [newPath, setNewPath] = useState('')
88
173
  const [error, setError] = useState(null)
89
174
  const [toast, setToast] = useState(null)
175
+ const [picking, setPicking] = useState(false)
90
176
  const timer = useRef(null)
91
177
 
92
178
  const showToast = (msg) => {
@@ -97,8 +183,18 @@ function ArchivedPanel() {
97
183
 
98
184
  const refresh = () => {
99
185
  setError(null)
100
- postJSON('/archived-sessions/list', {})
101
- .then((r) => setItems(r.items || []))
186
+ Promise.all([
187
+ postJSON('/archived-sessions/sessions', {}),
188
+ postJSON('/archived-sessions/workspaces', {}),
189
+ ])
190
+ .then(([s, works]) => {
191
+ setSessions(s.items || [])
192
+ setWorkspaces(works.items || [])
193
+ setSelected({})
194
+ setConfirmDel(null)
195
+ setConfirmBatch(false)
196
+ if (!targetWs && works.items && works.items.length) setTargetWs(works.items[0].workspaceId)
197
+ })
102
198
  .catch((e) => setError(String((e && e.message) || e)))
103
199
  }
104
200
 
@@ -108,44 +204,132 @@ function ArchivedPanel() {
108
204
  // eslint-disable-next-line react-hooks/exhaustive-deps
109
205
  }, [])
110
206
 
207
+ const wsPath = (id) => {
208
+ const w = workspaces.find((x) => x.workspaceId === id)
209
+ return w ? w.path : ''
210
+ }
211
+
212
+ const archivedList = sessions ? sessions.filter((x) => x.archived) : []
213
+ const activeList = sessions ? sessions.filter((x) => !x.archived) : []
214
+ const list = filter === 'archived' ? archivedList : sessions || []
215
+ const selIds = Object.keys(selected).filter((k) => selected[k])
216
+
217
+ const toggle = (id) => setSelected((s) => ({ ...s, [id]: !s[id] }))
218
+ const clearSel = () => setSelected({})
219
+ const selectAll = () => {
220
+ const o = {}
221
+ list.forEach((x) => { o[x.sessionId] = true })
222
+ setSelected(o)
223
+ }
224
+
111
225
  const act = (action, it) => {
112
226
  if (busy) return
113
227
  if (action === 'delete') {
114
- if (confirmId !== it.sessionId) { setConfirmId(it.sessionId); return }
115
- setConfirmId(null)
116
- } else { setConfirmId(null); setError(null) }
228
+ if (confirmDel !== it.sessionId) { setConfirmDel(it.sessionId); return }
229
+ setConfirmDel(null)
230
+ }
117
231
  setBusy(it.sessionId)
118
232
  postJSON('/archived-sessions/' + action, { sessionId: it.sessionId })
119
233
  .then(() => {
120
234
  setBusy(null)
121
- showToast(action === 'restore' ? `已恢复「${it.title || it.sessionId}」` : `已删除 ${it.title || it.sessionId}`)
235
+ const n = it.title || it.sessionId
236
+ showToast(action === 'archive' ? `已归档「${n}」` : action === 'restore' ? `已恢复「${n}」` : `已删除 ${n}`)
237
+ refresh()
238
+ })
239
+ .catch((e) => { setBusy(null); setConfirmDel(null); setError(String((e && e.message) || e)) })
240
+ }
241
+
242
+ const doMove = (it) => {
243
+ const targetPath = moveMode === 'new' ? newPath.trim() : wsPath(targetWs)
244
+ if (!targetPath) { setError('请选择已有工作区或输入新的目标目录路径'); return }
245
+ setBusy(it.sessionId)
246
+ setError(null)
247
+ postJSON('/archived-sessions/move', { sessionId: it.sessionId, targetPath })
248
+ .then((r) => {
249
+ setBusy(null)
250
+ setOpenMove(null)
251
+ setMoveMode('existing')
252
+ setNewPath('')
253
+ showToast(`已把「${it.title || it.sessionId}」移到 ${r.workspaceTitle || targetPath}`)
254
+ refresh()
255
+ })
256
+ .catch((e) => { setBusy(null); setError(String((e && e.message) || e)) })
257
+ }
258
+
259
+ const doBatch = (action) => {
260
+ if (!selIds.length || busy) return
261
+ if (action === 'delete-many') {
262
+ if (!confirmBatch) { setConfirmBatch(true); return }
263
+ setConfirmBatch(false)
264
+ }
265
+ setBusy('__batch__')
266
+ postJSON('/archived-sessions/' + action, { sessionIds: selIds })
267
+ .then((r) => {
268
+ setBusy(null)
269
+ const n = (r && (r.archived || r.restored || r.deleted)) || selIds.length
270
+ showToast(`已处理 ${n} 个会话`)
122
271
  refresh()
123
272
  })
124
- .catch((e) => { setBusy(null); setConfirmId(null); setError(String((e && e.message) || e)) })
273
+ .catch((e) => { setBusy(null); setConfirmBatch(false); setError(String((e && e.message) || e)) })
274
+ }
275
+
276
+ const openMoveFor = (it) => {
277
+ if (openMove === it.sessionId) { setOpenMove(null); return }
278
+ setTargetWs(workspaces.length ? (targetWs || workspaces[0].workspaceId) : '')
279
+ setMoveMode('existing')
280
+ setNewPath('')
281
+ setOpenMove(it.sessionId)
282
+ }
283
+
284
+ const pickDirectory = async () => {
285
+ if (!workspacesSvc || picking) return
286
+ setPicking(true)
287
+ try {
288
+ const p = await workspacesSvc.pickDirectory()
289
+ if (p) { setNewPath(p); setMoveMode('new') }
290
+ } catch (e) {
291
+ setError(String((e && e.message) || e))
292
+ } finally {
293
+ setPicking(false)
294
+ }
125
295
  }
126
296
 
127
297
  const workspaceTag = (it) => {
128
298
  if (it.hasWorkspace && it.workspaceGone) {
129
- return (
130
- <span className="archv-wtag archv-wgone" title={(it.workspacePath || '') + '(原工作区已删除)'}>
131
- 工作区已删 · {pathName(it.workspacePath) || '?'}
132
- </span>
133
- )
299
+ return <span className="archv-wtag archv-wgone" title={(it.workspacePath || '') + '(原工作区已删除)'}>工作区已删 · {pathName(it.workspacePath) || '?'}</span>
134
300
  }
135
301
  const wName = it.workspaceTitle || pathName(it.workspacePath)
136
302
  if (wName) return <span className="archv-wtag" title={it.workspacePath || ''}>{wName}</span>
137
- return null
303
+ return <span className="archv-wtag">未分组</span>
304
+ }
305
+
306
+ const rowActions = (it) => {
307
+ if (it.archived) {
308
+ return (
309
+ <div className="archv-actions">
310
+ <button type="button" className="archv-btn archv-go" disabled={busy !== null} onClick={() => act('restore', it)}>恢复</button>
311
+ <button type="button" className="archv-btn archv-go" disabled={busy !== null} onClick={() => openMoveFor(it)}>{openMove === it.sessionId ? '收起' : '移动…'}</button>
312
+ <button type="button" className="archv-btn archv-del" disabled={busy !== null} onClick={() => act('delete', it)}>{confirmDel === it.sessionId ? '确认删除?' : '删除'}</button>
313
+ </div>
314
+ )
315
+ }
316
+ return (
317
+ <div className="archv-actions">
318
+ <button type="button" className="archv-btn" disabled={busy !== null} onClick={() => act('archive', it)}>归档</button>
319
+ <button type="button" className="archv-btn archv-go" disabled={busy !== null} onClick={() => openMoveFor(it)}>{openMove === it.sessionId ? '收起' : '移动…'}</button>
320
+ </div>
321
+ )
138
322
  }
139
323
 
140
324
  return (
141
- <div className="archv" role="region" aria-label="归档会话">
325
+ <div className="archv" role="region" aria-label="会话管理">
142
326
  <style>{CSS}</style>
143
327
  <div className="archv-head">
144
- <h2 className="archv-title">归档会话</h2>
145
- {items !== null && <span className="archv-count" aria-label={`${items.length} 个归档会话`}>{items.length}</span>}
328
+ <h2 className="archv-title">会话管理</h2>
329
+ {sessions !== null && <span className="archv-count" aria-label={`${sessions.length} 个会话`}>{sessions.length}</span>}
146
330
  </div>
147
331
  <p className="archv-sub">
148
- 被归档(从侧栏隐藏)的会话都在这里。恢复会把会话放回原分组;删除会彻底移除日志文件,不可恢复。
332
+ 统一管理全部会话:归档 / 恢复 / 彻底删除 / 移动到其他工作区,支持批量。归档会从侧栏隐藏;删除会彻底移除日志,不可恢复。
149
333
  </p>
150
334
  {error && (
151
335
  <div className="archv-err" role="alert">
@@ -153,41 +337,125 @@ function ArchivedPanel() {
153
337
  <button type="button" className="archv-errretry" onClick={refresh}>重试</button>
154
338
  </div>
155
339
  )}
156
- {items === null ? (
340
+ {sessions === null ? (
157
341
  <div className="archv-skel" aria-label="加载中">
158
342
  {[0, 1, 2].map((i) => <div key={i} className="archv-skel-card" />)}
159
343
  </div>
160
- ) : items.length === 0 ? (
161
- <div className="archv-empty">目前没有归档会话。在侧栏任一会话的菜单里选择「归档」即可收纳进来。</div>
162
344
  ) : (
163
- <div className="archv-list" role="list">
164
- {items.map((it) => {
165
- const isConfirm = confirmId === it.sessionId
166
- const date = fmtDate(it.createdAt)
167
- const wtag = workspaceTag(it)
168
- return (
169
- <div key={it.sessionId} className="archv-card" role="listitem">
170
- <div className="archv-main">
171
- <div className="archv-name" title={it.title || ''}>{it.title || '(无标题)'}</div>
172
- <div className="archv-meta">
173
- {date && <span className="archv-date">{date}</span>}
174
- {date && wtag && <span className="archv-dot">·</span>}
175
- {wtag}
176
- <span className="archv-id">{it.sessionId}</span>
345
+ <>
346
+ <div className="sess-filter" role="tablist" aria-label="会话筛选">
347
+ <button type="button" className={'sess-fbtn' + (filter === 'all' ? ' sess-fbtn-on' : '')} onClick={() => { setFilter('all'); clearSel(); setConfirmBatch(false) }}>全部 ({sessions.length})</button>
348
+ <button type="button" className={'sess-fbtn' + (filter === 'archived' ? ' sess-fbtn-on' : '')} onClick={() => { setFilter('archived'); clearSel(); setConfirmBatch(false) }}>已归档 ({archivedList.length})</button>
349
+ </div>
350
+
351
+ {list.length > 0 && (
352
+ <div className="sess-batch">
353
+ <span className="sess-btntext">{selIds.length ? `已选 ${selIds.length} 项` : (filter === 'archived' ? `共 ${archivedList.length} 个归档会话` : `共 ${sessions.length} 个会话(活动 ${activeList.length} / 已归档 ${archivedList.length})`)}</span>
354
+ <button type="button" className="archv-btn" disabled={list.length === 0} onClick={selectAll}>全选</button>
355
+ {selIds.length > 0 && (
356
+ <>
357
+ {filter === 'archived' && (
358
+ <>
359
+ <button type="button" className="archv-btn" disabled={busy !== null} onClick={() => doBatch('restore-many')}>恢复所选</button>
360
+ <button type="button" className="archv-btn archv-del" disabled={busy !== null} onClick={() => doBatch('delete-many')}>{confirmBatch ? '确认删除所选?' : '删除所选'}</button>
361
+ </>
362
+ )}
363
+ {filter === 'all' && (
364
+ <button type="button" className="archv-btn" disabled={busy !== null} onClick={() => doBatch('archive-many')}>归档所选</button>
365
+ )}
366
+ <button type="button" className="archv-btn" onClick={clearSel}>取消选择</button>
367
+ </>
368
+ )}
369
+ </div>
370
+ )}
371
+
372
+ {list.length === 0 ? (
373
+ <div className="archv-empty">{filter === 'archived' ? '目前没有归档会话。在“全部”里选中会话点“归档”即可收纳进来。' : '暂无可管理的会话。'}</div>
374
+ ) : (
375
+ <div className="archv-list" role="list">
376
+ {list.map((it) => {
377
+ const date = fmtDate(it.createdAt)
378
+ const expanded = openMove === it.sessionId
379
+ return (
380
+ <div key={it.sessionId} className={'archv-card' + (expanded ? ' archv-card-exp' : '')} role="listitem">
381
+ <div className="archv-row">
382
+ <input
383
+ type="checkbox"
384
+ className="archv-check"
385
+ checked={!!selected[it.sessionId]}
386
+ onChange={() => toggle(it.sessionId)}
387
+ aria-label={'选择 ' + (it.title || it.sessionId)}
388
+ />
389
+ <div className="archv-body">
390
+ <div className="archv-main">
391
+ <div className="archv-name" title={it.title || ''}>{it.title || '(无标题)'}</div>
392
+ <div className="archv-meta">
393
+ {it.archived ? <span className="archv-wtag archv-wgone">已归档</span> : <span className="archv-wtag archv-active">活动</span>}
394
+ {workspaceTag(it)}
395
+ {date && <><span className="archv-dot">·</span><span className="archv-date">{date}</span></>}
396
+ <span className="archv-id">{it.sessionId}</span>
397
+ </div>
398
+ </div>
399
+ {rowActions(it)}
400
+ </div>
401
+ </div>
402
+ {expanded && (
403
+ <div className="mv-sheet" role="region" aria-label="移动到工作区">
404
+ <div className="mv-sheet-head">
405
+ <h3 className="mv-sheet-title">移动到工作区</h3>
406
+ <button type="button" className="mv-sheet-close" aria-label="关闭" onClick={() => setOpenMove(null)}>×</button>
407
+ </div>
408
+ <div className="mv-seg" role="tablist">
409
+ <button type="button" role="tab" aria-selected={moveMode === 'existing'} className={'mv-segbtn' + (moveMode === 'existing' ? ' mv-segbtn-on' : '')} onClick={() => setMoveMode('existing')}>已有工作区</button>
410
+ <button type="button" role="tab" aria-selected={moveMode === 'new'} className={'mv-segbtn' + (moveMode === 'new' ? ' mv-segbtn-on' : '')} onClick={() => setMoveMode('new')}>新建目录</button>
411
+ </div>
412
+ {moveMode === 'existing' ? (
413
+ <div className="mv-field">
414
+ <label className="mv-field-label" htmlFor="mv-target-ws">目标工作区</label>
415
+ <select id="mv-target-ws" value={targetWs} onChange={(e) => setTargetWs(e.target.value)}>
416
+ {workspaces.length === 0 && <option value="">(暂无工作区)</option>}
417
+ {workspaces.map((w) => (
418
+ <option key={w.workspaceId} value={w.workspaceId}>{w.title} · {w.path}</option>
419
+ ))}
420
+ </select>
421
+ </div>
422
+ ) : (
423
+ <div className="mv-field">
424
+ <label className="mv-field-label" htmlFor="mv-new-path">新工作区目录路径</label>
425
+ <div className="mv-browse-row">
426
+ <input
427
+ id="mv-new-path"
428
+ type="text"
429
+ value={newPath}
430
+ onChange={(e) => setNewPath(e.target.value)}
431
+ placeholder="例如 /Users/you/Projects/demo 或 ~/demo"
432
+ />
433
+ <button
434
+ type="button"
435
+ className="archv-btn"
436
+ onClick={pickDirectory}
437
+ disabled={busy !== null || picking || !workspacesSvc}
438
+ title={!workspacesSvc ? '当前运行环境不支持系统目录选择' : '打开系统目录选择窗口'}
439
+ >
440
+ {picking ? '选择中…' : '浏览…'}
441
+ </button>
442
+ </div>
443
+ </div>
444
+ )}
445
+ <div className="mv-foot">
446
+ <button type="button" className="archv-btn" onClick={() => setOpenMove(null)}>取消</button>
447
+ <button type="button" className="archv-btn archv-go" disabled={busy !== null} onClick={() => doMove(it)}>
448
+ {busy === it.sessionId && <span className="archv-spin" aria-hidden="true" />}确认移动
449
+ </button>
450
+ </div>
451
+ </div>
452
+ )}
177
453
  </div>
178
- </div>
179
- <div className="archv-actions">
180
- <button type="button" className="archv-btn archv-restore" disabled={busy !== null} aria-label={'恢复 ' + (it.title || it.sessionId)} onClick={() => act('restore', it)}>
181
- {busy === it.sessionId && <span className="archv-spin" aria-hidden="true" />}恢复
182
- </button>
183
- <button type="button" className="archv-btn archv-del" disabled={busy !== null} aria-label={(isConfirm ? '确认删除 ' : '删除 ') + (it.title || it.sessionId)} onClick={() => act('delete', it)}>
184
- {isConfirm ? '确认删除?' : '删除'}
185
- </button>
186
- </div>
187
- </div>
188
- )
189
- })}
190
- </div>
454
+ )
455
+ })}
456
+ </div>
457
+ )}
458
+ </>
191
459
  )}
192
460
  {toast && <div className="archv-status" role="status">{toast}</div>}
193
461
  </div>
@@ -195,10 +463,12 @@ function ArchivedPanel() {
195
463
  }
196
464
 
197
465
  export function apply(ctx) {
466
+ installSettingsNavIcons(ctx)
467
+ const workspacesSvc = ctx.get('workspaces')
198
468
  ctx.slots.inject('settings.section', () =>
199
469
  ctx.slots.register(
200
- { name: 'settings.section', id: 'archived-sessions', order: 90, label: '归档会话' },
201
- (props) => <ArchivedPanel {...props} />,
470
+ { name: 'settings.section', id: 'session-manager', order: 90, label: '会话管理' },
471
+ (props) => <SessionPanel {...props} workspacesSvc={workspacesSvc} />,
202
472
  ),
203
473
  )
204
474
  }