@mzzsfy/dsh-session-manager 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +82 -0
- package/cordis.patch.yml +12 -0
- package/package.json +51 -0
- package/src/client.js +835 -0
- package/src/core.mjs +190 -0
- package/src/history-cache.mjs +94 -0
- package/src/index.js +1069 -0
- package/src/trash.mjs +49 -0
- package/test/client-id.test.mjs +16 -0
- package/test/client-toast.test.mjs +89 -0
- package/test/core.test.mjs +307 -0
- package/test/index.test.mjs +1851 -0
- package/test/parity.test.mjs +160 -0
- package/test/trash.test.mjs +73 -0
package/src/client.js
ADDED
|
@@ -0,0 +1,835 @@
|
|
|
1
|
+
// dsh-session-manager Client 半区:settings.section 归档面板 + 归档/操作反馈通知
|
|
2
|
+
// + 历史输入浮层(Alt+↑ 唤起,列表浏览与点选回填)。
|
|
3
|
+
// 归档快照来自官方 workspace.follow 客户端模型(ctx.get('workspaces')),会话行来自
|
|
4
|
+
// ctx.get('sessions');面板数据 = 会话行 ∩ 归档集合(纯投影),通知由 archived
|
|
5
|
+
// 增量帧的集合差分驱动,经公共依赖 @mzzsfy/dsh-toast 展示。历史输入挂官方
|
|
6
|
+
// conversation.input.dock 插槽(行数据由 host /api/session-manager/inputs 聚合),
|
|
7
|
+
// 回填走宿主公共契约 inputActions.setDraft。浏览器半区经 webServer
|
|
8
|
+
// 路由('/api/session-manager/*')访问 Host。打包为单文件自包含格式,无法跨文件
|
|
9
|
+
// require;与 src/core.mjs 镜像的纯函数(projectArchiveRows / archiveToastStep /
|
|
10
|
+
// projectDeletedRows)修改需两处同步。
|
|
11
|
+
|
|
12
|
+
window.__ModuleLoader__.load({
|
|
13
|
+
id: '@mzzsfy/dsh-session-manager',
|
|
14
|
+
factory(require) {
|
|
15
|
+
const React = require('react')
|
|
16
|
+
const { useState, useEffect, useSyncExternalStore } = React
|
|
17
|
+
|
|
18
|
+
// 导航图标声明:交给 dsh-settings-nav-icons 统一渲染(本插件分区 → archive);
|
|
19
|
+
// 该插件未就绪时入队,由其启动时排空
|
|
20
|
+
const NAV_ICON = { '会话归档': 'archive' }
|
|
21
|
+
if (window.__navicIcons !== undefined) window.__navicIcons.register(NAV_ICON)
|
|
22
|
+
else if (Array.isArray(window.__navicIconQueue)) window.__navicIconQueue.push(NAV_ICON)
|
|
23
|
+
else window.__navicIconQueue = [NAV_ICON]
|
|
24
|
+
|
|
25
|
+
// 通知出口:公共依赖 @mzzsfy/dsh-toast(external require,宿主占位条目由
|
|
26
|
+
// 本插件 cordis.patch.yml 代挂)
|
|
27
|
+
const { show: toast } = require('@mzzsfy/dsh-toast/client')
|
|
28
|
+
|
|
29
|
+
// 视觉方案:归档台账——全部取宿主 alias 令牌(双主题自适应,不造色),数据列
|
|
30
|
+
// 用宿主代码字体(会话即文件的档案词汇),签名元素为托盘内的归档轨
|
|
31
|
+
const CSS = [
|
|
32
|
+
'.sm-panel { display:flex; flex-direction:column; gap:8px; min-width:0; color:var(--dsw-alias-label-primary); font:var(--dsw-font-s-14); }',
|
|
33
|
+
'.sm-head { display:flex; align-items:baseline; gap:8px; }',
|
|
34
|
+
'.sm-head__title { font:var(--dsw-font-m-18); }',
|
|
35
|
+
'.sm-head__count { font:12px/18px var(--ds-font-family-code, monospace); color:var(--dsw-alias-label-caption); font-variant-numeric:tabular-nums; }',
|
|
36
|
+
'.sm-head__hint { font:var(--dsw-font-xxs-12); color:var(--dsw-alias-label-caption); }',
|
|
37
|
+
'.sm-tray { position:relative; background:var(--dsw-alias-bg-module-platform); border-radius:10px; padding:4px 0; }',
|
|
38
|
+
'.sm-tray::before { content:""; position:absolute; left:15px; top:12px; bottom:12px; width:1px; background:var(--dsw-alias-border-l3); }',
|
|
39
|
+
'.sm-row { position:relative; display:grid; grid-template-columns:88px minmax(0, 1fr) auto; align-items:center; gap:8px;',
|
|
40
|
+
' padding:7px 10px 7px 28px; border-radius:8px; }',
|
|
41
|
+
'.sm-row::before { content:""; position:absolute; left:13px; top:50%; width:5px; height:5px; margin:-2.5px 0 0;',
|
|
42
|
+
' border-radius:50%; background:var(--dsw-alias-border-l4); }',
|
|
43
|
+
'.sm-row:hover, .sm-row:focus-within { background:var(--dsw-alias-interactive-bg-hover); }',
|
|
44
|
+
'.sm-row:hover::before, .sm-row:focus-within::before { background:var(--dsw-alias-state-business-primary); }',
|
|
45
|
+
'.sm-row--armed { background:var(--dsw-alias-interactive-bg-hover-danger); }',
|
|
46
|
+
'.sm-row--armed::before { background:var(--dsw-alias-state-error-primary); }',
|
|
47
|
+
'.sm-row--busy { opacity:.45; pointer-events:none; }',
|
|
48
|
+
'.sm-row__time, .sm-row__size { font:12px/18px var(--ds-font-family-code, monospace); color:var(--dsw-alias-label-tertiary); font-variant-numeric:tabular-nums; }',
|
|
49
|
+
'.sm-row__size { color:var(--dsw-alias-state-error-primary); }',
|
|
50
|
+
'.sm-row__title { font:var(--dsw-font-s-strong-14); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }',
|
|
51
|
+
'.sm-row__error { grid-column:2 / -1; font:var(--dsw-font-xxs-12); color:var(--dsw-alias-state-error-primary); }',
|
|
52
|
+
'.sm-row__path { grid-column:2 / -1; font:12px/16px var(--ds-font-family-code, monospace); color:var(--dsw-alias-label-caption);',
|
|
53
|
+
' white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }',
|
|
54
|
+
'.sm-deleted { display:flex; flex-direction:column; gap:8px; min-width:0; }',
|
|
55
|
+
'.sm-row__actions { display:flex; gap:2px; justify-content:flex-end; opacity:0; transition:opacity 0.12s ease; }',
|
|
56
|
+
'.sm-row:hover .sm-row__actions, .sm-row:focus-within .sm-row__actions, .sm-row--armed .sm-row__actions { opacity:1; }',
|
|
57
|
+
'.sm-btn { border:0; background:transparent; cursor:pointer; padding:2px 8px; border-radius:6px;',
|
|
58
|
+
' font:var(--dsw-font-xxs-strong-12); color:var(--dsw-alias-label-tertiary); }',
|
|
59
|
+
'.sm-btn:hover { background:var(--dsw-alias-interactive-bg-hover); color:var(--dsw-alias-label-primary); }',
|
|
60
|
+
'.sm-btn--restore:hover { color:var(--dsw-alias-state-business-primary); }',
|
|
61
|
+
'.sm-btn--danger:hover { color:var(--dsw-alias-state-error-primary); }',
|
|
62
|
+
'.sm-btn--confirm { color:var(--dsw-alias-state-error-primary); border:1px solid var(--dsw-alias-state-error-primary); }',
|
|
63
|
+
'.sm-btn--confirm:hover { background:var(--dsw-alias-interactive-bg-hover-danger); }',
|
|
64
|
+
'.sm-btn:disabled { opacity:.45; cursor:default; background:transparent; color:var(--dsw-alias-label-tertiary); }',
|
|
65
|
+
'.sm-btn--confirm:disabled { color:var(--dsw-alias-state-error-primary); }',
|
|
66
|
+
'.sm-btn:focus-visible { outline:2px solid var(--dsw-alias-state-business-primary); outline-offset:1px; }',
|
|
67
|
+
'.sm-empty { padding:24px 12px; text-align:center; color:var(--dsw-alias-label-caption); }',
|
|
68
|
+
'.sm-empty__hint { font:var(--dsw-font-xxs-12); margin-top:2px; }',
|
|
69
|
+
'.sm-histsw { display:inline-flex; align-items:center; gap:8px; margin-top:10px; cursor:pointer;',
|
|
70
|
+
' font:var(--dsw-font-xxs-12, 12px/18px sans-serif); color:var(--dsw-alias-label-caption, rgba(127,127,127,.9)); }',
|
|
71
|
+
'.sm-histsw input[type="checkbox"] { position:absolute; width:1px; height:1px; margin:-1px; opacity:0; }',
|
|
72
|
+
'.sm-histsw__track { position:relative; flex:none; width:28px; height:16px; border-radius:999px;',
|
|
73
|
+
' background:light-dark(rgba(15,17,21,.22), rgba(255,255,255,.26)); transition:background .15s; }',
|
|
74
|
+
'.sm-histsw__thumb { position:absolute; left:2px; top:2px; width:12px; height:12px; border-radius:999px;',
|
|
75
|
+
' background:#fff; box-shadow:0 1px 2px rgba(0,0,0,.25); transition:transform .15s; }',
|
|
76
|
+
'.sm-histsw input[type="checkbox"]:checked + .sm-histsw__track { background:#1677ff; }',
|
|
77
|
+
'.sm-histsw input[type="checkbox"]:checked + .sm-histsw__track .sm-histsw__thumb { transform:translateX(12px); }',
|
|
78
|
+
'.sm-histsw input[type="checkbox"]:focus-visible + .sm-histsw__track { outline:2px solid #1677ff; outline-offset:1px; }',
|
|
79
|
+
'.sm-histsw:hover { color:var(--dsw-alias-label-primary, inherit); }',
|
|
80
|
+
'@media (prefers-reduced-motion: reduce) { .sm-histsw__track, .sm-histsw__thumb { transition:none; } }',
|
|
81
|
+
'@media (prefers-reduced-motion: reduce) { .sm-row__actions { transition:none; } }',
|
|
82
|
+
// 历史输入:零高度锚点容器 + 浮层(Alt+↑ 唤起);浮层与输入框同宽对齐,
|
|
83
|
+
// 不透明实底 + 宿主同款卡片投影,与消息流明确区隔。
|
|
84
|
+
// 色值取自宿主实测(白底卡片/墨色文字/蓝色强调):dsw alias 变量在宿主为空,不可依赖
|
|
85
|
+
'.sm-hist { position:relative; height:0; }',
|
|
86
|
+
'.sm-hist__pop { position:absolute; left:0; right:0; bottom:10px; z-index:50;',
|
|
87
|
+
' max-height:min(420px, 46vh); display:flex; flex-direction:column; overflow:hidden;',
|
|
88
|
+
' color-scheme:light dark; color:light-dark(#0f1115, #e8eaed);',
|
|
89
|
+
' background:light-dark(#fff, #1e1f22); border-radius:14px;',
|
|
90
|
+
' box-shadow:0 0 0 0.5px light-dark(rgba(15,17,21,.18), rgba(255,255,255,.14)), 0 4px 16px rgba(0,0,0,.08), 0 16px 48px rgba(0,0,0,.16); }',
|
|
91
|
+
'.sm-hist__hint { display:flex; align-items:center; gap:10px; padding:9px 14px; flex:none;',
|
|
92
|
+
' border-bottom:1px solid light-dark(rgba(15,17,21,.08), rgba(255,255,255,.1));',
|
|
93
|
+
' font:var(--dsw-font-xxs-12, 12px/18px sans-serif); color:light-dark(rgba(15,17,21,.55), rgba(232,234,237,.55)); }',
|
|
94
|
+
'.sm-hist__scope { padding:2px 10px; border-radius:999px; flex:none;',
|
|
95
|
+
' background:#1677ff; color:#fff; font-weight:600; }',
|
|
96
|
+
'.sm-hist__list { overflow-y:auto; padding:6px; }',
|
|
97
|
+
'.sm-hist__row { position:relative; display:flex; align-items:center; gap:10px; width:100%; border:0;',
|
|
98
|
+
' background:transparent; cursor:pointer; text-align:left; padding:8px 12px 8px 14px;',
|
|
99
|
+
' border-radius:8px; min-width:0; }',
|
|
100
|
+
'.sm-hist__row:hover { background:light-dark(rgba(15,17,21,.05), rgba(255,255,255,.07)); }',
|
|
101
|
+
'.sm-hist__row--on, .sm-hist__row--on:hover { background:light-dark(rgba(22,119,255,.1), rgba(22,119,255,.22)); }',
|
|
102
|
+
'.sm-hist__row--on::before { content:""; position:absolute; left:0; top:6px; bottom:6px; width:3px;',
|
|
103
|
+
' border-radius:2px; background:#1677ff; }',
|
|
104
|
+
'.sm-hist__row--on .sm-hist__text { color:#1677ff; font-weight:600; }',
|
|
105
|
+
'.sm-hist__row:focus-visible { outline:2px solid #1677ff; outline-offset:-2px; }',
|
|
106
|
+
'.sm-hist__text { flex:1; min-width:0; font:var(--dsw-font-s-14, 14px/22px sans-serif);',
|
|
107
|
+
' color:inherit; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }',
|
|
108
|
+
'.sm-hist__time { font:12px/18px var(--ds-font-family-code, monospace);',
|
|
109
|
+
' color:light-dark(rgba(15,17,21,.4), rgba(232,234,237,.4));',
|
|
110
|
+
' font-variant-numeric:tabular-nums; flex:none; }',
|
|
111
|
+
'.sm-hist__star { border:0; background:transparent; cursor:pointer; flex:none;',
|
|
112
|
+
' font-size:15px; line-height:1; padding:2px 4px; border-radius:6px;',
|
|
113
|
+
' color:light-dark(rgba(15,17,21,.35), rgba(232,234,237,.35));',
|
|
114
|
+
' opacity:0; transition:opacity .12s; }',
|
|
115
|
+
'.sm-hist__row:hover .sm-hist__star, .sm-hist__star:focus-visible { opacity:1; }',
|
|
116
|
+
'.sm-hist__star--on { opacity:1; color:#f5a623; }',
|
|
117
|
+
'.sm-hist__star:hover { color:#f5a623; }',
|
|
118
|
+
'.sm-hist__editbtn { border:0; background:transparent; cursor:pointer; flex:none; padding:2px 8px;',
|
|
119
|
+
' border-radius:999px; font:12px/18px sans-serif; font-weight:600;',
|
|
120
|
+
' color:#1677ff; background:light-dark(rgba(22,119,255,.1), rgba(22,119,255,.22)); }',
|
|
121
|
+
'.sm-hist__editbtn:hover { background:light-dark(rgba(22,119,255,.18), rgba(22,119,255,.3)); }',
|
|
122
|
+
'.sm-hist__editwrap { display:flex; flex-direction:column; flex:1; min-height:0; }',
|
|
123
|
+
'.sm-hist__editwrap .sm-hist__list { flex:1; min-height:0; }',
|
|
124
|
+
'.sm-hist__editrow { display:flex; align-items:center; gap:6px; padding:4px 8px; }',
|
|
125
|
+
'.sm-hist__editinput { flex:1; min-width:0; border:1px solid light-dark(rgba(15,17,21,.14), rgba(255,255,255,.18));',
|
|
126
|
+
' border-radius:8px; padding:6px 10px; font:var(--dsw-font-s-14, 14px/22px sans-serif);',
|
|
127
|
+
' color:inherit; background:transparent; }',
|
|
128
|
+
'.sm-hist__editinput:focus { outline:2px solid #1677ff; outline-offset:-1px; }',
|
|
129
|
+
'.sm-hist__del { border:0; background:transparent; cursor:pointer; flex:none;',
|
|
130
|
+
' font-size:16px; line-height:1; padding:2px 8px; border-radius:6px;',
|
|
131
|
+
' color:light-dark(rgba(15,17,21,.45), rgba(232,234,237,.45)); }',
|
|
132
|
+
'.sm-hist__del:hover { color:#e5484d; }',
|
|
133
|
+
'.sm-hist__addrow { display:flex; gap:6px; padding:8px; flex:none;',
|
|
134
|
+
' border-top:1px solid light-dark(rgba(15,17,21,.08), rgba(255,255,255,.1)); }',
|
|
135
|
+
'.sm-hist__empty { padding:24px 14px; text-align:center; font:var(--dsw-font-s-14, 14px/22px sans-serif);',
|
|
136
|
+
' color:light-dark(rgba(15,17,21,.5), rgba(232,234,237,.5)); }',
|
|
137
|
+
'.sm-hist__banner { padding:7px 14px; flex:none; text-align:center; font:var(--dsw-font-xxs-12, 12px/18px sans-serif);',
|
|
138
|
+
' color:light-dark(rgba(15,17,21,.55), rgba(232,234,237,.55));',
|
|
139
|
+
' background:light-dark(rgba(15,17,21,.04), rgba(255,255,255,.06)); }',
|
|
140
|
+
].join('\n')
|
|
141
|
+
|
|
142
|
+
const UNARCHIVE_URL = '/api/session-manager/unarchive'
|
|
143
|
+
const DELETE_URL = '/api/session-manager/delete'
|
|
144
|
+
const INFO_URL = '/api/session-manager/info'
|
|
145
|
+
const DELETED_URL = '/api/session-manager/deleted'
|
|
146
|
+
const REMOUNT_URL = '/api/session-manager/remount'
|
|
147
|
+
const FORGET_URL = '/api/session-manager/forget'
|
|
148
|
+
const STATUS_URL = '/api/session-manager/status'
|
|
149
|
+
const INPUTS_URL = '/api/session-manager/inputs'
|
|
150
|
+
|
|
151
|
+
// 请求默认超时:host 被批量解压等同步任务阻塞时路由会迟滞数秒,
|
|
152
|
+
// 无超时则浮层停在「正在读取…」假死;超时按错误抛出,由调用方兜底,
|
|
153
|
+
// 轮询链继续,host 恢复后自动跟上
|
|
154
|
+
const API_TIMEOUT_MS = 8 * 1000
|
|
155
|
+
|
|
156
|
+
async function api(url, options) {
|
|
157
|
+
const controller = new AbortController()
|
|
158
|
+
const timer = setTimeout(() => controller.abort(), API_TIMEOUT_MS)
|
|
159
|
+
try {
|
|
160
|
+
const response = await fetch(url, {
|
|
161
|
+
headers: { 'content-type': 'application/json' },
|
|
162
|
+
signal: controller.signal,
|
|
163
|
+
...options,
|
|
164
|
+
})
|
|
165
|
+
const payload = await response.json().catch(() => ({}))
|
|
166
|
+
if (!response.ok) throw new Error(payload && payload.error ? payload.error : 'HTTP ' + response.status)
|
|
167
|
+
return payload
|
|
168
|
+
} catch (error) {
|
|
169
|
+
if (error && error.name === 'AbortError') throw new Error('响应超时(' + Math.round(API_TIMEOUT_MS / 1000) + 's),宿主繁忙中')
|
|
170
|
+
throw error
|
|
171
|
+
} finally {
|
|
172
|
+
clearTimeout(timer)
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function h(type, props) {
|
|
177
|
+
const children = Array.prototype.slice.call(arguments, 2)
|
|
178
|
+
return React.createElement.apply(React, [type, props || null].concat(children))
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function fmtTime(ms) {
|
|
182
|
+
const t = Number(ms)
|
|
183
|
+
if (t !== t || !t) return '—'
|
|
184
|
+
const d = new Date(t)
|
|
185
|
+
const pad = (n) => (n < 10 ? '0' : '') + n
|
|
186
|
+
const date = d.getFullYear() === new Date().getFullYear()
|
|
187
|
+
? pad(d.getMonth() + 1) + '/' + pad(d.getDate())
|
|
188
|
+
: d.getFullYear() + '/' + pad(d.getMonth() + 1) + '/' + pad(d.getDate())
|
|
189
|
+
return date + ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes())
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function fmtSize(bytes) {
|
|
193
|
+
const n = Number(bytes)
|
|
194
|
+
if (n !== n || n < 0) return '—'
|
|
195
|
+
const UNIT_STEP = 1024
|
|
196
|
+
if (n < UNIT_STEP) return n + ' B'
|
|
197
|
+
if (n < UNIT_STEP * UNIT_STEP) return (n / UNIT_STEP).toFixed(1) + ' KB'
|
|
198
|
+
return (n / (UNIT_STEP * UNIT_STEP)).toFixed(1) + ' MB'
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function useSnapshot(source) {
|
|
202
|
+
return useSyncExternalStore(
|
|
203
|
+
(listener) => source.subscribe(listener),
|
|
204
|
+
() => source.getSnapshot(),
|
|
205
|
+
)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// 已归档会话面板行:官方会话行 ∩ 归档集合,按更新时间倒序(镜像 core.mjs projectArchiveRows)
|
|
209
|
+
function projectRows(listState, archivedIds) {
|
|
210
|
+
const archived = new Set(archivedIds)
|
|
211
|
+
const byId = (listState && listState.byId) || {}
|
|
212
|
+
return Object.keys(byId)
|
|
213
|
+
.filter((id) => archived.has(id))
|
|
214
|
+
.map((id) => ({ id, title: byId[id].displayTitle || id, updatedAt: byId[id].updatedAt }))
|
|
215
|
+
.sort((left, right) => right.updatedAt - left.updatedAt)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// 已删除面板行:标题回退会话 id,按删除时间倒序(镜像 core.mjs projectDeletedRows)
|
|
219
|
+
function projectDeletedRows(deleted, listState) {
|
|
220
|
+
const byId = (listState && listState.byId) || {}
|
|
221
|
+
return [...(deleted || [])]
|
|
222
|
+
.sort((left, right) => right.deletedAt - left.deletedAt)
|
|
223
|
+
.map((item) => ({
|
|
224
|
+
sessionId: item.sessionId,
|
|
225
|
+
path: item.path,
|
|
226
|
+
deletedAt: item.deletedAt,
|
|
227
|
+
title: (byId[item.sessionId] && byId[item.sessionId].displayTitle) || item.sessionId,
|
|
228
|
+
}))
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Toast 差分守卫:连续两个 ready 快照才计新增(镜像 core.mjs archiveToastStep)。
|
|
232
|
+
// 模型订阅即时发射 pending 空态,基线(存量归档)成为第二帧;基线是重连权威而非
|
|
233
|
+
// 归档事件,启动与重连首装不误报。差分 Set 每帧构建一次,与 core.diffArchived 同构
|
|
234
|
+
function archiveToastStep(previous, snapshot) {
|
|
235
|
+
const ready = Boolean(snapshot && snapshot.phase === 'ready')
|
|
236
|
+
const ids = (snapshot && snapshot.archivedSessionIds) || []
|
|
237
|
+
const baseline = previous !== undefined && previous.ready && ready ? new Set(previous.ids) : null
|
|
238
|
+
const added = baseline ? ids.filter((id) => !baseline.has(id)) : []
|
|
239
|
+
return { state: { ready, ids }, added }
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function ArchiveRow(props) {
|
|
243
|
+
const row = props.row
|
|
244
|
+
const armed = props.armed
|
|
245
|
+
const busy = props.busy
|
|
246
|
+
const confirm = props.confirm
|
|
247
|
+
const actions = armed
|
|
248
|
+
? [
|
|
249
|
+
h('button', {
|
|
250
|
+
key: 'confirm',
|
|
251
|
+
className: 'sm-btn sm-btn--confirm',
|
|
252
|
+
disabled: busy || !confirm || Boolean(confirm.error),
|
|
253
|
+
onClick: props.onConfirm,
|
|
254
|
+
}, '移入回收站'),
|
|
255
|
+
h('button', { key: 'cancel', className: 'sm-btn', disabled: busy, onClick: props.onDisarm }, '取消'),
|
|
256
|
+
]
|
|
257
|
+
: [
|
|
258
|
+
h('button', { key: 'restore', className: 'sm-btn sm-btn--restore', disabled: busy, onClick: props.onUnarchive }, '恢复'),
|
|
259
|
+
h('button', { key: 'delete', className: 'sm-btn sm-btn--danger', disabled: busy, onClick: props.onDelete }, '删除'),
|
|
260
|
+
]
|
|
261
|
+
return h('div', {
|
|
262
|
+
className: 'sm-row' + (armed ? ' sm-row--armed' : '') + (busy ? ' sm-row--busy' : ''),
|
|
263
|
+
},
|
|
264
|
+
armed && confirm && !confirm.error
|
|
265
|
+
? h('span', { key: 'meta', className: 'sm-row__size' }, confirm.missing ? '产物已丢失' : fmtSize(confirm.sizeBytes))
|
|
266
|
+
: h('span', { key: 'meta', className: 'sm-row__time', title: new Date(row.updatedAt).toLocaleString() }, fmtTime(row.updatedAt)),
|
|
267
|
+
h('span', { className: 'sm-row__title', title: row.title }, row.title),
|
|
268
|
+
h('div', { className: 'sm-row__actions' }, actions),
|
|
269
|
+
confirm && confirm.error ? h('span', { className: 'sm-row__error' }, confirm.error) : null,
|
|
270
|
+
)
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function DeletedRow(props) {
|
|
274
|
+
const row = props.row
|
|
275
|
+
const busy = props.busy
|
|
276
|
+
return h('div', { className: 'sm-row' + (busy ? ' sm-row--busy' : '') },
|
|
277
|
+
h('span', { key: 'meta', className: 'sm-row__time', title: new Date(row.deletedAt).toLocaleString() }, fmtTime(row.deletedAt)),
|
|
278
|
+
h('span', { className: 'sm-row__title', title: row.title }, row.title),
|
|
279
|
+
h('div', { className: 'sm-row__actions' },
|
|
280
|
+
h('button', { key: 'remount', className: 'sm-btn sm-btn--restore', disabled: busy, onClick: props.onRemount }, '重新挂载'),
|
|
281
|
+
h('button', { key: 'forget', className: 'sm-btn', disabled: busy, onClick: props.onForget }, '移除记录'),
|
|
282
|
+
),
|
|
283
|
+
h('span', { className: 'sm-row__path', title: row.path }, '原位置 ' + row.path),
|
|
284
|
+
)
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// 已删除分区:仅非空渲染;还原指引在分区头部,行内提供重挂载与移除记录
|
|
288
|
+
function DeletedSection(props) {
|
|
289
|
+
const rows = props.rows
|
|
290
|
+
if (rows.length === 0) return null
|
|
291
|
+
return h('div', { className: 'sm-deleted' },
|
|
292
|
+
h('div', { className: 'sm-head' },
|
|
293
|
+
h('span', { className: 'sm-head__title' }, '已删除'),
|
|
294
|
+
h('span', { className: 'sm-head__count' }, rows.length + ' 条'),
|
|
295
|
+
),
|
|
296
|
+
h('div', { className: 'sm-head__hint' },
|
|
297
|
+
'到系统回收站将会话文件夹还原到原位置,再点「重新挂载」找回;清空回收站后无法找回。'),
|
|
298
|
+
h('div', { className: 'sm-tray' },
|
|
299
|
+
rows.map((row) => h(DeletedRow, {
|
|
300
|
+
key: row.sessionId,
|
|
301
|
+
row,
|
|
302
|
+
busy: props.busyId === row.sessionId,
|
|
303
|
+
onRemount: () => props.onRemount(row),
|
|
304
|
+
onForget: () => props.onForget(row),
|
|
305
|
+
}))),
|
|
306
|
+
)
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function SessionManagerApp(props) {
|
|
310
|
+
const rows = props.rows
|
|
311
|
+
const listState = props.listState
|
|
312
|
+
const [busyId, setBusyId] = useState(null)
|
|
313
|
+
const [armedId, setArmedId] = useState(null)
|
|
314
|
+
const [confirms, setConfirms] = useState({})
|
|
315
|
+
const [deleted, setDeleted] = useState([])
|
|
316
|
+
const [periodic, setPeriodic] = useState(null)
|
|
317
|
+
|
|
318
|
+
function refreshDeleted() {
|
|
319
|
+
api(DELETED_URL)
|
|
320
|
+
.then((payload) => setDeleted((payload && payload.deleted) || []))
|
|
321
|
+
.catch((error) => {
|
|
322
|
+
// 加载失败保留旧数据:清空会让用户误读为台账已清空(数据丢失假象)
|
|
323
|
+
console.warn('[session-manager] 已删除列表加载失败', error)
|
|
324
|
+
})
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
useEffect(() => { refreshDeleted() }, [])
|
|
328
|
+
|
|
329
|
+
// 周期评估状态仅用于降级提示,加载失败按无提示处理
|
|
330
|
+
useEffect(() => {
|
|
331
|
+
api(STATUS_URL)
|
|
332
|
+
.then((payload) => setPeriodic(payload && payload.periodic))
|
|
333
|
+
.catch(() => {})
|
|
334
|
+
}, [])
|
|
335
|
+
|
|
336
|
+
function run(sessionId, action, successText) {
|
|
337
|
+
setBusyId(sessionId)
|
|
338
|
+
return action()
|
|
339
|
+
.then((result) => {
|
|
340
|
+
if (result && result.partial) toast(result.message, { kind: 'ok' })
|
|
341
|
+
else if (result && result.message) toast(result.message, { kind: 'ok' })
|
|
342
|
+
else toast(successText || '操作完成', { kind: 'ok' })
|
|
343
|
+
setArmedId(null)
|
|
344
|
+
setConfirms({})
|
|
345
|
+
})
|
|
346
|
+
.catch((error) => toast(error && error.message ? error.message : String(error), { kind: 'error', sticky: true }))
|
|
347
|
+
.then(() => setBusyId(null))
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function onDelete(row) {
|
|
351
|
+
if (armedId !== row.id) {
|
|
352
|
+
setArmedId(row.id)
|
|
353
|
+
// 两段式确认:首段拉取标题 / 时间 / 体积;产物已缺失(missing)仍可确认,删除仅清理列表
|
|
354
|
+
if (confirms[row.id] === undefined) {
|
|
355
|
+
api(INFO_URL, { method: 'POST', body: JSON.stringify({ sessionId: row.id }) })
|
|
356
|
+
.then((info) => setConfirms((prev) => ({ ...prev, [row.id]: info.supported
|
|
357
|
+
? { sizeBytes: info.sizeBytes, missing: Boolean(info.missing) }
|
|
358
|
+
: { error: '当前存储后端不支持按会话删除' } })))
|
|
359
|
+
.catch((error) => setConfirms((prev) => ({ ...prev, [row.id]: { error: String(error.message || error) } })))
|
|
360
|
+
}
|
|
361
|
+
return
|
|
362
|
+
}
|
|
363
|
+
void run(row.id, () => api(DELETE_URL, { method: 'POST', body: JSON.stringify({ sessionId: row.id }) }),
|
|
364
|
+
'已移入系统回收站;还原后可在「已删除」区重新挂载')
|
|
365
|
+
.then(refreshDeleted)
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function onRemount(row) {
|
|
369
|
+
void run(row.sessionId, () => api(REMOUNT_URL, { method: 'POST', body: JSON.stringify({ sessionId: row.sessionId }) }),
|
|
370
|
+
'已重新挂载,会话回到工作区列表')
|
|
371
|
+
.then(refreshDeleted)
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function onForget(row) {
|
|
375
|
+
void run(row.sessionId, () => api(FORGET_URL, { method: 'POST', body: JSON.stringify({ sessionId: row.sessionId }) }))
|
|
376
|
+
.then(refreshDeleted)
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
return h('div', { className: 'sm-panel' },
|
|
380
|
+
h('div', { className: 'sm-head' },
|
|
381
|
+
h('span', { className: 'sm-head__title' }, '会话归档'),
|
|
382
|
+
rows.length > 0 ? h('span', { className: 'sm-head__count' }, rows.length + ' 条') : null,
|
|
383
|
+
),
|
|
384
|
+
h('div', { className: 'sm-head__hint' }, '恢复放回会话列表;删除移入系统回收站,可还原后重新挂载。'),
|
|
385
|
+
periodic && periodic.running === false
|
|
386
|
+
? h('div', { className: 'sm-head__hint' },
|
|
387
|
+
'周期评估未运行(' + (periodic.reason || '宿主定时服务不可用') + ');自动归档仍在新会话创建与启动时生效。')
|
|
388
|
+
: null,
|
|
389
|
+
h('div', { className: 'sm-tray' },
|
|
390
|
+
rows.length === 0
|
|
391
|
+
? h('div', { className: 'sm-empty' },
|
|
392
|
+
h('div', null, '还没有归档的会话'),
|
|
393
|
+
h('div', { className: 'sm-empty__hint' }, '会话归档后集中显示在这里'))
|
|
394
|
+
: rows.map((row) => h(ArchiveRow, {
|
|
395
|
+
key: row.id,
|
|
396
|
+
row,
|
|
397
|
+
armed: armedId === row.id,
|
|
398
|
+
busy: busyId === row.id,
|
|
399
|
+
confirm: confirms[row.id],
|
|
400
|
+
onUnarchive: () => {
|
|
401
|
+
void run(row.id, () => api(UNARCHIVE_URL, { method: 'POST', body: JSON.stringify({ sessionId: row.id }) }))
|
|
402
|
+
},
|
|
403
|
+
onDelete: () => onDelete(row),
|
|
404
|
+
onConfirm: () => onDelete(row),
|
|
405
|
+
onDisarm: () => { setArmedId(null); setConfirms({}) },
|
|
406
|
+
}))),
|
|
407
|
+
h(DeletedSection, {
|
|
408
|
+
rows: projectDeletedRows(deleted, listState),
|
|
409
|
+
busyId,
|
|
410
|
+
onRemount,
|
|
411
|
+
onForget,
|
|
412
|
+
}),
|
|
413
|
+
h(HistorySwitchRow, null),
|
|
414
|
+
)
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// 历史浮层启停开关:低频功能,收在面板末行小尺寸呈现;
|
|
418
|
+
// 值存宿主 settings(与原生设置页同存储),切换经本插件路由中转
|
|
419
|
+
function HistorySwitchRow() {
|
|
420
|
+
const [enabled, setEnabled] = useState(null)
|
|
421
|
+
useEffect(() => {
|
|
422
|
+
api(HISTORY_ENABLED_URL)
|
|
423
|
+
.then((payload) => setEnabled(payload ? payload.enabled !== false : true))
|
|
424
|
+
.catch(() => setEnabled(true))
|
|
425
|
+
}, [])
|
|
426
|
+
// 受控 checkbox:onChange 内同步落 state(异步确认会让 DOM 与渲染竞态,
|
|
427
|
+
// 视觉慢一拍),服务端响应仅用于失败回滚
|
|
428
|
+
const flip = (event) => {
|
|
429
|
+
const next = event.target.checked
|
|
430
|
+
setEnabled(next)
|
|
431
|
+
api(HISTORY_ENABLED_URL, { method: 'POST', body: JSON.stringify({ enabled: next }) })
|
|
432
|
+
.then((payload) => {
|
|
433
|
+
toast('历史输入浮层已' + (payload && payload.enabled !== false ? '启用' : '停用') + ',刷新页面后生效')
|
|
434
|
+
})
|
|
435
|
+
.catch(() => {
|
|
436
|
+
setEnabled(!next)
|
|
437
|
+
toast('切换失败', { kind: 'error' })
|
|
438
|
+
})
|
|
439
|
+
}
|
|
440
|
+
return h('label', { className: 'sm-histsw' },
|
|
441
|
+
h('input', { type: 'checkbox', checked: enabled !== false, onChange: flip }),
|
|
442
|
+
h('span', { className: 'sm-histsw__track' }, h('span', { className: 'sm-histsw__thumb' })),
|
|
443
|
+
h('span', { className: 'sm-histsw__label', onClick: (event) => event.preventDefault() }, '历史输入浮层(Alt+↑)'),
|
|
444
|
+
)
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// 历史输入浮层:Alt+↑ 快捷键唤起,平时零占位;浮层内点选或键盘(↑/↓ 选择、
|
|
448
|
+
// ←/→ 切范围、Enter 填入、Esc 关闭)回填历史,填入走宿主公共契约
|
|
449
|
+
// inputActions.setDraft,不直改编辑器 DOM。数据由 host 工作区持久缓存
|
|
450
|
+
// (~/.dsh/historyPrompt)直接返回,后台对齐保持新鲜
|
|
451
|
+
// 历史输入范围:索引即 ←/→ 切换顺序(索引 0 为常用收藏,→ 向更大范围),
|
|
452
|
+
// 与 core.mjs HISTORY_SCOPES 同序同值;浮层默认落点为当前会话
|
|
453
|
+
const HISTORY_SCOPES = ['prompts', 'session', 'workspace', 'global']
|
|
454
|
+
const HISTORY_SCOPE_LABELS = ['常用', '当前会话', '本工作区', '全部工作区']
|
|
455
|
+
const HISTORY_SCOPE_DEFAULT = HISTORY_SCOPES.indexOf('session')
|
|
456
|
+
// 对齐未就绪时的静默重拉间隔与上限(对齐通常秒级完成,上限防死循环)
|
|
457
|
+
const HISTORY_REPULL_MS = 3 * 1000
|
|
458
|
+
const PROMPTS_TOGGLE_URL = '/api/session-manager/prompts/toggle'
|
|
459
|
+
const HISTORY_ENABLED_URL = '/api/session-manager/history-enabled'
|
|
460
|
+
|
|
461
|
+
function HistoryDock({ session, inputActions }) {
|
|
462
|
+
const [open, setOpen] = useState(false)
|
|
463
|
+
const [items, setItems] = useState(null)
|
|
464
|
+
const [cursor, setCursor] = useState(-1)
|
|
465
|
+
const [scopeIndex, setScopeIndex] = useState(HISTORY_SCOPE_DEFAULT)
|
|
466
|
+
const [aligning, setAligning] = useState(false)
|
|
467
|
+
const [loadError, setLoadError] = useState(false)
|
|
468
|
+
// 收藏集合:浮层打开时并行拉一次,行悬停星标据此显示实/空心;
|
|
469
|
+
// editing 仅常用范围可进入(行内改/删 + 底部新增)
|
|
470
|
+
const [collected, setCollected] = useState(() => new Set())
|
|
471
|
+
const [editing, setEditing] = useState(false)
|
|
472
|
+
const rootRef = React.useRef(null)
|
|
473
|
+
// 键盘层权威状态:监听器挂载一次(空依赖),读写全走 ref,规避 effect 重挂
|
|
474
|
+
// 时序造成的闭包陈旧;state 仅驱动渲染,变更处双写
|
|
475
|
+
const viewRef = React.useRef({ open: false, items: null, cursor: -1, scopeIndex: 0, aligning: false })
|
|
476
|
+
const sessionRef = React.useRef(session)
|
|
477
|
+
const inputActionsRef = React.useRef(inputActions)
|
|
478
|
+
// 历史浮层启停(设置开关):挂载拉取一次,停用即整体不渲染、键盘放行
|
|
479
|
+
const enabledRef = React.useRef(true)
|
|
480
|
+
const [historyEnabled, setHistoryEnabled] = useState(true)
|
|
481
|
+
sessionRef.current = session
|
|
482
|
+
inputActionsRef.current = inputActions
|
|
483
|
+
useEffect(() => {
|
|
484
|
+
api(HISTORY_ENABLED_URL)
|
|
485
|
+
.then((payload) => {
|
|
486
|
+
const on = payload ? payload.enabled !== false : true
|
|
487
|
+
enabledRef.current = on
|
|
488
|
+
setHistoryEnabled(on)
|
|
489
|
+
})
|
|
490
|
+
.catch(() => {})
|
|
491
|
+
}, [])
|
|
492
|
+
|
|
493
|
+
function syncView(patch) {
|
|
494
|
+
viewRef.current = { ...viewRef.current, ...patch }
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// host 响应信封 { inputs, aligned };解包并防御形态漂移,消费侧恒为数组。
|
|
498
|
+
// aligned=false 表示工作区缓存尚未建立(首次),host 已在后台对齐
|
|
499
|
+
function fetchInputs(scope) {
|
|
500
|
+
return api(INPUTS_URL + '?sessionId=' + encodeURIComponent(sessionRef.current.sessionId) + '&scope=' + scope)
|
|
501
|
+
.then((payload) => ({
|
|
502
|
+
inputs: (payload && Array.isArray(payload.inputs)) ? payload.inputs : [],
|
|
503
|
+
aligned: Boolean(payload && payload.aligned),
|
|
504
|
+
}))
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// 收藏集合与浮层列表共用 inputs 路由(scope=prompts),毫秒级缓存读
|
|
508
|
+
function refreshCollected() {
|
|
509
|
+
return api(INPUTS_URL + '?sessionId=' + encodeURIComponent(sessionRef.current.sessionId) + '&scope=prompts')
|
|
510
|
+
.then((payload) => {
|
|
511
|
+
const list = payload && Array.isArray(payload.inputs) ? payload.inputs : []
|
|
512
|
+
setCollected(new Set(list.map((item) => item.text)))
|
|
513
|
+
})
|
|
514
|
+
.catch(() => {})
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// 收藏切换:行悬停星标与编辑态删除共用;成功后同步星标集,
|
|
518
|
+
// 当前正处于常用范围时立即重拉列表(不等轮询)
|
|
519
|
+
function togglePrompt(text) {
|
|
520
|
+
return api(PROMPTS_TOGGLE_URL, { method: 'POST', body: JSON.stringify({ text }) })
|
|
521
|
+
.then((payload) => {
|
|
522
|
+
const on = Boolean(payload && payload.collected)
|
|
523
|
+
toast(on ? '已收藏到常用' : '已取消收藏')
|
|
524
|
+
setCollected((prev) => {
|
|
525
|
+
const next = new Set(prev)
|
|
526
|
+
if (on) next.add(text)
|
|
527
|
+
else next.delete(text)
|
|
528
|
+
return next
|
|
529
|
+
})
|
|
530
|
+
if (viewRef.current.open && viewRef.current.scopeIndex === HISTORY_SCOPES.indexOf('prompts')) requestScope(viewRef.current.scopeIndex)
|
|
531
|
+
return on
|
|
532
|
+
})
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// 编辑态改文本 = 移除旧文本 + 收藏新文本(两次 toggle 原子性由"同文本去重"保证:
|
|
536
|
+
// 中断最坏留下旧文本,重改一次即自愈)
|
|
537
|
+
function renamePrompt(from, to) {
|
|
538
|
+
return togglePrompt(from)
|
|
539
|
+
.then(() => (to === '' ? Promise.resolve() : togglePrompt(to)))
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// 应用一次响应:仅当范围未变时生效,防止快速切范围后迟到响应覆盖新范围。
|
|
543
|
+
// items 深度不变的重复响应不重置光标(轮询期间保持用户选位)
|
|
544
|
+
function applyResult(scopeIdx, result) {
|
|
545
|
+
if (viewRef.current.scopeIndex !== scopeIdx) return
|
|
546
|
+
const prev = viewRef.current.items
|
|
547
|
+
if (prev !== null && JSON.stringify(prev) === JSON.stringify(result.inputs)) {
|
|
548
|
+
syncView({ aligning: !result.aligned })
|
|
549
|
+
setAligning(!result.aligned)
|
|
550
|
+
return
|
|
551
|
+
}
|
|
552
|
+
syncView({ items: result.inputs, cursor: -1, aligning: !result.aligned })
|
|
553
|
+
setItems(result.inputs)
|
|
554
|
+
setCursor(-1)
|
|
555
|
+
setAligning(!result.aligned)
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// 浮层打开期间固定节奏轮询当前范围:host 侧对齐/焦点对齐是异步的,
|
|
559
|
+
// 数据可能晚于首次响应到达,一次性重拉预算耗尽后迟到的数据将永不出现
|
|
560
|
+
// (实机验证实测:API 已就绪而浮层停在旧列表)。轮询开销为毫秒级缓存读;
|
|
561
|
+
// 请求失败(含超时)也保持轮询,host 恢复后列表自愈,不假死
|
|
562
|
+
function requestScope(scopeIdx) {
|
|
563
|
+
const scope = HISTORY_SCOPES[scopeIdx]
|
|
564
|
+
const scheduleNext = () => {
|
|
565
|
+
if (!viewRef.current.open || viewRef.current.scopeIndex !== scopeIdx) return
|
|
566
|
+
setTimeout(() => {
|
|
567
|
+
if (viewRef.current.open && viewRef.current.scopeIndex === scopeIdx) requestScope(scopeIdx)
|
|
568
|
+
}, HISTORY_REPULL_MS)
|
|
569
|
+
}
|
|
570
|
+
fetchInputs(scope)
|
|
571
|
+
.then((result) => {
|
|
572
|
+
applyResult(scopeIdx, result)
|
|
573
|
+
scheduleNext()
|
|
574
|
+
})
|
|
575
|
+
.catch((error) => {
|
|
576
|
+
if (viewRef.current.scopeIndex !== scopeIdx) return
|
|
577
|
+
syncView({ items: [] })
|
|
578
|
+
setLoadError(true)
|
|
579
|
+
setItems([])
|
|
580
|
+
toast(error && error.message ? error.message : String(error), { kind: 'error' })
|
|
581
|
+
scheduleNext()
|
|
582
|
+
})
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
function fill(text) {
|
|
586
|
+
inputActionsRef.current.setDraft(text)
|
|
587
|
+
syncView({ open: false })
|
|
588
|
+
setOpen(false)
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
function openPopup() {
|
|
592
|
+
syncView({ open: true, items: null, cursor: -1, scopeIndex: HISTORY_SCOPE_DEFAULT, aligning: false })
|
|
593
|
+
setOpen(true)
|
|
594
|
+
setScopeIndex(HISTORY_SCOPE_DEFAULT)
|
|
595
|
+
setCursor(-1)
|
|
596
|
+
setAligning(false)
|
|
597
|
+
setLoadError(false)
|
|
598
|
+
setItems(null)
|
|
599
|
+
setEditing(false)
|
|
600
|
+
refreshCollected()
|
|
601
|
+
requestScope(HISTORY_SCOPE_DEFAULT)
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// 切换范围:→ 向大(工作区/全局),← 返回收藏;边界停住;缓存直接返回(毫秒级),后台保持对齐
|
|
605
|
+
function switchScope(delta) {
|
|
606
|
+
const next = viewRef.current.scopeIndex + delta
|
|
607
|
+
if (next < 0 || next >= HISTORY_SCOPE_LABELS.length) return
|
|
608
|
+
syncView({ scopeIndex: next, items: null, cursor: -1, aligning: false })
|
|
609
|
+
setScopeIndex(next)
|
|
610
|
+
setCursor(-1)
|
|
611
|
+
setAligning(false)
|
|
612
|
+
setLoadError(false)
|
|
613
|
+
setItems(null)
|
|
614
|
+
setEditing(false)
|
|
615
|
+
requestScope(next)
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// Alt+↑ 唤起浮层;浮层开 = 菜单模态,捕获阶段拦截导航键,先于 Lexical 光标移动。
|
|
619
|
+
// 设置开关停用时监听器直接放行(读 ref,开关值挂载拉取后即时反映)
|
|
620
|
+
useEffect(() => {
|
|
621
|
+
function onKeyDown(event) {
|
|
622
|
+
if (enabledRef.current === false) return
|
|
623
|
+
const view = viewRef.current
|
|
624
|
+
if (event.isComposing) return
|
|
625
|
+
if (!view.open) {
|
|
626
|
+
if (event.altKey && !event.ctrlKey && !event.metaKey && !event.shiftKey && event.key === 'ArrowUp') {
|
|
627
|
+
event.preventDefault()
|
|
628
|
+
event.stopPropagation()
|
|
629
|
+
openPopup()
|
|
630
|
+
}
|
|
631
|
+
return
|
|
632
|
+
}
|
|
633
|
+
if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
|
|
634
|
+
event.preventDefault()
|
|
635
|
+
event.stopPropagation()
|
|
636
|
+
if (view.items === null || view.items.length === 0) return
|
|
637
|
+
const delta = event.key === 'ArrowUp' ? -1 : 1
|
|
638
|
+
const next = Math.min(Math.max(view.cursor + delta, 0), view.items.length - 1)
|
|
639
|
+
syncView({ cursor: next })
|
|
640
|
+
setCursor(next)
|
|
641
|
+
return
|
|
642
|
+
}
|
|
643
|
+
if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') {
|
|
644
|
+
event.preventDefault()
|
|
645
|
+
event.stopPropagation()
|
|
646
|
+
switchScope(event.key === 'ArrowLeft' ? -1 : 1)
|
|
647
|
+
return
|
|
648
|
+
}
|
|
649
|
+
if (event.key === 'Enter') {
|
|
650
|
+
// 未选中条目时放行:用户可能只是在草稿里按 Enter 发消息,吞掉即是「假死锁定」
|
|
651
|
+
if (!(view.items !== null && view.cursor >= 0 && view.cursor < view.items.length)) return
|
|
652
|
+
event.preventDefault()
|
|
653
|
+
event.stopPropagation()
|
|
654
|
+
fill(view.items[view.cursor].text)
|
|
655
|
+
return
|
|
656
|
+
}
|
|
657
|
+
if (event.key === 'Escape') {
|
|
658
|
+
event.preventDefault()
|
|
659
|
+
event.stopPropagation()
|
|
660
|
+
syncView({ open: false })
|
|
661
|
+
setOpen(false)
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
document.addEventListener('keydown', onKeyDown, true)
|
|
665
|
+
return () => document.removeEventListener('keydown', onKeyDown, true)
|
|
666
|
+
}, [])
|
|
667
|
+
|
|
668
|
+
// 选中行滚入可视区:键盘移动后高亮行保持在视野内
|
|
669
|
+
useEffect(() => {
|
|
670
|
+
if (!open || cursor < 0) return
|
|
671
|
+
const row = rootRef.current && rootRef.current.querySelector('.sm-hist__row--on')
|
|
672
|
+
if (row) row.scrollIntoView({ block: 'nearest' })
|
|
673
|
+
}, [open, cursor, items])
|
|
674
|
+
|
|
675
|
+
// 浮层外点击关闭(双写:漏写 viewRef 会让键盘层误判浮层仍开,吞掉输入框方向键)
|
|
676
|
+
useEffect(() => {
|
|
677
|
+
if (!open) return undefined
|
|
678
|
+
function onPointerDown(event) {
|
|
679
|
+
if (rootRef.current && !rootRef.current.contains(event.target)) {
|
|
680
|
+
syncView({ open: false })
|
|
681
|
+
setOpen(false)
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
document.addEventListener('mousedown', onPointerDown, true)
|
|
685
|
+
return () => document.removeEventListener('mousedown', onPointerDown, true)
|
|
686
|
+
}, [open])
|
|
687
|
+
|
|
688
|
+
// 零高度锚点:平时不占任何界面空间,仅浮层打开时渲染;设置停用整体不渲染
|
|
689
|
+
if (session === undefined || inputActions === undefined || historyEnabled === false) return null
|
|
690
|
+
const promptsIdx = HISTORY_SCOPES.indexOf('prompts')
|
|
691
|
+
const inPrompts = scopeIndex === promptsIdx
|
|
692
|
+
// 行悬停星标:非常用范围显示,已收藏实星(点击取消),未收藏空心(点击收藏);
|
|
693
|
+
// stopPropagation 防止触发行回填
|
|
694
|
+
const starButton = (item) => h('button', {
|
|
695
|
+
className: 'sm-hist__star' + (collected.has(item.text) ? ' sm-hist__star--on' : ''),
|
|
696
|
+
title: collected.has(item.text) ? '取消收藏' : '收藏',
|
|
697
|
+
onClick: (event) => {
|
|
698
|
+
event.stopPropagation()
|
|
699
|
+
togglePrompt(item.text)
|
|
700
|
+
},
|
|
701
|
+
}, collected.has(item.text) ? '★' : '☆')
|
|
702
|
+
const rowButton = (item, index) => h('button', {
|
|
703
|
+
key: index + ':' + item.at,
|
|
704
|
+
className: 'sm-hist__row' + (index === cursor ? ' sm-hist__row--on' : ''),
|
|
705
|
+
onClick: () => fill(item.text),
|
|
706
|
+
},
|
|
707
|
+
h('span', { className: 'sm-hist__text', title: item.text }, item.text),
|
|
708
|
+
h('span', { className: 'sm-hist__time' }, fmtTime(item.at)),
|
|
709
|
+
!inPrompts ? starButton(item) : null,
|
|
710
|
+
)
|
|
711
|
+
// 编辑态行:文本可改(行内 input,回车/失焦保存)+ 删除
|
|
712
|
+
const editRow = (item, index) => h('div', {
|
|
713
|
+
key: index + ':' + item.at,
|
|
714
|
+
className: 'sm-hist__editrow',
|
|
715
|
+
},
|
|
716
|
+
h('input', {
|
|
717
|
+
className: 'sm-hist__editinput',
|
|
718
|
+
defaultValue: item.text,
|
|
719
|
+
title: item.text,
|
|
720
|
+
onKeyDown: (event) => {
|
|
721
|
+
if (event.key === 'Enter') {
|
|
722
|
+
event.preventDefault()
|
|
723
|
+
renamePrompt(item.text, event.target.value.trim())
|
|
724
|
+
}
|
|
725
|
+
},
|
|
726
|
+
onBlur: (event) => {
|
|
727
|
+
const next = event.target.value.trim()
|
|
728
|
+
if (next !== item.text) renamePrompt(item.text, next)
|
|
729
|
+
},
|
|
730
|
+
}),
|
|
731
|
+
h('button', {
|
|
732
|
+
className: 'sm-hist__del',
|
|
733
|
+
title: '删除',
|
|
734
|
+
onClick: () => togglePrompt(item.text),
|
|
735
|
+
}, '×'),
|
|
736
|
+
)
|
|
737
|
+
return h('div', { className: 'sm-hist', ref: rootRef },
|
|
738
|
+
open && h('div', { className: 'sm-hist__pop' },
|
|
739
|
+
h('div', { className: 'sm-hist__hint' },
|
|
740
|
+
h('span', { className: 'sm-hist__scope' }, HISTORY_SCOPE_LABELS[scopeIndex]),
|
|
741
|
+
inPrompts
|
|
742
|
+
? h('button', {
|
|
743
|
+
className: 'sm-hist__editbtn',
|
|
744
|
+
onClick: () => setEditing(!editing),
|
|
745
|
+
}, editing ? '完成' : '编辑')
|
|
746
|
+
: null,
|
|
747
|
+
h('span', null, '↑/↓ 选择 · ←/→ 切换范围 · Enter 填入 · Esc 关闭')),
|
|
748
|
+
aligning && h('div', { className: 'sm-hist__banner' }, '首次对齐历史中,可能需要稍等'),
|
|
749
|
+
items === null
|
|
750
|
+
? h('div', { className: 'sm-hist__empty' }, '正在读取历史输入…')
|
|
751
|
+
: editing && inPrompts
|
|
752
|
+
? h('div', { className: 'sm-hist__editwrap' },
|
|
753
|
+
items.length === 0
|
|
754
|
+
? h('div', { className: 'sm-hist__empty' }, '还没有常用提示词,可从历史行悬停收藏,或在下方添加')
|
|
755
|
+
: h('div', { className: 'sm-hist__list' }, items.map(editRow)),
|
|
756
|
+
h('div', { className: 'sm-hist__addrow' },
|
|
757
|
+
h('input', {
|
|
758
|
+
className: 'sm-hist__editinput',
|
|
759
|
+
placeholder: '输入常用提示词,Enter 添加',
|
|
760
|
+
ref: (el) => { if (el) el.dataset.addinput = '1' },
|
|
761
|
+
onKeyDown: (event) => {
|
|
762
|
+
if (event.key === 'Enter') {
|
|
763
|
+
event.preventDefault()
|
|
764
|
+
const text = event.target.value.trim()
|
|
765
|
+
if (text !== '') {
|
|
766
|
+
togglePrompt(text)
|
|
767
|
+
event.target.value = ''
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
},
|
|
771
|
+
}),
|
|
772
|
+
),
|
|
773
|
+
)
|
|
774
|
+
: items.length === 0
|
|
775
|
+
? h('div', { className: 'sm-hist__empty' }, loadError ? '历史输入加载失败,可关闭后重试' : (inPrompts ? '还没有常用提示词' : '该范围内还没有历史输入'))
|
|
776
|
+
: h('div', { className: 'sm-hist__list' }, items.map(rowButton)),
|
|
777
|
+
),
|
|
778
|
+
)
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
return {
|
|
782
|
+
inject: ['slots', 'sessions', 'workspaces'],
|
|
783
|
+
apply(ctx) {
|
|
784
|
+
const sessions = ctx.get('sessions')
|
|
785
|
+
const workspaces = ctx.get('workspaces')
|
|
786
|
+
|
|
787
|
+
// 样式挂载在宿主文档级:设置页未打开时面板不存在,
|
|
788
|
+
// 样式若随面板注入则面板裸样式渲染
|
|
789
|
+
ctx.effect(() => {
|
|
790
|
+
const style = document.createElement('style')
|
|
791
|
+
style.textContent = CSS
|
|
792
|
+
document.head.appendChild(style)
|
|
793
|
+
return () => style.remove()
|
|
794
|
+
}, 'session-manager styles')
|
|
795
|
+
|
|
796
|
+
// 归档快照差分:新增条数驱动通知
|
|
797
|
+
let previous
|
|
798
|
+
const unsubscribe = workspaces.list.subscribe(() => {
|
|
799
|
+
const step = archiveToastStep(previous, workspaces.list.getSnapshot())
|
|
800
|
+
previous = step.state
|
|
801
|
+
if (step.added.length > 0) toast('有 ' + step.added.length + ' 个会话已归档')
|
|
802
|
+
})
|
|
803
|
+
|
|
804
|
+
ctx.slots.inject('settings.section', () =>
|
|
805
|
+
ctx.slots.register(
|
|
806
|
+
{ name: 'settings.section', id: 'session-manager', order: 46, label: '会话归档' },
|
|
807
|
+
() => React.createElement(SessionManagerPanel, { sessions, workspaces }),
|
|
808
|
+
))
|
|
809
|
+
|
|
810
|
+
// 历史输入回溯入口:官方 conversation.input.dock 插槽(dsh-client-ui-goal 同构)。
|
|
811
|
+
// 宿主缺该插槽时注册抛错即禁用本功能,不阻塞其余能力
|
|
812
|
+
try {
|
|
813
|
+
ctx.slots.inject('conversation.input.dock', () =>
|
|
814
|
+
ctx.slots.register(
|
|
815
|
+
{ name: 'conversation.input.dock', id: 'session-manager-history', order: 90 },
|
|
816
|
+
HistoryDock,
|
|
817
|
+
))
|
|
818
|
+
} catch (error) {
|
|
819
|
+
console.warn('[session-manager] 历史输入入口未注册(宿主无 conversation.input.dock 插槽)', error)
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
ctx.effect(() => unsubscribe, 'session-manager archived diff')
|
|
823
|
+
|
|
824
|
+
function SessionManagerPanel({ sessions: sessionSvc, workspaces: workspaceSvc }) {
|
|
825
|
+
const listState = useSnapshot(sessionSvc.list)
|
|
826
|
+
const workspaceState = useSnapshot(workspaceSvc.list)
|
|
827
|
+
return React.createElement(SessionManagerApp, {
|
|
828
|
+
rows: projectRows(listState, (workspaceState && workspaceState.archivedSessionIds) || []),
|
|
829
|
+
listState,
|
|
830
|
+
})
|
|
831
|
+
}
|
|
832
|
+
},
|
|
833
|
+
}
|
|
834
|
+
},
|
|
835
|
+
})
|