@goodandready/dsh-session-control 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +40 -0
- package/LICENSE +21 -0
- package/README.md +147 -0
- package/cordis.patch.yml +21 -0
- package/lib/client.js +1515 -0
- package/lib/index.js +127 -0
- package/lib/transcript.js +67 -0
- package/package.json +54 -0
package/lib/client.js
ADDED
|
@@ -0,0 +1,1515 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Браузерная половина dsh-session-control.
|
|
3
|
+
*
|
|
4
|
+
* Плагин состоит из двух независимых половин.
|
|
5
|
+
*
|
|
6
|
+
* Половина «СЕРВИС» отдаёт `uiWorkspace` вместо выключенного ядрового ряда
|
|
7
|
+
* ui-workspace. Этот сервис стоит в ОБЯЗАТЕЛЬНОМ inject у
|
|
8
|
+
* dsh-client-ui-sidebar и dsh-client-ui-conversation, поэтому без него не
|
|
9
|
+
* поднимутся ни боковая панель, ни интерфейс беседы — экран будет пустой.
|
|
10
|
+
* Её единственная задача — никогда не падать: ни React, ни нашей логики, ни
|
|
11
|
+
* настроек здесь нет и быть не должно.
|
|
12
|
+
*
|
|
13
|
+
* Половина «ИНТЕРФЕЙС» — наш список сессий в слоте sidebar.workspaces, выбор
|
|
14
|
+
* папки в conversation.hero.workspace и карточка настроек. Она поднимается в
|
|
15
|
+
* отдельном дочернем контексте и целиком обёрнута в try/catch: её отказ
|
|
16
|
+
* оставляет слот пустым, но приложение живым.
|
|
17
|
+
*/
|
|
18
|
+
window.__ModuleLoader__.load({
|
|
19
|
+
id: '@goodandready/dsh-session-control',
|
|
20
|
+
factory: (require) => {
|
|
21
|
+
var module = { exports: {} }
|
|
22
|
+
var exports = module.exports
|
|
23
|
+
|
|
24
|
+
const cordis = require('@deepseek-ai/cordis')
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Папка, в которой работали последней.
|
|
28
|
+
*
|
|
29
|
+
* Свежесть папки — это максимальное `updatedAt` среди её сессий; у папки
|
|
30
|
+
* без сессий берём время создания, иначе новая пустая папка никогда не
|
|
31
|
+
* выигрывала бы. Порядок обхода задаёт хост, поэтому при равенстве времён
|
|
32
|
+
* выбор устойчив.
|
|
33
|
+
*/
|
|
34
|
+
function recentWorkspace(workspaces, sessions) {
|
|
35
|
+
let selected
|
|
36
|
+
let selectedTime = Number.NEGATIVE_INFINITY
|
|
37
|
+
for (const workspace of workspaces) {
|
|
38
|
+
let latest = Number.NEGATIVE_INFINITY
|
|
39
|
+
for (const sessionId of workspace.sessionIds) {
|
|
40
|
+
const session = sessions[sessionId]
|
|
41
|
+
if (session !== undefined) latest = Math.max(latest, session.updatedAt)
|
|
42
|
+
}
|
|
43
|
+
if (latest === Number.NEGATIVE_INFINITY) latest = Date.parse(workspace.createdAt)
|
|
44
|
+
if (selected === undefined || latest > selectedTime) {
|
|
45
|
+
selected = workspace.workspaceId
|
|
46
|
+
selectedTime = latest
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return selected
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Отказ выбора каталога, донесённый до вызывающего без потери кода. */
|
|
53
|
+
class DirectoryBrowseError extends Error {
|
|
54
|
+
constructor(rpcError) {
|
|
55
|
+
super(`directory browse failed: ${rpcError.code}: ${rpcError.message}`)
|
|
56
|
+
this.rpcError = rpcError
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Операции над папками и каталогами, которых ждут ядровые модули.
|
|
62
|
+
*
|
|
63
|
+
* Поверхность повторяет ядровую дословно: любое расхождение здесь выходит
|
|
64
|
+
* наружу не ошибкой, а неработающей кнопкой в чужом модуле.
|
|
65
|
+
*/
|
|
66
|
+
class UiWorkspaceService extends cordis.Service {
|
|
67
|
+
constructor(ctx, directoryPicker, workspaces, sessions) {
|
|
68
|
+
super(ctx, 'uiWorkspace')
|
|
69
|
+
this.directoryPicker = directoryPicker
|
|
70
|
+
this.workspaces = workspaces
|
|
71
|
+
this.sessions = sessions
|
|
72
|
+
/** Незавершённые подключения по папкам: защита от двойного создания. */
|
|
73
|
+
this.connecting = new Map()
|
|
74
|
+
ctx.effect(() => this.watchNavigation(), 'dsh-session-control: политика выбора папки')
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Вернуть сессию, в которую попадёт пользователь при переходе в папку.
|
|
79
|
+
*
|
|
80
|
+
* Пустую сессию папки переиспользуем вместо создания новой: иначе каждый
|
|
81
|
+
* щелчок по папке плодил бы «Новая сессия».
|
|
82
|
+
*/
|
|
83
|
+
async connectWorkspace(workspaceId) {
|
|
84
|
+
const workspace = this.workspaces.list
|
|
85
|
+
.getSnapshot()
|
|
86
|
+
.items.find((item) => item.workspaceId === workspaceId)
|
|
87
|
+
if (workspace === undefined) {
|
|
88
|
+
throw new Error(`uiWorkspace.connectWorkspace: unknown workspace ${workspaceId}`)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const inflight = this.connecting.get(workspaceId)
|
|
92
|
+
if (inflight !== undefined) return inflight
|
|
93
|
+
|
|
94
|
+
const archived = this.workspaces.list.getSnapshot().archivedSessionIds
|
|
95
|
+
const sessions = this.sessions.list.getSnapshot()
|
|
96
|
+
for (const id of sessions.ids) {
|
|
97
|
+
const summary = sessions.byId[id]
|
|
98
|
+
if (
|
|
99
|
+
summary !== undefined &&
|
|
100
|
+
summary.blank &&
|
|
101
|
+
summary.cwd === workspace.path &&
|
|
102
|
+
workspace.sessionIds.includes(summary.id) &&
|
|
103
|
+
!archived.includes(summary.id)
|
|
104
|
+
) {
|
|
105
|
+
return summary.id
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const attempt = this.sessions.create({ workspaceId }).finally(() => {
|
|
110
|
+
this.connecting.delete(workspaceId)
|
|
111
|
+
})
|
|
112
|
+
this.connecting.set(workspaceId, attempt)
|
|
113
|
+
return attempt
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Начать работу: в указанной папке, иначе в текущей, иначе в последней.
|
|
118
|
+
*
|
|
119
|
+
* Если папок нет вовсе, просто снимаем выбор — пользователь окажется на
|
|
120
|
+
* пустом экране беседы, где ему предложат создать папку.
|
|
121
|
+
*/
|
|
122
|
+
startSession(workspaceId) {
|
|
123
|
+
const workspace = this.workspaces.list.getSnapshot()
|
|
124
|
+
const sessions = this.sessions.list.getSnapshot()
|
|
125
|
+
const current = sessions.current
|
|
126
|
+
const currentWorkspaceId =
|
|
127
|
+
current === undefined
|
|
128
|
+
? undefined
|
|
129
|
+
: workspace.items.find((item) => item.sessionIds.includes(current))?.workspaceId
|
|
130
|
+
const recent =
|
|
131
|
+
workspace.phase === 'ready' && sessions.phase === 'ready'
|
|
132
|
+
? recentWorkspace(workspace.items, sessions.byId)
|
|
133
|
+
: undefined
|
|
134
|
+
|
|
135
|
+
const target = workspaceId ?? currentWorkspaceId ?? recent
|
|
136
|
+
if (target === undefined) {
|
|
137
|
+
this.sessions.clear()
|
|
138
|
+
return
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
this.connectWorkspace(target).then(
|
|
142
|
+
(sessionId) => {
|
|
143
|
+
this.sessions.open(sessionId)
|
|
144
|
+
},
|
|
145
|
+
(reason) => {
|
|
146
|
+
console.warn('[dsh-session-control] не удалось начать сессию:', reason)
|
|
147
|
+
},
|
|
148
|
+
)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async archiveSession(sessionId) {
|
|
152
|
+
await this.workspaces.archiveSession(sessionId)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async pickDirectory() {
|
|
156
|
+
const result = await this.directoryPicker.pick()
|
|
157
|
+
if (!result.ok) throw new Error(`directory picker failed: ${result.error.message}`)
|
|
158
|
+
return result.value
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async listDirectory(path, signal) {
|
|
162
|
+
const result = await this.directoryPicker.list(path, signal)
|
|
163
|
+
if (!result.ok) throw new DirectoryBrowseError(result.error)
|
|
164
|
+
return result.value
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async createDirectory(path, name) {
|
|
168
|
+
const result = await this.directoryPicker.createDirectory(path, name)
|
|
169
|
+
if (!result.ok) throw new DirectoryBrowseError(result.error)
|
|
170
|
+
return result.value
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Один раз за загрузку открыть последнюю папку и всегда снимать выбор с
|
|
175
|
+
* заархивированной сессии.
|
|
176
|
+
*
|
|
177
|
+
* Начальный выбор делается ровно однажды: `initial` не даёт повторить его
|
|
178
|
+
* после того, как пользователь сам куда-то перешёл. При неудаче
|
|
179
|
+
* возвращаемся в `waiting`, чтобы следующая перерисовка попробовала снова.
|
|
180
|
+
*/
|
|
181
|
+
watchNavigation() {
|
|
182
|
+
let initial = 'waiting'
|
|
183
|
+
let disposed = false
|
|
184
|
+
|
|
185
|
+
const reconcile = () => {
|
|
186
|
+
if (disposed) return
|
|
187
|
+
if (this.clearArchivedCurrent()) return
|
|
188
|
+
if (initial !== 'waiting') return
|
|
189
|
+
|
|
190
|
+
const workspace = this.workspaces.list.getSnapshot()
|
|
191
|
+
const sessions = this.sessions.list.getSnapshot()
|
|
192
|
+
if (workspace.phase !== 'ready' || sessions.phase !== 'ready') return
|
|
193
|
+
if (sessions.current !== undefined) {
|
|
194
|
+
initial = 'done'
|
|
195
|
+
return
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const target = recentWorkspace(workspace.items, sessions.byId)
|
|
199
|
+
if (target === undefined) {
|
|
200
|
+
initial = 'done'
|
|
201
|
+
return
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
initial = 'connecting'
|
|
205
|
+
this.connectWorkspace(target).then(
|
|
206
|
+
(sessionId) => {
|
|
207
|
+
if (disposed) return
|
|
208
|
+
if (this.sessions.list.getSnapshot().current === undefined) {
|
|
209
|
+
this.sessions.open(sessionId)
|
|
210
|
+
}
|
|
211
|
+
initial = 'done'
|
|
212
|
+
},
|
|
213
|
+
(reason) => {
|
|
214
|
+
if (disposed) return
|
|
215
|
+
initial = 'waiting'
|
|
216
|
+
console.warn('[dsh-session-control] не удалось открыть последнюю папку:', reason)
|
|
217
|
+
},
|
|
218
|
+
)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const disposeWorkspaces = this.workspaces.list.subscribe(reconcile)
|
|
222
|
+
const disposeSessions = this.sessions.list.subscribe(reconcile)
|
|
223
|
+
reconcile()
|
|
224
|
+
|
|
225
|
+
return () => {
|
|
226
|
+
disposed = true
|
|
227
|
+
disposeSessions()
|
|
228
|
+
disposeWorkspaces()
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* @returns true, если выбор пришлось снять с заархивированной сессии.
|
|
234
|
+
*/
|
|
235
|
+
clearArchivedCurrent() {
|
|
236
|
+
const current = this.sessions.list.getSnapshot().current
|
|
237
|
+
if (
|
|
238
|
+
current === undefined ||
|
|
239
|
+
!this.workspaces.list.getSnapshot().archivedSessionIds.includes(current)
|
|
240
|
+
) {
|
|
241
|
+
return false
|
|
242
|
+
}
|
|
243
|
+
this.sessions.clear()
|
|
244
|
+
return true
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// ================================================================
|
|
249
|
+
// Половина «ИНТЕРФЕЙС»
|
|
250
|
+
//
|
|
251
|
+
// Всё ниже необязательно для жизни приложения: если здесь что-то
|
|
252
|
+
// сломается, слот sidebar.workspaces останется пустым, но панель,
|
|
253
|
+
// настройки и беседа продолжат работать за счёт половины «сервис».
|
|
254
|
+
// Поэтому регистрация идёт в отдельном дочернем контексте и целиком
|
|
255
|
+
// в try/catch.
|
|
256
|
+
// ================================================================
|
|
257
|
+
|
|
258
|
+
const React = require('react')
|
|
259
|
+
const h = React.createElement
|
|
260
|
+
|
|
261
|
+
/** Значок раскрытия берём у ядра: свой треугольник выдаёт самоделку. */
|
|
262
|
+
let ChevronIcon = null
|
|
263
|
+
let SearchIcon = null
|
|
264
|
+
let AddIcon = null
|
|
265
|
+
try {
|
|
266
|
+
const primitives = require('@deepseek-ai/dsh-client-ui-primitives')
|
|
267
|
+
ChevronIcon = primitives && primitives.IconChevronDownOutline14
|
|
268
|
+
SearchIcon = primitives && primitives.IconSearchOutline16
|
|
269
|
+
AddIcon = primitives && primitives.IconProjectAddOutline16
|
|
270
|
+
} catch (noPrimitives) {
|
|
271
|
+
// В урезанной сборке набора может не быть: незащищённый require уронил
|
|
272
|
+
// бы всю клиентскую половину.
|
|
273
|
+
ChevronIcon = null
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function FallbackChevron(props) {
|
|
277
|
+
return h(
|
|
278
|
+
'svg',
|
|
279
|
+
{ className: props.className, width: 14, height: 14, viewBox: '0 0 14 14', 'aria-hidden': 'true' },
|
|
280
|
+
h('path', {
|
|
281
|
+
d: 'M3.5 5.5 7 9l3.5-3.5',
|
|
282
|
+
fill: 'none',
|
|
283
|
+
stroke: 'currentColor',
|
|
284
|
+
strokeWidth: 1.5,
|
|
285
|
+
strokeLinecap: 'round',
|
|
286
|
+
strokeLinejoin: 'round',
|
|
287
|
+
}),
|
|
288
|
+
)
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function FallbackSearch(props) {
|
|
292
|
+
return h('svg', { className: props.className, width: 16, height: 16, viewBox: '0 0 16 16', 'aria-hidden': 'true' },
|
|
293
|
+
h('circle', { cx: 7, cy: 7, r: 4.25, fill: 'none', stroke: 'currentColor', strokeWidth: 1.5 }),
|
|
294
|
+
h('path', { d: 'm10.4 10.4 3 3', stroke: 'currentColor', strokeWidth: 1.5, strokeLinecap: 'round' }))
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function FallbackAdd(props) {
|
|
298
|
+
return h('svg', { className: props.className, width: 16, height: 16, viewBox: '0 0 16 16', 'aria-hidden': 'true' },
|
|
299
|
+
h('path', { d: 'M8 3.5v9M3.5 8h9', fill: 'none', stroke: 'currentColor', strokeWidth: 1.5, strokeLinecap: 'round' }))
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const Chevron = ChevronIcon || FallbackChevron
|
|
303
|
+
const Search = SearchIcon || FallbackSearch
|
|
304
|
+
const Add = AddIcon || FallbackAdd
|
|
305
|
+
|
|
306
|
+
const STYLE_ID = 'dsc-styles'
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Стили вносим один раз на документ.
|
|
310
|
+
*
|
|
311
|
+
* Горизонтальный отступ берём из ядровой переменной, которую задаёт
|
|
312
|
+
* боковая панель: только так наш блок совпадает по вертикали с кнопкой
|
|
313
|
+
* «Новая сессия» над ним. Своё число здесь означало бы рассинхрон при
|
|
314
|
+
* любой правке ядра.
|
|
315
|
+
*/
|
|
316
|
+
function ensureStyles() {
|
|
317
|
+
if (document.getElementById(STYLE_ID)) return
|
|
318
|
+
const style = document.createElement('style')
|
|
319
|
+
style.id = STYLE_ID
|
|
320
|
+
style.textContent = `
|
|
321
|
+
.dsc-root { display:flex; flex-direction:column; min-height:0; flex:1;
|
|
322
|
+
padding-inline: var(--dsh-sidebar-inline-padding, 12px); gap:2px }
|
|
323
|
+
.dsc-head { display:flex; align-items:center; gap:6px; height:28px;
|
|
324
|
+
color:var(--dsw-alias-label-secondary); font-size:12px }
|
|
325
|
+
.dsc-head-title { flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap }
|
|
326
|
+
.dsc-icon-btn { appearance:none; background:0 0; border:0; cursor:pointer; padding:4px;
|
|
327
|
+
border-radius:6px; color:var(--dsw-alias-label-tertiary); display:inline-flex;
|
|
328
|
+
align-items:center; justify-content:center; transition:background-color .12s var(--ds-ease-in-out, ease) }
|
|
329
|
+
.dsc-icon-btn:hover { background:var(--dsw-alias-interactive-bg-hover) }
|
|
330
|
+
.dsc-icon-btn:focus-visible { outline:2px solid var(--dsw-alias-state-business-primary); outline-offset:1px }
|
|
331
|
+
.dsc-search { width:100%; height:28px; box-sizing:border-box; margin:2px 0 6px;
|
|
332
|
+
border:1px solid var(--dsw-alias-border-l2); border-radius:8px; padding:0 8px;
|
|
333
|
+
font:inherit; font-size:13px; background:0 0; color:var(--dsw-alias-label-primary) }
|
|
334
|
+
.dsc-list { flex:1; min-height:0; overflow-y:auto; overflow-x:hidden;
|
|
335
|
+
scrollbar-color: var(--dsw-alias-scrollbar-bg-l2) transparent }
|
|
336
|
+
.dsc-section { margin-top:4px }
|
|
337
|
+
.dsc-section-head { appearance:none; width:100%; font:inherit; color:var(--dsw-alias-label-secondary);
|
|
338
|
+
text-align:left; cursor:pointer; background:0 0; border:0; border-radius:6px;
|
|
339
|
+
display:flex; align-items:center; gap:6px; padding:4px 6px; font-size:12px }
|
|
340
|
+
.dsc-section-head:hover { background:var(--dsw-alias-interactive-bg-hover) }
|
|
341
|
+
.dsc-section-head:focus-visible { outline:2px solid var(--dsw-alias-state-business-primary); outline-offset:-2px }
|
|
342
|
+
.dsc-section-name { flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap }
|
|
343
|
+
.dsc-count { color:var(--dsw-alias-label-tertiary); font-size:11px; flex:none }
|
|
344
|
+
.dsc-chev { flex:none; color:var(--dsw-alias-label-tertiary); transition:transform .16s var(--ds-ease-in-out, ease) }
|
|
345
|
+
.dsc-chev-collapsed { transform:rotate(-90deg) }
|
|
346
|
+
.dsc-row { display:flex; align-items:center; gap:6px; width:100%; box-sizing:border-box;
|
|
347
|
+
padding:5px 6px; border-radius:6px; cursor:pointer; border:0; background:0 0;
|
|
348
|
+
font:inherit; text-align:left; color:var(--dsw-alias-label-primary);
|
|
349
|
+
transition:background-color .12s var(--ds-ease-in-out, ease) }
|
|
350
|
+
.dsc-row:hover { background:var(--dsw-alias-interactive-bg-hover) }
|
|
351
|
+
.dsc-row:focus-visible { outline:2px solid var(--dsw-alias-state-business-primary); outline-offset:-2px }
|
|
352
|
+
.dsc-row-current { background:var(--dsw-alias-interactive-bg-hover) }
|
|
353
|
+
.dsc-row-title { flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:13px }
|
|
354
|
+
.dsc-row-age { flex:none; color:var(--dsw-alias-label-secondary); font-size:11px }
|
|
355
|
+
.dsc-dot { flex:none; width:6px; height:6px; border-radius:50%;
|
|
356
|
+
background:var(--dsw-alias-state-business-primary) }
|
|
357
|
+
.dsc-pin { flex:none; color:var(--dsw-alias-label-tertiary); font-size:11px }
|
|
358
|
+
.dsc-more { flex:none; opacity:0; padding:2px 4px; border-radius:4px; border:0; background:0 0;
|
|
359
|
+
color:var(--dsw-alias-label-tertiary); cursor:pointer; font:inherit }
|
|
360
|
+
.dsc-row:hover .dsc-more, .dsc-more:focus-visible { opacity:1 }
|
|
361
|
+
.dsc-check { flex:none; margin:0 2px 0 0; opacity:0; cursor:pointer }
|
|
362
|
+
.dsc-row:hover .dsc-check, .dsc-check:focus-visible, .dsc-check-on { opacity:1 }
|
|
363
|
+
.dsc-bulk { display:flex; align-items:center; gap:8px; flex-wrap:wrap;
|
|
364
|
+
border-top:1px solid var(--dsw-alias-border-l2); padding:8px 6px 4px;
|
|
365
|
+
color:var(--dsw-alias-label-secondary); font-size:12px }
|
|
366
|
+
.dsc-bulk-count { flex:none; color:var(--dsw-alias-label-primary) }
|
|
367
|
+
.dsc-bulk-act { appearance:none; font:inherit; font-size:12px; cursor:pointer;
|
|
368
|
+
border:1px solid var(--dsw-alias-border-l2); border-radius:6px; padding:3px 9px;
|
|
369
|
+
background:0 0; color:var(--dsw-alias-label-primary) }
|
|
370
|
+
.dsc-bulk-act:hover { background:var(--dsw-alias-interactive-bg-hover) }
|
|
371
|
+
.dsc-row-lines { flex:1; min-width:0; display:flex; flex-direction:column; gap:1px }
|
|
372
|
+
.dsc-row-snippet { color:var(--dsw-alias-label-caption, var(--dsw-alias-label-tertiary));
|
|
373
|
+
font-size:11px; line-height:1.35; overflow:hidden; text-overflow:ellipsis; white-space:nowrap }
|
|
374
|
+
.dsc-rename { flex:1; min-width:0; height:22px; box-sizing:border-box; font:inherit; font-size:13px;
|
|
375
|
+
border:1px solid var(--dsw-alias-state-business-primary); border-radius:4px; padding:0 4px;
|
|
376
|
+
background:0 0; color:var(--dsw-alias-label-primary) }
|
|
377
|
+
.dsc-menu { position:fixed; z-index:60; min-width:190px; padding:4px;
|
|
378
|
+
border:1px solid var(--dsw-alias-border-l2); border-radius:10px;
|
|
379
|
+
background:var(--dsw-alias-bg-layer-3); box-shadow:0 8px 24px rgba(0,0,0,.28) }
|
|
380
|
+
.dsc-menu-item { appearance:none; display:block; width:100%; font:inherit; font-size:13px;
|
|
381
|
+
text-align:left; border:0; background:0 0; cursor:pointer; padding:6px 10px; border-radius:6px;
|
|
382
|
+
color:var(--dsw-alias-label-primary) }
|
|
383
|
+
.dsc-menu-item:hover { background:var(--dsw-alias-interactive-bg-hover) }
|
|
384
|
+
.dsc-menu-item:disabled { color:var(--dsw-alias-label-tertiary); cursor:default; background:0 0 }
|
|
385
|
+
.dsc-menu-sep { height:1px; margin:4px 6px; background:var(--dsw-alias-border-l2) }
|
|
386
|
+
.dsc-menu-danger { color:var(--dsw-alias-state-error-primary) }
|
|
387
|
+
.dsc-empty { padding:10px 6px; color:var(--dsw-alias-label-caption, var(--dsw-alias-label-tertiary));
|
|
388
|
+
font-size:12px; line-height:1.5 }
|
|
389
|
+
.dsc-error { padding:4px 6px; color:var(--dsw-alias-state-error-primary); font-size:12px }
|
|
390
|
+
.dsc-skeleton { height:26px; margin:3px 6px; border-radius:6px;
|
|
391
|
+
background:var(--dsw-alias-interactive-bg-hover); opacity:.5 }
|
|
392
|
+
.dsc-viewer { position:fixed; inset:0; z-index:80; display:flex; align-items:center;
|
|
393
|
+
justify-content:center; background:rgba(0,0,0,.45) }
|
|
394
|
+
.dsc-viewer-box { display:flex; flex-direction:column; width:min(860px,92vw); height:min(80vh,900px);
|
|
395
|
+
border:1px solid var(--dsw-alias-border-l2); border-radius:12px;
|
|
396
|
+
background:var(--dsw-alias-bg-layer-3); overflow:hidden }
|
|
397
|
+
.dsc-viewer-head { display:flex; align-items:center; gap:12px; padding:14px 16px;
|
|
398
|
+
border-bottom:1px solid var(--dsw-alias-border-l2) }
|
|
399
|
+
.dsc-viewer-title { flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
|
|
400
|
+
color:var(--dsw-alias-label-primary); font-size:15px; font-weight:600 }
|
|
401
|
+
.dsc-viewer-note { color:var(--dsw-alias-label-secondary); font-size:12px; flex:none }
|
|
402
|
+
.dsc-viewer-body { flex:1; min-height:0; overflow-y:auto; padding:12px 16px }
|
|
403
|
+
.dsc-msg { padding:10px 0; border-bottom:1px solid var(--dsw-alias-border-l2) }
|
|
404
|
+
.dsc-msg:last-child { border-bottom:0 }
|
|
405
|
+
.dsc-msg-role { color:var(--dsw-alias-label-secondary); font-size:11px;
|
|
406
|
+
text-transform:uppercase; letter-spacing:.04em; margin-bottom:4px }
|
|
407
|
+
.dsc-msg-user .dsc-msg-role { color:var(--dsw-alias-state-business-primary) }
|
|
408
|
+
.dsc-msg-text { color:var(--dsw-alias-label-primary); font-size:13px; line-height:1.55;
|
|
409
|
+
white-space:pre-wrap; overflow-wrap:anywhere }
|
|
410
|
+
.dsc-msg-tool { color:var(--dsw-alias-label-tertiary); font-size:12px;
|
|
411
|
+
font-family:var(--ds-font-family-code, monospace) }
|
|
412
|
+
.dsc-viewer-cut { color:var(--dsw-alias-label-caption, var(--dsw-alias-label-tertiary));
|
|
413
|
+
font-size:12px; padding:8px 0 12px }
|
|
414
|
+
.dsc-card { border:1px solid var(--dsw-alias-border-l2);
|
|
415
|
+
background:var(--dsw-alias-bg-layer-3); border-radius:12px; list-style:none }
|
|
416
|
+
.dsc-card-head { appearance:none; width:100%; font:inherit; color:inherit; text-align:left;
|
|
417
|
+
cursor:pointer; background:0 0; border:0; border-radius:12px; display:flex;
|
|
418
|
+
align-items:center; gap:12px; padding:14px 16px }
|
|
419
|
+
.dsc-card-head:focus-visible { outline:2px solid var(--dsw-alias-state-business-primary); outline-offset:-2px }
|
|
420
|
+
.dsc-card-text { flex:1; min-width:0; display:flex; flex-direction:column; gap:2px }
|
|
421
|
+
.dsc-card-title { color:var(--dsw-alias-label-primary); font-size:15px; font-weight:600; line-height:1.4 }
|
|
422
|
+
.dsc-card-sub { color:var(--dsw-alias-label-secondary); font-size:13px }
|
|
423
|
+
.dsc-card-chev { margin-left:auto; flex:none; color:var(--dsw-alias-label-tertiary);
|
|
424
|
+
transition:transform .16s var(--ds-ease-in-out, ease) }
|
|
425
|
+
.dsc-card-chev-open { transform:rotate(180deg) }
|
|
426
|
+
.dsc-card-body { border-top:1px solid var(--dsw-alias-border-l2); margin:0 16px; padding:12px 0 }
|
|
427
|
+
.dsc-card-stat { color:var(--dsw-alias-label-secondary); font-size:13px }
|
|
428
|
+
.dsc-card-toggle { display:flex; align-items:center; gap:8px; margin-top:10px;
|
|
429
|
+
color:var(--dsw-alias-label-primary); font-size:13px; cursor:pointer }
|
|
430
|
+
.dsc-card-action { appearance:none; font:inherit; font-size:13px; cursor:pointer; margin-top:10px;
|
|
431
|
+
border:1px solid var(--dsw-alias-border-l2); border-radius:8px; padding:5px 12px;
|
|
432
|
+
background:0 0; color:var(--dsw-alias-label-primary) }
|
|
433
|
+
.dsc-card-action:disabled { color:var(--dsw-alias-label-tertiary); cursor:default }
|
|
434
|
+
.dsc-rail { display:flex; flex-direction:column; align-items:center; gap:6px; padding-top:4px }
|
|
435
|
+
.dsc-rail-btn { width:36px; height:36px; border-radius:8px; border:0; background:0 0; cursor:pointer;
|
|
436
|
+
color:var(--dsw-alias-label-tertiary); display:flex; align-items:center; justify-content:center }
|
|
437
|
+
.dsc-rail-btn:hover { background:var(--dsw-alias-interactive-bg-hover) }
|
|
438
|
+
`
|
|
439
|
+
document.head.appendChild(style)
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/** Возраст строки: те же ступени, что показывает ядро. */
|
|
443
|
+
function formatAge(t, updatedAt) {
|
|
444
|
+
const diff = Date.now() - updatedAt
|
|
445
|
+
if (!Number.isFinite(diff) || diff < 0) return ''
|
|
446
|
+
const min = Math.floor(diff / 60000)
|
|
447
|
+
if (min < 1) return t('age.now')
|
|
448
|
+
if (min < 60) return t('age.min', { n: min })
|
|
449
|
+
const hours = Math.floor(min / 60)
|
|
450
|
+
if (hours < 24) return t('age.hour', { n: hours })
|
|
451
|
+
return t('age.day', { n: Math.floor(hours / 24) })
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Период, к которому относится строка.
|
|
456
|
+
*
|
|
457
|
+
* Границы календарные, а не «минус столько-то часов»: человек мыслит
|
|
458
|
+
* «сегодня» и «на этой неделе», а не сутками от текущего момента.
|
|
459
|
+
*
|
|
460
|
+
* @param updatedAt - время последнего события строки.
|
|
461
|
+
* @param now - текущее время.
|
|
462
|
+
* @returns ключ периода.
|
|
463
|
+
*/
|
|
464
|
+
function periodOf(updatedAt, now) {
|
|
465
|
+
const day = new Date(now); day.setHours(0, 0, 0, 0)
|
|
466
|
+
const startOfToday = day.getTime()
|
|
467
|
+
if (updatedAt >= startOfToday) return 'today'
|
|
468
|
+
if (updatedAt >= startOfToday - 6 * 86400000) return 'week'
|
|
469
|
+
if (updatedAt >= startOfToday - 29 * 86400000) return 'month'
|
|
470
|
+
return 'older'
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/** Периоды в порядке показа. */
|
|
474
|
+
const PERIODS = ['today', 'week', 'month', 'older']
|
|
475
|
+
|
|
476
|
+
/** Подписка на пространство настроек. Статус важнее значения. */
|
|
477
|
+
function useSettings(scope) {
|
|
478
|
+
const snapshot = React.useSyncExternalStore(
|
|
479
|
+
React.useMemo(() => (cb) => (scope ? scope.subscribe(cb) : () => {}), [scope]),
|
|
480
|
+
React.useCallback(() => (scope ? scope.getSnapshot() : { status: 'loading' }), [scope]),
|
|
481
|
+
React.useCallback(() => ({ status: 'loading' }), []),
|
|
482
|
+
)
|
|
483
|
+
const status = (snapshot && snapshot.status) || 'loading'
|
|
484
|
+
const value = (snapshot && snapshot.value) || {}
|
|
485
|
+
return {
|
|
486
|
+
status,
|
|
487
|
+
pinned: Array.isArray(value.pinned) ? value.pinned : [],
|
|
488
|
+
hidden: Array.isArray(value.hidden) ? value.hidden : [],
|
|
489
|
+
// Пока настройки не пришли, прячем: так список сразу выглядит как
|
|
490
|
+
// после загрузки, без скачка от мусора к чистому виду.
|
|
491
|
+
hideBlank: value.hideBlank !== false,
|
|
492
|
+
writable: status === 'ready',
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* Свёрнутость разделов — видовое состояние одного браузера, а не общая
|
|
498
|
+
* настройка: держим её локально и не гоняем через хост.
|
|
499
|
+
*/
|
|
500
|
+
function useCollapsed() {
|
|
501
|
+
const [state, setState] = React.useState(() => {
|
|
502
|
+
try {
|
|
503
|
+
return JSON.parse(window.localStorage.getItem('dsc.collapsed') || '{}') || {}
|
|
504
|
+
} catch (noStorage) {
|
|
505
|
+
return {}
|
|
506
|
+
}
|
|
507
|
+
})
|
|
508
|
+
// У разделов разные умолчания: папки развёрнуты, «Скрытые» и «Архив»
|
|
509
|
+
// свёрнуты. Поэтому и чтение, и переключение обязаны знать умолчание
|
|
510
|
+
// раздела: иначе первое нажатие пишет то же значение, которое уже
|
|
511
|
+
// подразумевалось, и раздел открывается только со второго раза.
|
|
512
|
+
const isCollapsed = React.useCallback(
|
|
513
|
+
(key, byDefault) => (state[key] === undefined ? byDefault : state[key] === true),
|
|
514
|
+
[state],
|
|
515
|
+
)
|
|
516
|
+
const toggle = React.useCallback((key, byDefault) => {
|
|
517
|
+
setState((prev) => {
|
|
518
|
+
const current = prev[key] === undefined ? byDefault : prev[key] === true
|
|
519
|
+
const next = Object.assign({}, prev, { [key]: !current })
|
|
520
|
+
try { window.localStorage.setItem('dsc.collapsed', JSON.stringify(next)) } catch (noStorage) {}
|
|
521
|
+
return next
|
|
522
|
+
})
|
|
523
|
+
}, [])
|
|
524
|
+
return [isCollapsed, toggle]
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/** Контекстное меню строки. Закрывается по Escape и щелчку мимо. */
|
|
528
|
+
function RowMenu({ at, items, onClose }) {
|
|
529
|
+
const ref = React.useRef(null)
|
|
530
|
+
React.useEffect(() => {
|
|
531
|
+
const onKey = (e) => { if (e.key === 'Escape') onClose() }
|
|
532
|
+
const onDown = (e) => {
|
|
533
|
+
if (ref.current && !ref.current.contains(e.target)) onClose()
|
|
534
|
+
}
|
|
535
|
+
document.addEventListener('keydown', onKey)
|
|
536
|
+
document.addEventListener('mousedown', onDown)
|
|
537
|
+
return () => {
|
|
538
|
+
document.removeEventListener('keydown', onKey)
|
|
539
|
+
document.removeEventListener('mousedown', onDown)
|
|
540
|
+
}
|
|
541
|
+
}, [onClose])
|
|
542
|
+
|
|
543
|
+
return h(
|
|
544
|
+
'div',
|
|
545
|
+
{ className: 'dsc-menu', style: { left: at.x + 'px', top: at.y + 'px' }, ref, role: 'menu' },
|
|
546
|
+
items.map((item, i) =>
|
|
547
|
+
item.separator
|
|
548
|
+
? h('div', { key: 'sep' + i, className: 'dsc-menu-sep' })
|
|
549
|
+
: h(
|
|
550
|
+
'button',
|
|
551
|
+
{
|
|
552
|
+
key: item.key,
|
|
553
|
+
type: 'button',
|
|
554
|
+
role: 'menuitem',
|
|
555
|
+
disabled: item.disabled === true,
|
|
556
|
+
className: 'dsc-menu-item' + (item.danger ? ' dsc-menu-danger' : ''),
|
|
557
|
+
onClick: () => { onClose(); item.run() },
|
|
558
|
+
},
|
|
559
|
+
item.label,
|
|
560
|
+
),
|
|
561
|
+
),
|
|
562
|
+
)
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/** Одна строка сессии. */
|
|
566
|
+
function SessionRow({ node, current, pinned, snippet, selected, selectable, onSelect, t, onOpen, onMenu, renaming, onRenameCommit, onRenameCancel }) {
|
|
567
|
+
const inputRef = React.useRef(null)
|
|
568
|
+
React.useEffect(() => {
|
|
569
|
+
if (renaming && inputRef.current) {
|
|
570
|
+
inputRef.current.focus()
|
|
571
|
+
inputRef.current.select()
|
|
572
|
+
}
|
|
573
|
+
}, [renaming])
|
|
574
|
+
|
|
575
|
+
const title = node.blank ? t('row.blank') : (node.title || t('row.untitled'))
|
|
576
|
+
|
|
577
|
+
if (renaming) {
|
|
578
|
+
return h(
|
|
579
|
+
'div',
|
|
580
|
+
{ className: 'dsc-row' },
|
|
581
|
+
h('input', {
|
|
582
|
+
ref: inputRef,
|
|
583
|
+
className: 'dsc-rename',
|
|
584
|
+
defaultValue: title,
|
|
585
|
+
aria: undefined,
|
|
586
|
+
'aria-label': t('menu.rename'),
|
|
587
|
+
onKeyDown: (e) => {
|
|
588
|
+
if (e.key === 'Enter') onRenameCommit(e.currentTarget.value)
|
|
589
|
+
if (e.key === 'Escape') onRenameCancel()
|
|
590
|
+
},
|
|
591
|
+
onBlur: (e) => onRenameCommit(e.currentTarget.value),
|
|
592
|
+
}),
|
|
593
|
+
)
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
return h(
|
|
597
|
+
'div',
|
|
598
|
+
{
|
|
599
|
+
className: 'dsc-row' + (current ? ' dsc-row-current' : ''),
|
|
600
|
+
role: 'button',
|
|
601
|
+
tabIndex: 0,
|
|
602
|
+
onClick: () => onOpen(node.id),
|
|
603
|
+
onDoubleClick: (e) => { e.preventDefault(); onMenu(null, node, 'rename') },
|
|
604
|
+
onKeyDown: (e) => {
|
|
605
|
+
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onOpen(node.id) }
|
|
606
|
+
if (e.key === 'F2') { e.preventDefault(); onMenu(null, node, 'rename') }
|
|
607
|
+
},
|
|
608
|
+
onContextMenu: (e) => { e.preventDefault(); onMenu({ x: e.clientX, y: e.clientY }, node) },
|
|
609
|
+
},
|
|
610
|
+
selectable
|
|
611
|
+
? h('input', {
|
|
612
|
+
type: 'checkbox',
|
|
613
|
+
className: 'dsc-check' + (selected ? ' dsc-check-on' : ''),
|
|
614
|
+
checked: selected === true,
|
|
615
|
+
'aria-label': t('bulk.select'),
|
|
616
|
+
// Клик по галочке не должен открывать сессию: выбор и открытие —
|
|
617
|
+
// разные намерения.
|
|
618
|
+
onClick: (e) => { e.stopPropagation(); onSelect(node.id, e.shiftKey) },
|
|
619
|
+
onChange: () => {},
|
|
620
|
+
})
|
|
621
|
+
: null,
|
|
622
|
+
pinned ? h('span', { className: 'dsc-pin', title: t('menu.unpin'), 'aria-hidden': 'true' }, '•') : null,
|
|
623
|
+
h('span', { className: 'dsc-row-lines' },
|
|
624
|
+
h('span', { className: 'dsc-row-title' }, title),
|
|
625
|
+
// Сниппет показываем только во время поиска: он объясняет, ЧЕМ
|
|
626
|
+
// строка нашлась, и вне поиска объяснять нечего.
|
|
627
|
+
snippet ? h('span', { className: 'dsc-row-snippet', title: snippet }, snippet) : null),
|
|
628
|
+
node.running
|
|
629
|
+
? h('span', { className: 'dsc-dot', role: 'img', 'aria-label': t('row.running') })
|
|
630
|
+
: h('span', { className: 'dsc-row-age' }, formatAge(t, node.updatedAt)),
|
|
631
|
+
h(
|
|
632
|
+
'button',
|
|
633
|
+
{
|
|
634
|
+
type: 'button',
|
|
635
|
+
className: 'dsc-more',
|
|
636
|
+
'aria-label': t('row.menu'),
|
|
637
|
+
onClick: (e) => {
|
|
638
|
+
e.stopPropagation()
|
|
639
|
+
const r = e.currentTarget.getBoundingClientRect()
|
|
640
|
+
onMenu({ x: Math.round(r.left - 150), y: Math.round(r.bottom + 4) }, node)
|
|
641
|
+
},
|
|
642
|
+
},
|
|
643
|
+
'···',
|
|
644
|
+
),
|
|
645
|
+
)
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/** Свёртываемый раздел с заголовком-кнопкой. */
|
|
649
|
+
function Section({ id, name, count, collapsed, defaultCollapsed, onToggle, children }) {
|
|
650
|
+
return h(
|
|
651
|
+
'div',
|
|
652
|
+
{ className: 'dsc-section' },
|
|
653
|
+
h(
|
|
654
|
+
'button',
|
|
655
|
+
{
|
|
656
|
+
type: 'button',
|
|
657
|
+
className: 'dsc-section-head',
|
|
658
|
+
'aria-expanded': collapsed ? 'false' : 'true',
|
|
659
|
+
onClick: () => onToggle(id, defaultCollapsed === true),
|
|
660
|
+
},
|
|
661
|
+
h(Chevron, { className: 'dsc-chev' + (collapsed ? ' dsc-chev-collapsed' : '') }),
|
|
662
|
+
h('span', { className: 'dsc-section-name' }, name),
|
|
663
|
+
h('span', { className: 'dsc-count' }, String(count)),
|
|
664
|
+
),
|
|
665
|
+
collapsed ? null : children,
|
|
666
|
+
)
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/**
|
|
670
|
+
* Расшифровка заархивированной сессии, только на чтение.
|
|
671
|
+
*
|
|
672
|
+
* Читаем не через клиентский менеджер сессий, а своим маршрутом. Причина
|
|
673
|
+
* в устройстве клиента: окно истории поднимается только для сессии «на
|
|
674
|
+
* сцене», то есть текущей, а заархивированная текущей быть не может.
|
|
675
|
+
* Серверная половина берёт лог штатным `sessionPersistence.readRaw`, не
|
|
676
|
+
* поднимая живую сессию.
|
|
677
|
+
*/
|
|
678
|
+
function ArchiveViewer({ sessionId, title, t, onClose }) {
|
|
679
|
+
const [state, setState] = React.useState({ phase: 'loading', messages: [], truncated: false, total: 0 })
|
|
680
|
+
|
|
681
|
+
React.useEffect(() => {
|
|
682
|
+
let dropped = false
|
|
683
|
+
const controller = new AbortController()
|
|
684
|
+
fetch('/dsh-session-control/transcript?session=' + encodeURIComponent(sessionId),
|
|
685
|
+
{ signal: controller.signal })
|
|
686
|
+
.then(async (response) => {
|
|
687
|
+
const body = await response.json().catch(() => ({}))
|
|
688
|
+
if (dropped) return
|
|
689
|
+
if (!response.ok || body.ok !== true) {
|
|
690
|
+
setState({ phase: 'failed', error: body.error || ('HTTP ' + response.status),
|
|
691
|
+
messages: [], truncated: false, total: 0 })
|
|
692
|
+
return
|
|
693
|
+
}
|
|
694
|
+
setState({ phase: body.unreadable === true ? 'unreadable' : 'ready',
|
|
695
|
+
messages: body.messages || [],
|
|
696
|
+
truncated: body.truncated === true, total: body.total || 0 })
|
|
697
|
+
})
|
|
698
|
+
.catch((failure) => {
|
|
699
|
+
if (dropped || (failure && failure.name === 'AbortError')) return
|
|
700
|
+
setState({ phase: 'failed', error: (failure && failure.message) || String(failure),
|
|
701
|
+
messages: [], truncated: false, total: 0 })
|
|
702
|
+
})
|
|
703
|
+
return () => { dropped = true; controller.abort() }
|
|
704
|
+
}, [sessionId])
|
|
705
|
+
|
|
706
|
+
React.useEffect(() => {
|
|
707
|
+
const onKey = (e) => { if (e.key === 'Escape') onClose() }
|
|
708
|
+
document.addEventListener('keydown', onKey)
|
|
709
|
+
return () => document.removeEventListener('keydown', onKey)
|
|
710
|
+
}, [onClose])
|
|
711
|
+
|
|
712
|
+
let body
|
|
713
|
+
if (state.phase === 'loading') {
|
|
714
|
+
body = h('div', { className: 'dsc-empty' }, t('viewer.loading'))
|
|
715
|
+
} else if (state.phase === 'failed') {
|
|
716
|
+
body = h('div', { className: 'dsc-error' }, t('viewer.failed', { message: state.error }))
|
|
717
|
+
} else if (state.phase === 'unreadable') {
|
|
718
|
+
body = h('div', { className: 'dsc-empty' }, t('viewer.unreadable'))
|
|
719
|
+
} else if (state.messages.length === 0) {
|
|
720
|
+
body = h('div', { className: 'dsc-empty' }, t('viewer.empty'))
|
|
721
|
+
} else {
|
|
722
|
+
const rows = []
|
|
723
|
+
if (state.truncated) {
|
|
724
|
+
rows.push(h('div', { key: 'cut', className: 'dsc-viewer-cut' },
|
|
725
|
+
t('viewer.truncated', { shown: state.messages.length, total: state.total })))
|
|
726
|
+
}
|
|
727
|
+
state.messages.forEach((m, index) => {
|
|
728
|
+
if (m.role === 'tool') {
|
|
729
|
+
rows.push(h('div', { key: 'r' + index, className: 'dsc-msg' },
|
|
730
|
+
h('div', { className: 'dsc-msg-tool' }, t('viewer.tool', { name: m.text }))))
|
|
731
|
+
return
|
|
732
|
+
}
|
|
733
|
+
rows.push(h('div', { key: 'r' + index, className: 'dsc-msg ' + (m.role === 'user' ? 'dsc-msg-user' : '') },
|
|
734
|
+
h('div', { className: 'dsc-msg-role' }, m.role === 'user' ? t('viewer.you') : t('viewer.agent')),
|
|
735
|
+
h('div', { className: 'dsc-msg-text' }, m.text)))
|
|
736
|
+
})
|
|
737
|
+
body = rows
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
return h(
|
|
741
|
+
'div',
|
|
742
|
+
{ className: 'dsc-viewer', role: 'dialog', 'aria-modal': 'true',
|
|
743
|
+
onClick: (e) => { if (e.target === e.currentTarget) onClose() } },
|
|
744
|
+
h(
|
|
745
|
+
'div',
|
|
746
|
+
{ className: 'dsc-viewer-box' },
|
|
747
|
+
h('div', { className: 'dsc-viewer-head' },
|
|
748
|
+
h('span', { className: 'dsc-viewer-title' }, title),
|
|
749
|
+
h('span', { className: 'dsc-viewer-note' }, t('viewer.readonly')),
|
|
750
|
+
h('button', { type: 'button', className: 'dsc-icon-btn',
|
|
751
|
+
'aria-label': t('viewer.close'), onClick: onClose }, '\u2715')),
|
|
752
|
+
h('div', { className: 'dsc-viewer-body' }, body),
|
|
753
|
+
),
|
|
754
|
+
)
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
/** Список диалогов: тело боковой панели. */
|
|
758
|
+
function SessionListPanel(props) {
|
|
759
|
+
const t = props.t
|
|
760
|
+
const settings = useSettings(props.settingsScope)
|
|
761
|
+
const [isCollapsed, toggleCollapsed] = useCollapsed()
|
|
762
|
+
|
|
763
|
+
const [query, setQuery] = React.useState('')
|
|
764
|
+
const [searchOpen, setSearchOpen] = React.useState(false)
|
|
765
|
+
const [found, setFound] = React.useState(null)
|
|
766
|
+
const [searchFailed, setSearchFailed] = React.useState('')
|
|
767
|
+
const [menu, setMenu] = React.useState(null)
|
|
768
|
+
const [renamingId, setRenamingId] = React.useState(null)
|
|
769
|
+
const [error, setError] = React.useState('')
|
|
770
|
+
const [flowOpen, setFlowOpen] = React.useState(false)
|
|
771
|
+
const [flowBusy, setFlowBusy] = React.useState(false)
|
|
772
|
+
const [viewing, setViewing] = React.useState(null)
|
|
773
|
+
const [selected, setSelected] = React.useState([])
|
|
774
|
+
const lastPicked = React.useRef(null)
|
|
775
|
+
const selectedSet = React.useMemo(() => new Set(selected), [selected])
|
|
776
|
+
// Порядок для выбора диапазона: тот же, в котором строки видны.
|
|
777
|
+
// Объявляем рядом с остальным состоянием: заполняется он ниже, при
|
|
778
|
+
// сборке списков, и обращение к нему не должно опережать объявление.
|
|
779
|
+
const orderRef = React.useRef([])
|
|
780
|
+
|
|
781
|
+
React.useEffect(() => { ensureStyles() }, [])
|
|
782
|
+
|
|
783
|
+
const workspaces = props.useWorkspaces((s) => s.items)
|
|
784
|
+
const workspacePhase = props.useWorkspaces((s) => s.phase)
|
|
785
|
+
const archivedIds = props.useWorkspaces((s) => s.archivedSessionIds)
|
|
786
|
+
const sessionsById = props.useSessions((s) => s.byId)
|
|
787
|
+
const sessionPhase = props.useSessions((s) => s.phase)
|
|
788
|
+
const currentId = props.useSessions((s) => s.current)
|
|
789
|
+
const flowAvailable = props.useDirectoryFlow((occupied) => occupied)
|
|
790
|
+
|
|
791
|
+
// Ссылка на поиск живёт в ref, а эффект зависит ТОЛЬКО от строки запроса.
|
|
792
|
+
//
|
|
793
|
+
// Иначе выходит так: панель перерисовывается от любого обновления
|
|
794
|
+
// хранилища сессий, эффект перезапускается и его уборщик отменяет
|
|
795
|
+
// собственный запрос на лету. Внешне это «поиск то находит, то нет» —
|
|
796
|
+
// самый неприятный вид отказа, потому что выглядит как случайность.
|
|
797
|
+
const searchRef = React.useRef(props.searchSessions)
|
|
798
|
+
searchRef.current = props.searchSessions
|
|
799
|
+
|
|
800
|
+
// Содержательный поиск идёт на хост с задержкой: без неё каждая буква
|
|
801
|
+
// превращается в запрос.
|
|
802
|
+
React.useEffect(() => {
|
|
803
|
+
const text = query.trim()
|
|
804
|
+
if (text === '') { setFound(null); setSearchFailed(''); return undefined }
|
|
805
|
+
let cancelled = false
|
|
806
|
+
const controller = new AbortController()
|
|
807
|
+
const timer = setTimeout(() => {
|
|
808
|
+
searchRef.current(text, controller.signal).then(
|
|
809
|
+
(res) => {
|
|
810
|
+
if (cancelled) return
|
|
811
|
+
// Держим и сниппет: хост его уже посчитал, выбрасывать нечего.
|
|
812
|
+
const byId = {}
|
|
813
|
+
for (const item of res.items) byId[item.sessionId] = item.snippet || ''
|
|
814
|
+
setFound(byId)
|
|
815
|
+
setSearchFailed('')
|
|
816
|
+
},
|
|
817
|
+
(e) => {
|
|
818
|
+
// Отмену показывать не надо — она наша. Настоящий отказ показать
|
|
819
|
+
// обязаны: молчащий поиск неотличим от пустого результата.
|
|
820
|
+
if (cancelled || (e && e.name === 'AbortError')) return
|
|
821
|
+
setSearchFailed(t('error.search', { message: (e && e.message) || String(e) }))
|
|
822
|
+
},
|
|
823
|
+
)
|
|
824
|
+
}, 250)
|
|
825
|
+
return () => { cancelled = true; clearTimeout(timer); controller.abort() }
|
|
826
|
+
}, [query])
|
|
827
|
+
|
|
828
|
+
const nodeOf = React.useCallback((id) => {
|
|
829
|
+
const s = sessionsById[id]
|
|
830
|
+
if (s === undefined) return undefined
|
|
831
|
+
return {
|
|
832
|
+
id,
|
|
833
|
+
title: s.blank ? '' : (s.displayTitle || s.title || ''),
|
|
834
|
+
blank: s.blank === true,
|
|
835
|
+
running: s.running === true,
|
|
836
|
+
updatedAt: s.updatedAt || 0,
|
|
837
|
+
}
|
|
838
|
+
}, [sessionsById])
|
|
839
|
+
|
|
840
|
+
// Пустая сессия — мусор от расписаний и мессенджера, КРОМЕ текущей:
|
|
841
|
+
// её человек открыл сам и вот-вот начнёт в неё писать.
|
|
842
|
+
const visible = React.useCallback(
|
|
843
|
+
(node) => !(settings.hideBlank && node.blank && node.id !== currentId),
|
|
844
|
+
[settings.hideBlank, currentId],
|
|
845
|
+
)
|
|
846
|
+
|
|
847
|
+
const pinnedSet = React.useMemo(() => new Set(settings.pinned), [settings.pinned])
|
|
848
|
+
const hiddenSet = React.useMemo(() => new Set(settings.hidden), [settings.hidden])
|
|
849
|
+
const archivedSet = React.useMemo(() => new Set(archivedIds || []), [archivedIds])
|
|
850
|
+
|
|
851
|
+
const matches = React.useCallback((node) => {
|
|
852
|
+
const text = query.trim().toLowerCase()
|
|
853
|
+
if (text === '') return true
|
|
854
|
+
if (node.title.toLowerCase().includes(text)) return true
|
|
855
|
+
return found !== null && Object.prototype.hasOwnProperty.call(found, node.id)
|
|
856
|
+
}, [query, found])
|
|
857
|
+
|
|
858
|
+
const pinnedRows = settings.pinned
|
|
859
|
+
.map(nodeOf)
|
|
860
|
+
.filter((n) => n !== undefined && !hiddenSet.has(n.id) && !archivedSet.has(n.id)
|
|
861
|
+
&& visible(n) && matches(n))
|
|
862
|
+
|
|
863
|
+
const groups = (workspaces || []).map((w) => ({
|
|
864
|
+
id: w.workspaceId,
|
|
865
|
+
title: w.title,
|
|
866
|
+
rows: (w.sessionIds || [])
|
|
867
|
+
.filter((id) => !pinnedSet.has(id) && !hiddenSet.has(id) && !archivedSet.has(id))
|
|
868
|
+
.map(nodeOf)
|
|
869
|
+
.filter((n) => n !== undefined && visible(n) && matches(n)),
|
|
870
|
+
}))
|
|
871
|
+
|
|
872
|
+
const hiddenRows = settings.hidden.map(nodeOf).filter((n) => n !== undefined && matches(n))
|
|
873
|
+
const archiveRows = (archivedIds || []).map(nodeOf).filter((n) => n !== undefined && matches(n))
|
|
874
|
+
|
|
875
|
+
const archiveByPeriod = React.useMemo(() => {
|
|
876
|
+
const now = Date.now()
|
|
877
|
+
const buckets = { today: [], week: [], month: [], older: [] }
|
|
878
|
+
for (const row of archiveRows) buckets[periodOf(row.updatedAt, now)].push(row)
|
|
879
|
+
return buckets
|
|
880
|
+
}, [archiveRows])
|
|
881
|
+
|
|
882
|
+
// Плоский порядок всех видимых строк — по нему считается диапазон при
|
|
883
|
+
// выборе с Shift.
|
|
884
|
+
orderRef.current = []
|
|
885
|
+
.concat(pinnedRows.map((r) => r.id))
|
|
886
|
+
.concat(...groups.map((g) => g.rows.map((r) => r.id)))
|
|
887
|
+
.concat(hiddenRows.map((r) => r.id))
|
|
888
|
+
.concat(...PERIODS.map((p) => archiveByPeriod[p].map((r) => r.id)))
|
|
889
|
+
|
|
890
|
+
// Выбор не переживает смену запроса: после фильтра на экране другие
|
|
891
|
+
// строки, и молча действовать над невидимым нельзя.
|
|
892
|
+
React.useEffect(() => { setSelected([]); lastPicked.current = null }, [query])
|
|
893
|
+
|
|
894
|
+
const writeList = async (key, next) => {
|
|
895
|
+
setError('')
|
|
896
|
+
try {
|
|
897
|
+
await props.settingsScope.set(key, next)
|
|
898
|
+
} catch (e) {
|
|
899
|
+
setError(t('error.save', { message: (e && e.message) || String(e) }))
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
const togglePin = (id) => {
|
|
904
|
+
const next = pinnedSet.has(id)
|
|
905
|
+
? settings.pinned.filter((x) => x !== id)
|
|
906
|
+
: settings.pinned.concat([id])
|
|
907
|
+
writeList('pinned', next)
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
const toggleHidden = (id) => {
|
|
911
|
+
const next = hiddenSet.has(id)
|
|
912
|
+
? settings.hidden.filter((x) => x !== id)
|
|
913
|
+
: settings.hidden.concat([id])
|
|
914
|
+
writeList('hidden', next)
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
// Переноса между папками в меню нет и быть не может: рабочая папка —
|
|
918
|
+
// это каталог, а членство сессии выводится из её рабочего каталога.
|
|
919
|
+
// Хост проверяет это в attachSession и отвергает сессию, чей cwd не
|
|
920
|
+
// совпадает с путём папки. Именно поэтому такого пункта нет и в
|
|
921
|
+
// ядровой панели.
|
|
922
|
+
const openMenu = (at, node, intent) => {
|
|
923
|
+
if (intent === 'rename') { setRenamingId(node.id); return }
|
|
924
|
+
const items = [
|
|
925
|
+
{
|
|
926
|
+
key: 'pin',
|
|
927
|
+
label: pinnedSet.has(node.id) ? t('menu.unpin') : t('menu.pin'),
|
|
928
|
+
disabled: !settings.writable,
|
|
929
|
+
run: () => togglePin(node.id),
|
|
930
|
+
},
|
|
931
|
+
{ key: 'rename', label: t('menu.rename'), run: () => setRenamingId(node.id) },
|
|
932
|
+
{ key: 'fork', label: t('menu.fork'), run: () => props.forkSession(node.id) },
|
|
933
|
+
{ separator: true },
|
|
934
|
+
]
|
|
935
|
+
items.push({
|
|
936
|
+
key: 'hide',
|
|
937
|
+
label: hiddenSet.has(node.id) ? t('menu.unhide') : t('menu.hide'),
|
|
938
|
+
disabled: !settings.writable,
|
|
939
|
+
run: () => toggleHidden(node.id),
|
|
940
|
+
})
|
|
941
|
+
items.push({
|
|
942
|
+
key: 'archive',
|
|
943
|
+
label: t('menu.archive'),
|
|
944
|
+
danger: true,
|
|
945
|
+
run: () => {
|
|
946
|
+
// Единственное действие плагина с подтверждением: ядровый архив
|
|
947
|
+
// необратим, метода возврата в API нет вовсе.
|
|
948
|
+
if (window.confirm(t('confirm.archive'))) {
|
|
949
|
+
props.archiveSession(node.id).catch((e) => {
|
|
950
|
+
setError(t('error.archive', { message: (e && e.message) || String(e) }))
|
|
951
|
+
})
|
|
952
|
+
}
|
|
953
|
+
},
|
|
954
|
+
})
|
|
955
|
+
setMenu({ at: at || { x: 120, y: 120 }, items })
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
const commitRename = async (id, title) => {
|
|
959
|
+
setRenamingId(null)
|
|
960
|
+
const next = String(title || '').trim()
|
|
961
|
+
if (next === '') return
|
|
962
|
+
try {
|
|
963
|
+
await props.renameSession(id, next)
|
|
964
|
+
} catch (e) {
|
|
965
|
+
setError(t('error.rename', { message: (e && e.message) || String(e) }))
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
// Архивную сессию открываем расшифровкой, а не в беседе: сделать её
|
|
970
|
+
// текущей нельзя, ядро немедленно снимет выбор.
|
|
971
|
+
const openRow = (id) => {
|
|
972
|
+
if (archivedSet.has(id)) {
|
|
973
|
+
const node = nodeOf(id)
|
|
974
|
+
setViewing({ id, title: (node && node.title) || t('row.untitled') })
|
|
975
|
+
return
|
|
976
|
+
}
|
|
977
|
+
props.open(id)
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
const pickRow = (id, withRange) => {
|
|
981
|
+
const order = orderRef.current
|
|
982
|
+
setSelected((prev) => {
|
|
983
|
+
const set = new Set(prev)
|
|
984
|
+
if (withRange && lastPicked.current !== null) {
|
|
985
|
+
const from = order.indexOf(lastPicked.current)
|
|
986
|
+
const to = order.indexOf(id)
|
|
987
|
+
if (from !== -1 && to !== -1) {
|
|
988
|
+
const [a, b] = from <= to ? [from, to] : [to, from]
|
|
989
|
+
for (let i = a; i <= b; i += 1) set.add(order[i])
|
|
990
|
+
lastPicked.current = id
|
|
991
|
+
return [...set]
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
if (set.has(id)) set.delete(id)
|
|
995
|
+
else set.add(id)
|
|
996
|
+
lastPicked.current = id
|
|
997
|
+
return [...set]
|
|
998
|
+
})
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
const renderRows = (rows) => rows.map((node) =>
|
|
1002
|
+
h(SessionRow, {
|
|
1003
|
+
key: node.id,
|
|
1004
|
+
node,
|
|
1005
|
+
t,
|
|
1006
|
+
current: node.id === currentId,
|
|
1007
|
+
pinned: pinnedSet.has(node.id),
|
|
1008
|
+
snippet: found === null ? undefined : found[node.id],
|
|
1009
|
+
selectable: true,
|
|
1010
|
+
selected: selectedSet.has(node.id),
|
|
1011
|
+
onSelect: pickRow,
|
|
1012
|
+
renaming: renamingId === node.id,
|
|
1013
|
+
onOpen: openRow,
|
|
1014
|
+
onMenu: openMenu,
|
|
1015
|
+
onRenameCommit: (value) => commitRename(node.id, value),
|
|
1016
|
+
onRenameCancel: () => setRenamingId(null),
|
|
1017
|
+
}),
|
|
1018
|
+
)
|
|
1019
|
+
|
|
1020
|
+
// Узкая панель: ядро отдаёт нам ту же полосу, что и штатному блоку.
|
|
1021
|
+
if (props.wide === false) {
|
|
1022
|
+
return h(
|
|
1023
|
+
'div',
|
|
1024
|
+
{ className: 'dsc-rail' },
|
|
1025
|
+
h('button', {
|
|
1026
|
+
type: 'button',
|
|
1027
|
+
className: 'dsc-rail-btn',
|
|
1028
|
+
'aria-label': t('head.search'),
|
|
1029
|
+
onClick: () => { props.expandSidebar(); setSearchOpen(true) },
|
|
1030
|
+
}, h(Search, {})),
|
|
1031
|
+
flowAvailable
|
|
1032
|
+
? h('button', {
|
|
1033
|
+
type: 'button',
|
|
1034
|
+
className: 'dsc-rail-btn',
|
|
1035
|
+
'aria-label': t('head.add'),
|
|
1036
|
+
onClick: () => { props.expandSidebar(); setFlowOpen(true) },
|
|
1037
|
+
}, h(Add, {}))
|
|
1038
|
+
: null,
|
|
1039
|
+
)
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
const loading = workspacePhase !== 'ready' || sessionPhase !== 'ready'
|
|
1043
|
+
const nothing = !loading && groups.length === 0 && pinnedRows.length === 0
|
|
1044
|
+
|
|
1045
|
+
return h(
|
|
1046
|
+
'div',
|
|
1047
|
+
{ className: 'dsc-root' },
|
|
1048
|
+
h(
|
|
1049
|
+
'div',
|
|
1050
|
+
{ className: 'dsc-head' },
|
|
1051
|
+
h('span', { className: 'dsc-head-title' }, t('head.title')),
|
|
1052
|
+
h('button', {
|
|
1053
|
+
type: 'button',
|
|
1054
|
+
className: 'dsc-icon-btn',
|
|
1055
|
+
'aria-label': t('head.search'),
|
|
1056
|
+
onClick: () => setSearchOpen((v) => !v),
|
|
1057
|
+
}, h(Search, {})),
|
|
1058
|
+
flowAvailable
|
|
1059
|
+
? h('button', {
|
|
1060
|
+
type: 'button',
|
|
1061
|
+
className: 'dsc-icon-btn',
|
|
1062
|
+
'aria-label': t('head.add'),
|
|
1063
|
+
onClick: () => setFlowOpen(true),
|
|
1064
|
+
}, h(Add, {}))
|
|
1065
|
+
: null,
|
|
1066
|
+
),
|
|
1067
|
+
searchOpen
|
|
1068
|
+
? h('input', {
|
|
1069
|
+
className: 'dsc-search',
|
|
1070
|
+
value: query,
|
|
1071
|
+
placeholder: t('head.searchPlaceholder'),
|
|
1072
|
+
autoFocus: true,
|
|
1073
|
+
onChange: (e) => setQuery(e.currentTarget.value),
|
|
1074
|
+
onKeyDown: (e) => { if (e.key === 'Escape') { setQuery(''); setSearchOpen(false) } },
|
|
1075
|
+
})
|
|
1076
|
+
: null,
|
|
1077
|
+
settings.status === 'unavailable'
|
|
1078
|
+
? h('div', { className: 'dsc-error' }, t('error.settings'))
|
|
1079
|
+
: null,
|
|
1080
|
+
error !== '' ? h('div', { className: 'dsc-error' }, error) : null,
|
|
1081
|
+
searchFailed !== '' ? h('div', { className: 'dsc-error' }, searchFailed) : null,
|
|
1082
|
+
h(
|
|
1083
|
+
'div',
|
|
1084
|
+
{ className: 'dsc-list' },
|
|
1085
|
+
loading
|
|
1086
|
+
? h('div', null,
|
|
1087
|
+
h('div', { className: 'dsc-skeleton' }),
|
|
1088
|
+
h('div', { className: 'dsc-skeleton' }),
|
|
1089
|
+
h('div', { className: 'dsc-skeleton' }))
|
|
1090
|
+
: null,
|
|
1091
|
+
!loading && nothing
|
|
1092
|
+
? h('div', { className: 'dsc-empty' }, t('empty.noWorkspaces'))
|
|
1093
|
+
: null,
|
|
1094
|
+
!loading && pinnedRows.length > 0
|
|
1095
|
+
? h(Section, {
|
|
1096
|
+
id: 'pinned',
|
|
1097
|
+
name: t('section.pinned'),
|
|
1098
|
+
count: pinnedRows.length,
|
|
1099
|
+
collapsed: isCollapsed('pinned', false),
|
|
1100
|
+
defaultCollapsed: false,
|
|
1101
|
+
onToggle: toggleCollapsed,
|
|
1102
|
+
}, renderRows(pinnedRows))
|
|
1103
|
+
: null,
|
|
1104
|
+
!loading
|
|
1105
|
+
? groups.map((g) => h(Section, {
|
|
1106
|
+
key: g.id,
|
|
1107
|
+
id: 'ws:' + g.id,
|
|
1108
|
+
name: g.title,
|
|
1109
|
+
count: g.rows.length,
|
|
1110
|
+
collapsed: isCollapsed('ws:' + g.id, false),
|
|
1111
|
+
defaultCollapsed: false,
|
|
1112
|
+
onToggle: toggleCollapsed,
|
|
1113
|
+
}, g.rows.length === 0
|
|
1114
|
+
? h('div', { className: 'dsc-empty' },
|
|
1115
|
+
query.trim() === '' ? t('empty.workspace') : t('empty.search'))
|
|
1116
|
+
: renderRows(g.rows)))
|
|
1117
|
+
: null,
|
|
1118
|
+
!loading && hiddenRows.length > 0
|
|
1119
|
+
? h(Section, {
|
|
1120
|
+
id: 'hidden',
|
|
1121
|
+
name: t('section.hidden'),
|
|
1122
|
+
count: hiddenRows.length,
|
|
1123
|
+
collapsed: isCollapsed('hidden', true),
|
|
1124
|
+
defaultCollapsed: true,
|
|
1125
|
+
onToggle: toggleCollapsed,
|
|
1126
|
+
}, renderRows(hiddenRows))
|
|
1127
|
+
: null,
|
|
1128
|
+
!loading && archiveRows.length > 0
|
|
1129
|
+
? h(Section, {
|
|
1130
|
+
id: 'archive',
|
|
1131
|
+
name: t('section.archive'),
|
|
1132
|
+
count: archiveRows.length,
|
|
1133
|
+
collapsed: isCollapsed('archive', true),
|
|
1134
|
+
defaultCollapsed: true,
|
|
1135
|
+
onToggle: toggleCollapsed,
|
|
1136
|
+
},
|
|
1137
|
+
h('div', { className: 'dsc-empty' }, t('section.archiveNote')),
|
|
1138
|
+
// Периоды: свёрнутый период не рисуется вовсе, поэтому сотни
|
|
1139
|
+
// строк перестают попадать в разметку разом.
|
|
1140
|
+
PERIODS.map((period) => {
|
|
1141
|
+
const rows = archiveByPeriod[period]
|
|
1142
|
+
if (rows.length === 0) return null
|
|
1143
|
+
// Во время поиска периоды раскрыты: иначе совпадения
|
|
1144
|
+
// спрячутся за свёрнутым заголовком.
|
|
1145
|
+
const forced = query.trim() !== ''
|
|
1146
|
+
return h(Section, {
|
|
1147
|
+
key: period,
|
|
1148
|
+
id: 'archive:' + period,
|
|
1149
|
+
name: t('period.' + period),
|
|
1150
|
+
count: rows.length,
|
|
1151
|
+
collapsed: forced ? false : isCollapsed('archive:' + period, period !== 'today'),
|
|
1152
|
+
defaultCollapsed: period !== 'today',
|
|
1153
|
+
onToggle: toggleCollapsed,
|
|
1154
|
+
}, renderRows(rows))
|
|
1155
|
+
}))
|
|
1156
|
+
: null,
|
|
1157
|
+
),
|
|
1158
|
+
selected.length > 0
|
|
1159
|
+
? h('div', { className: 'dsc-bulk' },
|
|
1160
|
+
h('span', { className: 'dsc-bulk-count' }, t('bulk.count', { n: selected.length })),
|
|
1161
|
+
h('button', {
|
|
1162
|
+
type: 'button', className: 'dsc-bulk-act', disabled: !settings.writable,
|
|
1163
|
+
onClick: () => {
|
|
1164
|
+
const next = settings.hidden.concat(selected.filter((id) => !hiddenSet.has(id)))
|
|
1165
|
+
writeList('hidden', next); setSelected([])
|
|
1166
|
+
},
|
|
1167
|
+
}, t('bulk.hide')),
|
|
1168
|
+
h('button', {
|
|
1169
|
+
type: 'button', className: 'dsc-bulk-act', disabled: !settings.writable,
|
|
1170
|
+
onClick: () => {
|
|
1171
|
+
writeList('hidden', settings.hidden.filter((id) => !selectedSet.has(id)))
|
|
1172
|
+
setSelected([])
|
|
1173
|
+
},
|
|
1174
|
+
}, t('bulk.unhide')),
|
|
1175
|
+
h('button', {
|
|
1176
|
+
type: 'button', className: 'dsc-bulk-act', disabled: !settings.writable,
|
|
1177
|
+
onClick: () => {
|
|
1178
|
+
const next = settings.pinned.concat(selected.filter((id) => !pinnedSet.has(id)))
|
|
1179
|
+
writeList('pinned', next); setSelected([])
|
|
1180
|
+
},
|
|
1181
|
+
}, t('bulk.pin')),
|
|
1182
|
+
h('button', {
|
|
1183
|
+
type: 'button', className: 'dsc-bulk-act',
|
|
1184
|
+
onClick: () => { setSelected([]); lastPicked.current = null },
|
|
1185
|
+
}, t('bulk.clear')))
|
|
1186
|
+
: null,
|
|
1187
|
+
menu ? h(RowMenu, { at: menu.at, items: menu.items, onClose: () => setMenu(null) }) : null,
|
|
1188
|
+
viewing
|
|
1189
|
+
? h(ArchiveViewer, { sessionId: viewing.id, title: viewing.title, t,
|
|
1190
|
+
onClose: () => setViewing(null) })
|
|
1191
|
+
: null,
|
|
1192
|
+
// Поток выбора каталога: мы владеем кнопкой и присвоением пути,
|
|
1193
|
+
// занявший слот владеет всем, что между открытием и выбранным путём.
|
|
1194
|
+
props.renderSlot('sidebar.workspaces.directoryFlow', {
|
|
1195
|
+
open: flowOpen,
|
|
1196
|
+
busy: flowBusy,
|
|
1197
|
+
onPicked: (path) => {
|
|
1198
|
+
setFlowBusy(true)
|
|
1199
|
+
props.createWorkspace({ path }).then(
|
|
1200
|
+
() => { setFlowBusy(false); setFlowOpen(false) },
|
|
1201
|
+
(e) => {
|
|
1202
|
+
setFlowBusy(false)
|
|
1203
|
+
setFlowOpen(false)
|
|
1204
|
+
setError(t('error.addWorkspace', { message: (e && e.message) || String(e) }))
|
|
1205
|
+
},
|
|
1206
|
+
)
|
|
1207
|
+
},
|
|
1208
|
+
onCancel: () => setFlowOpen(false),
|
|
1209
|
+
onError: (message) => { setFlowOpen(false); setError(message) },
|
|
1210
|
+
}),
|
|
1211
|
+
)
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
/**
|
|
1215
|
+
* Выбор папки на пустом экране беседы.
|
|
1216
|
+
*
|
|
1217
|
+
* Обязательство замены: ядровый ряд отдавал этот слот, и без него на
|
|
1218
|
+
* пустом экране не остаётся способа выбрать или завести папку.
|
|
1219
|
+
*/
|
|
1220
|
+
function HeroWorkspacePicker(props) {
|
|
1221
|
+
const t = props.t
|
|
1222
|
+
const [open, setOpen] = React.useState(false)
|
|
1223
|
+
const [busy, setBusy] = React.useState(false)
|
|
1224
|
+
const [error, setError] = React.useState('')
|
|
1225
|
+
const available = props.useDirectoryFlow((occupied) => occupied)
|
|
1226
|
+
|
|
1227
|
+
React.useEffect(() => { ensureStyles() }, [])
|
|
1228
|
+
|
|
1229
|
+
// Дочерний слот отрисовывается всегда, даже когда занять его некому:
|
|
1230
|
+
// так делает ядровый эталон. Условной отрисовкой мы бы создавали и
|
|
1231
|
+
// разрушали поддерево при каждой смене занятости, а слот объявлен
|
|
1232
|
+
// single/root — его занимает один компонент сразу на обе дырки.
|
|
1233
|
+
return h(
|
|
1234
|
+
'div',
|
|
1235
|
+
null,
|
|
1236
|
+
available
|
|
1237
|
+
? h('button', {
|
|
1238
|
+
type: 'button',
|
|
1239
|
+
className: 'dsc-menu-item',
|
|
1240
|
+
disabled: busy,
|
|
1241
|
+
onClick: () => setOpen(true),
|
|
1242
|
+
}, t('head.add'))
|
|
1243
|
+
: null,
|
|
1244
|
+
error !== '' ? h('div', { className: 'dsc-error' }, error) : null,
|
|
1245
|
+
props.renderSlot('conversation.hero.workspace.directoryFlow', {
|
|
1246
|
+
open,
|
|
1247
|
+
busy,
|
|
1248
|
+
onPicked: (path) => {
|
|
1249
|
+
setBusy(true)
|
|
1250
|
+
props.createWorkspace({ path }).then(
|
|
1251
|
+
() => { setBusy(false); setOpen(false) },
|
|
1252
|
+
(e) => {
|
|
1253
|
+
setBusy(false)
|
|
1254
|
+
setOpen(false)
|
|
1255
|
+
setError(t('error.addWorkspace', { message: (e && e.message) || String(e) }))
|
|
1256
|
+
},
|
|
1257
|
+
)
|
|
1258
|
+
},
|
|
1259
|
+
onCancel: () => setOpen(false),
|
|
1260
|
+
onError: (message) => { setOpen(false); setError(message) },
|
|
1261
|
+
}),
|
|
1262
|
+
)
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
/** Карточка в «Настройки → Плагины → Настройки плагинов». */
|
|
1266
|
+
function SettingsCard(props) {
|
|
1267
|
+
const t = props.t
|
|
1268
|
+
const ctx = props.ctx
|
|
1269
|
+
const [open, setOpen] = React.useState(false)
|
|
1270
|
+
const scope = React.useMemo(
|
|
1271
|
+
() => (ctx && ctx.settingsScope ? ctx.settingsScope.bind({ namespace: NS }) : undefined),
|
|
1272
|
+
[ctx],
|
|
1273
|
+
)
|
|
1274
|
+
const settings = useSettings(scope)
|
|
1275
|
+
React.useEffect(() => { ensureStyles() }, [])
|
|
1276
|
+
|
|
1277
|
+
const body = settings.status === 'loading'
|
|
1278
|
+
? h('div', { className: 'dsc-card-stat' }, t('card.loading'))
|
|
1279
|
+
: settings.status !== 'ready'
|
|
1280
|
+
? h('div', { className: 'dsc-error' }, t('error.settings'))
|
|
1281
|
+
: h('div', null,
|
|
1282
|
+
h('div', { className: 'dsc-card-stat' },
|
|
1283
|
+
t('card.pinnedCount', { n: settings.pinned.length }) + ' · ' +
|
|
1284
|
+
t('card.hiddenCount', { n: settings.hidden.length })),
|
|
1285
|
+
h('label', { className: 'dsc-card-toggle' },
|
|
1286
|
+
h('input', {
|
|
1287
|
+
type: 'checkbox',
|
|
1288
|
+
checked: settings.hideBlank,
|
|
1289
|
+
onChange: (e) => { if (scope) scope.set('hideBlank', e.currentTarget.checked) },
|
|
1290
|
+
}),
|
|
1291
|
+
h('span', null, t('card.hideBlank'))),
|
|
1292
|
+
h('button', {
|
|
1293
|
+
type: 'button',
|
|
1294
|
+
className: 'dsc-card-action',
|
|
1295
|
+
disabled: settings.hidden.length === 0,
|
|
1296
|
+
onClick: () => { if (scope) scope.set('hidden', []) },
|
|
1297
|
+
}, t('card.unhideAll')))
|
|
1298
|
+
|
|
1299
|
+
return h(
|
|
1300
|
+
'li',
|
|
1301
|
+
{ className: 'dsc-card' },
|
|
1302
|
+
h(
|
|
1303
|
+
'button',
|
|
1304
|
+
{
|
|
1305
|
+
type: 'button',
|
|
1306
|
+
className: 'dsc-card-head',
|
|
1307
|
+
'aria-expanded': open ? 'true' : 'false',
|
|
1308
|
+
onClick: () => setOpen((v) => !v),
|
|
1309
|
+
},
|
|
1310
|
+
h('span', { className: 'dsc-card-text' },
|
|
1311
|
+
h('span', { className: 'dsc-card-title' }, t('card.title')),
|
|
1312
|
+
h('span', { className: 'dsc-card-sub' }, t('card.subtitle'))),
|
|
1313
|
+
h(Chevron, { className: 'dsc-card-chev' + (open ? ' dsc-card-chev-open' : '') }),
|
|
1314
|
+
),
|
|
1315
|
+
open ? h('div', { className: 'dsc-card-body' }, body) : null,
|
|
1316
|
+
)
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
/** Пространство настроек. Совпадает с ключом карточки. */
|
|
1320
|
+
const NS = 'dsh-session-control'
|
|
1321
|
+
|
|
1322
|
+
const en = {
|
|
1323
|
+
'head.title': 'Conversations',
|
|
1324
|
+
'head.search': 'Search',
|
|
1325
|
+
'head.searchPlaceholder': 'Title or message text',
|
|
1326
|
+
'head.add': 'Add workspace…',
|
|
1327
|
+
'period.today': 'Today',
|
|
1328
|
+
'period.week': 'This week',
|
|
1329
|
+
'period.month': 'This month',
|
|
1330
|
+
'period.older': 'Earlier',
|
|
1331
|
+
'bulk.select': 'Select session',
|
|
1332
|
+
'bulk.count': 'selected: {n}',
|
|
1333
|
+
'bulk.hide': 'Hide',
|
|
1334
|
+
'bulk.unhide': 'Unhide',
|
|
1335
|
+
'bulk.pin': 'Pin',
|
|
1336
|
+
'bulk.clear': 'Clear',
|
|
1337
|
+
'section.pinned': 'Pinned',
|
|
1338
|
+
'section.hidden': 'Hidden',
|
|
1339
|
+
'section.archive': 'Archived',
|
|
1340
|
+
'section.archiveNote': 'Archived by the core: open to read, restoring is not possible yet.',
|
|
1341
|
+
'row.blank': 'New session',
|
|
1342
|
+
'row.untitled': 'Untitled',
|
|
1343
|
+
'row.running': 'Running',
|
|
1344
|
+
'row.menu': 'Session actions',
|
|
1345
|
+
'menu.pin': 'Pin',
|
|
1346
|
+
'menu.unpin': 'Unpin',
|
|
1347
|
+
'menu.rename': 'Rename',
|
|
1348
|
+
'menu.fork': 'Fork session',
|
|
1349
|
+
'menu.hide': 'Hide',
|
|
1350
|
+
'menu.unhide': 'Unhide',
|
|
1351
|
+
'menu.archive': 'Archive (permanent)',
|
|
1352
|
+
'confirm.archive': 'Archiving is permanent: the core has no way to restore a session. Continue?',
|
|
1353
|
+
'empty.noWorkspaces': 'No workspaces yet. Add one to start a conversation.',
|
|
1354
|
+
'empty.workspace': 'No conversations here yet.',
|
|
1355
|
+
'empty.search': 'Nothing found.',
|
|
1356
|
+
'error.search': 'Search failed: {message}',
|
|
1357
|
+
'error.settings': 'Settings are unavailable, so pinning and hiding are off. The list still works.',
|
|
1358
|
+
'error.save': 'Could not save: {message}',
|
|
1359
|
+
'error.rename': 'Could not rename: {message}',
|
|
1360
|
+
'error.archive': 'Could not archive: {message}',
|
|
1361
|
+
'error.addWorkspace': 'Could not add workspace: {message}',
|
|
1362
|
+
'age.now': 'now',
|
|
1363
|
+
'age.min': '{n} min',
|
|
1364
|
+
'age.hour': '{n} h',
|
|
1365
|
+
'age.day': '{n} d',
|
|
1366
|
+
'viewer.readonly': 'read-only',
|
|
1367
|
+
'viewer.close': 'Close',
|
|
1368
|
+
'viewer.you': 'You',
|
|
1369
|
+
'viewer.agent': 'Agent',
|
|
1370
|
+
'viewer.tool': 'tool: {name}',
|
|
1371
|
+
'viewer.loading': 'Loading the transcript…',
|
|
1372
|
+
'viewer.empty': 'This session has no messages.',
|
|
1373
|
+
'viewer.unreadable': 'An older log format the core declines to read. The conversation is intact on disk, but cannot be shown here.',
|
|
1374
|
+
'viewer.truncated': 'Showing the last {shown} of {total} messages.',
|
|
1375
|
+
'viewer.failed': 'Could not read the session: {message}',
|
|
1376
|
+
'card.title': 'Session control',
|
|
1377
|
+
'card.subtitle': 'Pinned and hidden conversations in the sidebar list.',
|
|
1378
|
+
'card.loading': 'Loading…',
|
|
1379
|
+
'card.pinnedCount': 'pinned: {n}',
|
|
1380
|
+
'card.hiddenCount': 'hidden: {n}',
|
|
1381
|
+
'card.hideBlank': 'Hide sessions with no messages',
|
|
1382
|
+
'card.unhideAll': 'Unhide all',
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
|
|
1386
|
+
// Половине «сервис» нужен минимум: чем короче этот список, тем меньше
|
|
1387
|
+
// причин, по которым приложение может не подняться.
|
|
1388
|
+
exports.inject = ['slots', 'sessions', 'workspaces', 'remote', 'remote.directoryPicker']
|
|
1389
|
+
|
|
1390
|
+
exports.apply = function apply(ctx) {
|
|
1391
|
+
const sessions = ctx.get('sessions')
|
|
1392
|
+
const workspaces = ctx.get('workspaces')
|
|
1393
|
+
|
|
1394
|
+
// ---- половина «СЕРВИС» ----
|
|
1395
|
+
new UiWorkspaceService(ctx, ctx.remote.directoryPicker, workspaces, sessions)
|
|
1396
|
+
ctx.slots.provideRoot({ hooks: { workspaces: workspaces.list } })
|
|
1397
|
+
|
|
1398
|
+
// ---- половина «ИНТЕРФЕЙС» ----
|
|
1399
|
+
// Отдельный дочерний контекст: если locale или settingsScope в сборке
|
|
1400
|
+
// нет, без интерфейса останется только слот, а сервис уже отдан.
|
|
1401
|
+
ctx.inject(['locale', 'settingsScope'], (uictx) => {
|
|
1402
|
+
try {
|
|
1403
|
+
// Словари регистрируем в собственной защите и НИКОГДА не даём им
|
|
1404
|
+
// утащить за собой интерфейс.
|
|
1405
|
+
//
|
|
1406
|
+
// Клиентское дерево применяется дважды за загрузку страницы.
|
|
1407
|
+
// Повторная регистрация того же пространства бросает «already has
|
|
1408
|
+
// locale», и если этот вызов стоит первым в общем try, вместе с ним
|
|
1409
|
+
// не регистрируется ни список, ни выбор папки, ни карточка настроек:
|
|
1410
|
+
// приложение живо, а тело панели пустое. Ровно это и случилось на
|
|
1411
|
+
// production. Цена отказа здесь — подписи на английском, а не
|
|
1412
|
+
// отсутствие панели.
|
|
1413
|
+
// Плагин везёт только английский. Русский для наших плагинов
|
|
1414
|
+
// поставляет отдельный языковой плагин, и он занимает то же
|
|
1415
|
+
// пространство настроек. Регистрация под защитой: если он успел
|
|
1416
|
+
// раньше, повторная попытка бросает, а уронить вместе с собой весь
|
|
1417
|
+
// интерфейс словари не имеют права — именно так панель и пропала на
|
|
1418
|
+
// production.
|
|
1419
|
+
try {
|
|
1420
|
+
uictx.effect(
|
|
1421
|
+
() => uictx.locale.register(NS, { en }),
|
|
1422
|
+
'dsh-session-control: словарь en',
|
|
1423
|
+
)
|
|
1424
|
+
} catch (taken) {
|
|
1425
|
+
console.warn('[dsh-session-control] словарь уже зарегистрирован:',
|
|
1426
|
+
(taken && taken.message) || taken)
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
const searchSessions = async (query, signal) => {
|
|
1430
|
+
const result = await sessions.search(query, signal)
|
|
1431
|
+
if (!result.ok) throw new Error(result.error.message)
|
|
1432
|
+
return result.value
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
// Занятость дочернего слота: источник должен быть стабильным,
|
|
1436
|
+
// отрисовщик кэширует хуки по тождеству источника.
|
|
1437
|
+
const flowSource = (hole) => ({
|
|
1438
|
+
getSnapshot: () => uictx.slots.entries(hole).length > 0,
|
|
1439
|
+
subscribe: (listener) => uictx.slots.subscribe(hole, listener),
|
|
1440
|
+
})
|
|
1441
|
+
const browserFlow = flowSource('sidebar.workspaces.directoryFlow')
|
|
1442
|
+
const pickerFlow = flowSource('conversation.hero.workspace.directoryFlow')
|
|
1443
|
+
const hostInfo = {
|
|
1444
|
+
getSnapshot: () => uictx.remote.$host,
|
|
1445
|
+
subscribe: (listener) => uictx.on('connection/reset', listener),
|
|
1446
|
+
}
|
|
1447
|
+
const settingsScope = uictx.settingsScope.bind({ namespace: NS })
|
|
1448
|
+
|
|
1449
|
+
const browserInjected = () => ({
|
|
1450
|
+
settingsScope,
|
|
1451
|
+
startSession: (workspaceId) => { uictx.get('uiWorkspace').startSession(workspaceId) },
|
|
1452
|
+
open: (sessionId) => { sessions.open(sessionId) },
|
|
1453
|
+
searchSessions,
|
|
1454
|
+
searchResultLimit: sessions.searchResultLimit,
|
|
1455
|
+
renameSession: async (sessionId, title) => {
|
|
1456
|
+
const session = sessions.binding(sessionId)?.session
|
|
1457
|
+
if (session === undefined) throw new Error(`unknown session "${sessionId}"`)
|
|
1458
|
+
const result = await session.rename(title)
|
|
1459
|
+
if (!result.ok) throw new Error(result.error.message)
|
|
1460
|
+
},
|
|
1461
|
+
forkSession: (sessionId) => {
|
|
1462
|
+
sessions.fork({ sessionId, increaseTitle: true })
|
|
1463
|
+
.then((childId) => { sessions.open(childId) })
|
|
1464
|
+
.catch(() => { /* отказ ответвления сохраняет текущий выбор */ })
|
|
1465
|
+
},
|
|
1466
|
+
archiveSession: async (sessionId) => { await workspaces.archiveSession(sessionId) },
|
|
1467
|
+
createWorkspace: (input) => workspaces.create(input),
|
|
1468
|
+
hooks: { directoryFlow: browserFlow, hostInfo },
|
|
1469
|
+
})
|
|
1470
|
+
|
|
1471
|
+
const pickerInjected = () => ({
|
|
1472
|
+
createWorkspace: (input) => workspaces.create(input),
|
|
1473
|
+
hooks: { directoryFlow: pickerFlow },
|
|
1474
|
+
})
|
|
1475
|
+
|
|
1476
|
+
uictx.slots.inject('sidebar.workspaces', () => uictx.slots.register(
|
|
1477
|
+
{
|
|
1478
|
+
name: 'sidebar.workspaces',
|
|
1479
|
+
children: { 'sidebar.workspaces.directoryFlow': { kind: 'single', scope: 'root' } },
|
|
1480
|
+
inject: browserInjected,
|
|
1481
|
+
locale: NS,
|
|
1482
|
+
},
|
|
1483
|
+
SessionListPanel,
|
|
1484
|
+
))
|
|
1485
|
+
|
|
1486
|
+
uictx.slots.inject('conversation.hero.workspace', () => uictx.slots.register(
|
|
1487
|
+
{
|
|
1488
|
+
name: 'conversation.hero.workspace',
|
|
1489
|
+
children: { 'conversation.hero.workspace.directoryFlow': { kind: 'single', scope: 'root' } },
|
|
1490
|
+
inject: pickerInjected,
|
|
1491
|
+
locale: NS,
|
|
1492
|
+
},
|
|
1493
|
+
HeroWorkspacePicker,
|
|
1494
|
+
))
|
|
1495
|
+
|
|
1496
|
+
uictx.slots.inject('settings.plugin.item', () => uictx.slots.register(
|
|
1497
|
+
{
|
|
1498
|
+
name: 'settings.plugin.item',
|
|
1499
|
+
key: NS,
|
|
1500
|
+
locale: NS,
|
|
1501
|
+
inject: () => ({ ctx: uictx }),
|
|
1502
|
+
},
|
|
1503
|
+
SettingsCard,
|
|
1504
|
+
))
|
|
1505
|
+
} catch (uiFailed) {
|
|
1506
|
+
// Слот останется пустым, приложение — живым. Молчать нельзя:
|
|
1507
|
+
// именно молчаливый catch уже дал нам два невидимых дефекта.
|
|
1508
|
+
console.error('[dsh-session-control] интерфейс не поднялся:', uiFailed)
|
|
1509
|
+
}
|
|
1510
|
+
})
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
return module.exports
|
|
1514
|
+
},
|
|
1515
|
+
})
|