@tobycaimf/dsh-archived-sessions 1.0.1 → 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,172 +110,69 @@ async function postJSON(path, body) {
81
110
  return data
82
111
  }
83
112
 
84
- function ArchivedPanel() {
85
- const [items, setItems] = useState(null)
86
- const [busy, setBusy] = useState(null)
87
- const [confirmId, setConfirmId] = useState(null)
88
- const [error, setError] = useState(null)
89
- const [toast, setToast] = useState(null)
90
- const timer = useRef(null)
91
-
92
- const showToast = (msg) => {
93
- if (timer.current) clearTimeout(timer.current)
94
- setToast(msg)
95
- timer.current = setTimeout(() => setToast(null), 2400)
96
- }
97
-
98
- const refresh = () => {
99
- setError(null)
100
- postJSON('/archived-sessions/list', {})
101
- .then((r) => setItems(r.items || []))
102
- .catch((e) => setError(String((e && e.message) || e)))
103
- }
104
-
105
- useEffect(() => {
106
- refresh()
107
- return () => { if (timer.current) clearTimeout(timer.current) }
108
- // eslint-disable-next-line react-hooks/exhaustive-deps
109
- }, [])
110
-
111
- const act = (action, it) => {
112
- if (busy) return
113
- if (action === 'delete') {
114
- if (confirmId !== it.sessionId) { setConfirmId(it.sessionId); return }
115
- setConfirmId(null)
116
- } else { setConfirmId(null); setError(null) }
117
- setBusy(it.sessionId)
118
- postJSON('/archived-sessions/' + action, { sessionId: it.sessionId })
119
- .then(() => {
120
- setBusy(null)
121
- showToast(action === 'restore' ? `已恢复「${it.title || it.sessionId}」` : `已删除 ${it.title || it.sessionId}`)
122
- refresh()
123
- })
124
- .catch((e) => { setBusy(null); setConfirmId(null); setError(String((e && e.message) || e)) })
125
- }
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
+ `
126
127
 
127
- const workspaceTag = (it) => {
128
- if (it.hasWorkspace && it.workspaceGone) {
129
- return (
130
- <span className="archv-wtag archv-wgone" title={(it.workspacePath || '') + '(原工作区已删除)'}>
131
- 工作区已删 · {pathName(it.workspacePath) || '?'}
132
- </span>
133
- )
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')
134
138
  }
135
- const wName = it.workspaceTitle || pathName(it.workspacePath)
136
- if (wName) return <span className="archv-wtag" title={it.workspacePath || ''}>{wName}</span>
137
- return null
138
139
  }
139
-
140
- return (
141
- <div className="archv" role="region" aria-label="归档会话">
142
- <style>{CSS}</style>
143
- <div className="archv-head">
144
- <h2 className="archv-title">归档会话</h2>
145
- {items !== null && <span className="archv-count" aria-label={`${items.length} 个归档会话`}>{items.length}</span>}
146
- </div>
147
- <p className="archv-sub">
148
- 被归档(从侧栏隐藏)的会话都在这里。恢复会把会话放回原分组;删除会彻底移除日志文件,不可恢复。
149
- </p>
150
- {error && (
151
- <div className="archv-err" role="alert">
152
- <span>{error}</span>
153
- <button type="button" className="archv-errretry" onClick={refresh}>重试</button>
154
- </div>
155
- )}
156
- {items === null ? (
157
- <div className="archv-skel" aria-label="加载中">
158
- {[0, 1, 2].map((i) => <div key={i} className="archv-skel-card" />)}
159
- </div>
160
- ) : items.length === 0 ? (
161
- <div className="archv-empty">目前没有归档会话。在侧栏任一会话的菜单里选择「归档」即可收纳进来。</div>
162
- ) : (
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>
177
- </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>
191
- )}
192
- {toast && <div className="archv-status" role="status">{toast}</div>}
193
- </div>
194
- )
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
+ }
195
148
  }
196
149
 
197
- const MOVE_CSS = `
198
- .mv{display:flex;flex-direction:column;gap:4px;max-width:800px;padding:8px 2px 28px}
199
- .mv-head{display:flex;align-items:center;gap:10px;margin:0 0 2px}
200
- .mv-title{font-size:16px;font-weight:650;color:var(--dsw-alias-label-primary);letter-spacing:-0.01em;margin:0}
201
- .mv-count{font-size:11px;font-weight:600;color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-fill-subtle);border:1px solid var(--dsw-alias-border-l2);border-radius:999px;padding:1px 8px;flex:none}
202
- .mv-sub{font-size:12px;line-height:1.55;color:var(--dsw-alias-label-tertiary);margin:0 0 12px;max-width:64ch}
203
- .mv-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}
204
- .mv-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}
205
- .mv-list{display:flex;flex-direction:column;gap:8px}
206
- .mv-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}
207
- .mv-card:hover{border-color:var(--dsw-alias-border-l4)}
208
- .mv-main{min-width:0;display:flex;flex-direction:column;gap:4px}
209
- .mv-name{font-size:13px;font-weight:550;color:var(--dsw-alias-label-primary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
210
- .mv-meta{display:flex;align-items:center;gap:7px;flex-wrap:wrap;min-width:0}
211
- .mv-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}
212
- .mv-archived{color:var(--dsw-alias-state-warning-primary);border-color:color-mix(in srgb,var(--dsw-alias-state-warning-primary) 50%,transparent);background:color-mix(in srgb,var(--dsw-alias-state-warning-primary) 8%,transparent)}
213
- .mv-date{font-size:11px;color:var(--dsw-alias-label-tertiary);white-space:nowrap;flex:none}
214
- .mv-id{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:10.5px;color:var(--dsw-alias-label-tertiary);flex:none}
215
- .mv-dot{color:var(--dsw-alias-border-l3);flex:none}
216
- .mv-actions{display:flex;gap:8px;flex:none}
217
- .mv-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}
218
- .mv-btn:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
219
- .mv-btn:disabled{opacity:.5;cursor:default}
220
- .mv-go{color:var(--dsw-alias-state-business-primary);border-color:color-mix(in srgb,var(--dsw-alias-state-business-primary) 45%,transparent)}
221
- .mv-go:hover:not(:disabled){background:color-mix(in srgb,var(--dsw-alias-state-business-primary) 10%,transparent);color:var(--dsw-alias-state-business-primary)}
222
- .mv-sheet{display:flex;flex-direction:column;gap:10px;margin-top:10px;padding:12px 14px;border:1px solid var(--dsw-alias-border-l2);border-radius:12px;background:var(--dsw-alias-fill-subtle)}
223
- .mv-sheet-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;font-size:12px;color:var(--dsw-alias-label-secondary)}
224
- .mv-sheet-row input[type=radio]{accent-color:var(--dsw-alias-state-business-primary)}
225
- .mv-sheet select,.mv-sheet input[type=text]{appearance:none;min-height:32px;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}
226
- .mv-sheet input[type=text]{flex:1;min-width:220px}
227
- .mv-sheet select:focus-visible,.mv-sheet input[type=text]:focus-visible,.mv-go:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary);outline-offset:2px}
228
- .mv-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}
229
- .mv-skel{display:flex;flex-direction:column;gap:8px}
230
- .mv-skel-card{height:58px;border-radius:12px;background:var(--dsw-alias-fill-subtle);position:relative;overflow:hidden}
231
- .mv-skel-card::after{content:'';position:absolute;inset:0;transform:translateX(-100%);background:linear-gradient(90deg,transparent,color-mix(in srgb,var(--dsw-alias-fill-elevated) 75%,transparent),transparent);animation:mv-shimmer 1.4s infinite}
232
- @keyframes mv-shimmer{100%{transform:translateX(100%)}}
233
- @keyframes mv-pop{from{opacity:0;transform:translateX(-50%) translateY(10px)}}
234
- .mv-status{position:fixed;bottom:20px;left:50%;transform:translateX(-50%);z-index:60;background:var(--dsw-alias-fill-elevated);border:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-primary);padding:9px 16px;border-radius:999px;font-size:12px;box-shadow:0 8px 24px rgb(0 0 0/.18);display:flex;align-items:center;gap:8px;animation:mv-pop .18s ease-out;max-width:min(90vw,420px)}
235
- .mv-spin{width:12px;height:12px;border:2px solid color-mix(in srgb,var(--dsw-alias-label-secondary) 35%,transparent);border-top-color:var(--dsw-alias-label-secondary);border-radius:50%;animation:archv-rot .8s linear infinite;flex:none}
236
- @media (prefers-reduced-motion:reduce){.mv-skel-card::after{animation:none}.mv-card,.mv-btn{transition:none}.mv-status,.mv-spin{animation:none}}
237
- @media (max-width:560px){.mv-card{flex-direction:column;align-items:stretch;gap:10px}.mv-actions{justify-content:flex-end}.mv-sheet input[type=text]{min-width:0}}
238
- `
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
+ }
239
160
 
240
- function MovePanel() {
161
+ function SessionPanel({ workspacesSvc }) {
241
162
  const [sessions, setSessions] = useState(null)
242
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)
243
168
  const [busy, setBusy] = useState(null)
244
- const [open, setOpen] = useState(null)
245
- const [mode, setMode] = useState('existing')
169
+ const [openMove, setOpenMove] = useState(null)
170
+ const [moveMode, setMoveMode] = useState('existing')
246
171
  const [targetWs, setTargetWs] = useState('')
247
172
  const [newPath, setNewPath] = useState('')
248
173
  const [error, setError] = useState(null)
249
174
  const [toast, setToast] = useState(null)
175
+ const [picking, setPicking] = useState(false)
250
176
  const timer = useRef(null)
251
177
 
252
178
  const showToast = (msg) => {
@@ -264,6 +190,9 @@ function MovePanel() {
264
190
  .then(([s, works]) => {
265
191
  setSessions(s.items || [])
266
192
  setWorkspaces(works.items || [])
193
+ setSelected({})
194
+ setConfirmDel(null)
195
+ setConfirmBatch(false)
267
196
  if (!targetWs && works.items && works.items.length) setTargetWs(works.items[0].workspaceId)
268
197
  })
269
198
  .catch((e) => setError(String((e && e.message) || e)))
@@ -280,16 +209,46 @@ function MovePanel() {
280
209
  return w ? w.path : ''
281
210
  }
282
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
+
225
+ const act = (action, it) => {
226
+ if (busy) return
227
+ if (action === 'delete') {
228
+ if (confirmDel !== it.sessionId) { setConfirmDel(it.sessionId); return }
229
+ setConfirmDel(null)
230
+ }
231
+ setBusy(it.sessionId)
232
+ postJSON('/archived-sessions/' + action, { sessionId: it.sessionId })
233
+ .then(() => {
234
+ setBusy(null)
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
+
283
242
  const doMove = (it) => {
284
- const targetPath = mode === 'new' ? newPath.trim() : wsPath(targetWs)
243
+ const targetPath = moveMode === 'new' ? newPath.trim() : wsPath(targetWs)
285
244
  if (!targetPath) { setError('请选择已有工作区或输入新的目标目录路径'); return }
286
245
  setBusy(it.sessionId)
287
246
  setError(null)
288
247
  postJSON('/archived-sessions/move', { sessionId: it.sessionId, targetPath })
289
248
  .then((r) => {
290
249
  setBusy(null)
291
- setOpen(null)
292
- setMode('existing')
250
+ setOpenMove(null)
251
+ setMoveMode('existing')
293
252
  setNewPath('')
294
253
  showToast(`已把「${it.title || it.sessionId}」移到 ${r.workspaceTitle || targetPath}`)
295
254
  refresh()
@@ -297,188 +256,219 @@ function MovePanel() {
297
256
  .catch((e) => { setBusy(null); setError(String((e && e.message) || e)) })
298
257
  }
299
258
 
300
- const wtag = (it) => {
301
- if (it.workspacePath) {
302
- return <span className="mv-wtag" title={it.workspacePath}>{it.workspaceTitle || pathName(it.workspacePath)}</span>
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} 个会话`)
271
+ refresh()
272
+ })
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)
303
294
  }
304
- return <span className="mv-wtag">未分组</span>
295
+ }
296
+
297
+ const workspaceTag = (it) => {
298
+ if (it.hasWorkspace && it.workspaceGone) {
299
+ return <span className="archv-wtag archv-wgone" title={(it.workspacePath || '') + '(原工作区已删除)'}>工作区已删 · {pathName(it.workspacePath) || '?'}</span>
300
+ }
301
+ const wName = it.workspaceTitle || pathName(it.workspacePath)
302
+ if (wName) return <span className="archv-wtag" title={it.workspacePath || ''}>{wName}</span>
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
+ )
305
322
  }
306
323
 
307
324
  return (
308
- <div className="mv" role="region" aria-label="移动会话">
309
- <style>{MOVE_CSS}</style>
310
- <div className="mv-head">
311
- <h2 className="mv-title">移动会话到工作区</h2>
312
- {sessions !== null && <span className="mv-count" aria-label={`${sessions.length} 个会话`}>{sessions.length}</span>}
325
+ <div className="archv" role="region" aria-label="会话管理">
326
+ <style>{CSS}</style>
327
+ <div className="archv-head">
328
+ <h2 className="archv-title">会话管理</h2>
329
+ {sessions !== null && <span className="archv-count" aria-label={`${sessions.length} 个会话`}>{sessions.length}</span>}
313
330
  </div>
314
- <p className="mv-sub">
315
- 把任意会话移动到某个工作区目录(可选用现有工作区或输入新目录,自动创建)。移动会改写会话的工作目录并把日志迁移过去;
316
- 会话当前处于打开状态时需先在侧栏切走才能移动。
331
+ <p className="archv-sub">
332
+ 统一管理全部会话:归档 / 恢复 / 彻底删除 / 移动到其他工作区,支持批量。归档会从侧栏隐藏;删除会彻底移除日志,不可恢复。
317
333
  </p>
318
334
  {error && (
319
- <div className="mv-err" role="alert">
335
+ <div className="archv-err" role="alert">
320
336
  <span>{error}</span>
321
- <button type="button" className="mv-errretry" onClick={refresh}>重试</button>
337
+ <button type="button" className="archv-errretry" onClick={refresh}>重试</button>
322
338
  </div>
323
339
  )}
324
340
  {sessions === null ? (
325
- <div className="mv-skel" aria-label="加载中">
326
- {[0, 1, 2].map((i) => <div key={i} className="mv-skel-card" />)}
341
+ <div className="archv-skel" aria-label="加载中">
342
+ {[0, 1, 2].map((i) => <div key={i} className="archv-skel-card" />)}
327
343
  </div>
328
- ) : sessions.length === 0 ? (
329
- <div className="mv-empty">暂无可移动的会话。</div>
330
344
  ) : (
331
- <div className="mv-list" role="list">
332
- {sessions.map((it) => {
333
- const date = fmtDate(it.createdAt)
334
- const expanded = open === it.sessionId
335
- return (
336
- <div key={it.sessionId} className="mv-card" role="listitem">
337
- <div className="mv-main">
338
- <div className="mv-name" title={it.title || ''}>{it.title || '(无标题)'}</div>
339
- <div className="mv-meta">
340
- {it.archived && <span className="mv-wtag mv-archived">已归档</span>}
341
- {wtag(it)}
342
- {date && <><span className="mv-dot">·</span><span className="mv-date">{date}</span></>}
343
- <span className="mv-id">{it.sessionId}</span>
344
- </div>
345
- {expanded && (
346
- <div className="mv-sheet">
347
- <label className="mv-sheet-row">
348
- <input type="radio" name="mv-target" checked={mode === 'existing'} onChange={() => setMode('existing')} />
349
- 已有工作区
350
- </label>
351
- {mode === 'existing' && (
352
- <div className="mv-sheet-row">
353
- <select value={targetWs} onChange={(e) => setTargetWs(e.target.value)} aria-label="目标工作区">
354
- {workspaces.length === 0 && <option value="">(暂无工作区)</option>}
355
- {workspaces.map((w) => (
356
- <option key={w.workspaceId} value={w.workspaceId}>{w.title} · {w.path}</option>
357
- ))}
358
- </select>
359
- </div>
360
- )}
361
- <label className="mv-sheet-row">
362
- <input type="radio" name="mv-target" checked={mode === 'new'} onChange={() => setMode('new')} />
363
- 新建工作区目录
364
- </label>
365
- {mode === 'new' && (
366
- <div className="mv-sheet-row">
367
- <input
368
- type="text"
369
- value={newPath}
370
- onChange={(e) => setNewPath(e.target.value)}
371
- placeholder="例如 /Users/you/Projects/demo 或 ~/demo"
372
- aria-label="新工作区目录路径"
373
- />
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>
374
398
  </div>
375
- )}
376
- <div className="mv-sheet-row">
377
- <button type="button" className="mv-btn mv-go" disabled={busy !== null} onClick={() => doMove(it)}>
378
- {busy === it.sessionId && <span className="mv-spin" aria-hidden="true" />}确认移动
379
- </button>
380
- <button type="button" className="mv-btn" disabled={busy !== null} onClick={() => { setOpen(null); setMode('existing'); setNewPath(''); }}>取消</button>
399
+ {rowActions(it)}
381
400
  </div>
382
401
  </div>
383
- )}
384
- </div>
385
- <div className="mv-actions">
386
- <button
387
- type="button"
388
- className="mv-btn mv-go"
389
- disabled={busy !== null}
390
- aria-label={'移动 ' + (it.title || it.sessionId)}
391
- onClick={() => { if (expanded) { setOpen(null) } else { setTargetWs(workspaces.length ? (targetWs || workspaces[0].workspaceId) : ''); setMode('existing'); setNewPath(''); setOpen(it.sessionId) } }}
392
- >
393
- {expanded ? '收起' : '移动到…'}
394
- </button>
395
- </div>
396
- </div>
397
- )
398
- })}
399
- </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
+ )}
453
+ </div>
454
+ )
455
+ })}
456
+ </div>
457
+ )}
458
+ </>
400
459
  )}
401
- {toast && <div className="mv-status" role="status">{toast}</div>}
460
+ {toast && <div className="archv-status" role="status">{toast}</div>}
402
461
  </div>
403
462
  )
404
463
  }
405
464
 
406
- // Left-nav icon swap for the two plugin settings sections.
407
- //
408
- // DSH's settings shell renders a *shared* fallback gear for every custom
409
- // section (its internal navIcon() only differentiates 模型/插件/Agent预设), and
410
- // settings.section has no per-section icon field. So — exactly like
411
- // dsh-better-sidebar — we mark our own rows by matching their visible label
412
- // text against the shell DOM, then let CSS swap the gear for a distinct glyph.
413
- // The marker owns no shell structure and is removed on fiber disposal, so the
414
- // adaptation stays HMR-safe and ships with the plugin.
415
- const NAV_CSS = `
416
- [data-dsh-nav-archived] > svg:first-child,
417
- [data-dsh-nav-move] > svg:first-child { display: none; }
418
- [data-dsh-nav-archived]::before,
419
- [data-dsh-nav-move]::before {
420
- content: ''; flex: none; width: 16px; height: 16px; background: currentColor;
421
- -webkit-mask: center / contain no-repeat; mask: center / contain no-repeat;
422
- }
423
- [data-dsh-nav-archived]::before {
424
- -webkit-mask-image: 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");
425
- mask-image: 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");
426
- }
427
- [data-dsh-nav-move]::before {
428
- -webkit-mask-image: 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'M22%2019a2%202%200%200%201-2%202H4a2%202%200%200%201-2-2V5a2%202%200%200%201%202-2h5l2%203h9a2%202%200%200%201%202%202z'/%3E%3Cpath%20d%3D'M12%2010v6'/%3E%3Cpath%20d%3D'm15%2013-3%203-3-3'/%3E%3C/svg%3E");
429
- mask-image: 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'M22%2019a2%202%200%200%201-2%202H4a2%202%200%200%201-2-2V5a2%202%200%200%201%202-2h5l2%203h9a2%202%200%200%201%202%202z'/%3E%3Cpath%20d%3D'M12%2010v6'/%3E%3Cpath%20d%3D'm15%2013-3%203-3-3'/%3E%3C/svg%3E");
430
- }
431
- `
432
-
433
- function markSettingsNav() {
434
- const ARCHIVED_LABEL = '归档会话'
435
- const MOVE_LABEL = '移动会话'
436
- let disposed = false
437
- const sync = () => {
438
- if (disposed) return
439
- const buttons = document.querySelectorAll('[role="dialog"] nav button')
440
- for (const b of buttons) {
441
- const t = (b.textContent || '').trim()
442
- if (t === ARCHIVED_LABEL) b.setAttribute('data-dsh-nav-archived', '')
443
- else b.removeAttribute('data-dsh-nav-archived')
444
- if (t === MOVE_LABEL) b.setAttribute('data-dsh-nav-move', '')
445
- else b.removeAttribute('data-dsh-nav-move')
446
- }
447
- }
448
- sync()
449
- const obs = new MutationObserver(sync)
450
- obs.observe(document.body, { childList: true, subtree: true, characterData: true })
451
- return () => {
452
- disposed = true
453
- obs.disconnect()
454
- document.querySelectorAll('[data-dsh-nav-archived]').forEach((e) => e.removeAttribute('data-dsh-nav-archived'))
455
- document.querySelectorAll('[data-dsh-nav-move]').forEach((e) => e.removeAttribute('data-dsh-nav-move'))
456
- }
457
- }
458
-
459
- function installSettingsNavIcons(ctx) {
460
- const styleEl = document.createElement('style')
461
- styleEl.textContent = NAV_CSS
462
- document.head.appendChild(styleEl)
463
- const dispose = markSettingsNav()
464
- ctx.effect(() => () => {
465
- dispose()
466
- if (styleEl.parentNode) styleEl.parentNode.removeChild(styleEl)
467
- })
468
- }
469
-
470
465
  export function apply(ctx) {
471
466
  installSettingsNavIcons(ctx)
467
+ const workspacesSvc = ctx.get('workspaces')
472
468
  ctx.slots.inject('settings.section', () =>
473
469
  ctx.slots.register(
474
- { name: 'settings.section', id: 'archived-sessions', order: 90, label: '归档会话' },
475
- (props) => <ArchivedPanel {...props} />,
476
- ),
477
- )
478
- ctx.slots.inject('settings.section', () =>
479
- ctx.slots.register(
480
- { name: 'settings.section', id: 'move-sessions', order: 91, label: '移动会话' },
481
- (props) => <MovePanel {...props} />,
470
+ { name: 'settings.section', id: 'session-manager', order: 90, label: '会话管理' },
471
+ (props) => <SessionPanel {...props} workspacesSvc={workspacesSvc} />,
482
472
  ),
483
473
  )
484
474
  }