@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/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
+ })