ddeck-remote 0.0.0-stage → 0.9.7-beta

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.
@@ -0,0 +1,3614 @@
1
+ /*
2
+ * DDeck — поведение мобильного слоя.
3
+ *
4
+ * Слой не перерисовывает интерфейс: он добавляет то, чего у настольной вёрстки нет —
5
+ * кнопку меню, затемнение, понятие «панель открыта», двухшаговый выбор сессии,
6
+ * системную «назад», клавиатуру в терминале и уведомления через нативный мост
7
+ * приложения. Всё остальное приходит из skin.css.
8
+ *
9
+ * Правило, которое здесь соблюдается: ничего не подменять, пока не измерено. Каждый
10
+ * селектор и жест ниже проверены на живой странице; имена классов у клиента
11
+ * хешированные, поэтому совпадение идёт по окончанию имени или по публичному
12
+ * data-атрибуту клиента, а не по точному значению.
13
+ *
14
+ * Публичные контракты слоя (их читают инструменты и замеры):
15
+ * window.__dshRemoteLayer — слой уже применён (повторная вставка выходит сразу);
16
+ * window.__dshmStartError — текст ошибки старта, если слой не поднялся;
17
+ * window.__dshmPause — пауза периодических обходов для замеров;
18
+ * window.__dshmNoBlur — не снимать фокус с полей (для замеров);
19
+ * событие dsh-mobile:native-back — системная «назад» от оболочки; preventDefault
20
+ * означает «страница обработала жест сама».
21
+ *
22
+ * ОГЛАВЛЕНИЕ
23
+ * 1. Состояние и общие помощники (DOM, подписи, фокус, флаги замеров)
24
+ * 2. Боковая панель и выбор строки сессии
25
+ * 3. Фокус и клавиатура
26
+ * 4. Навигация и системная «назад»
27
+ * 5. Тема и рабочая область
28
+ * 6. Наблюдение за страницей: статистика хода
29
+ * 7. Уведомления через нативный мост
30
+ * 8. Композер, очередь и подписи
31
+ * 9. Карточка вопроса агента
32
+ * 10. Скрытие настольных элементов
33
+ * 11. Старт слоя
34
+ */
35
+ (function () {
36
+ 'use strict'
37
+ if (window.__dshRemoteLayer) return
38
+ window.__dshRemoteLayer = true
39
+
40
+
41
+ /**
42
+ * Язык интерфейса слоя: русский или английский.
43
+ *
44
+ * DSH ставит `lang` на документ, но у `/m` и панели он может отличаться; явный
45
+ * `?lang=en` и `navigator.language` — запасные источники.
46
+ *
47
+ * @returns {'ru'|'en'} язык.
48
+ */
49
+ function uiLang() {
50
+ let raw = ''
51
+ try { raw = new URLSearchParams(location.search).get('lang') || '' } catch { raw = '' }
52
+ raw = raw || document.documentElement.getAttribute('lang') || navigator.language || 'ru'
53
+ return raw.toLowerCase().startsWith('en') ? 'en' : 'ru'
54
+ }
55
+
56
+ /** Английские подписи; ключ — русский текст, чтобы правки не расходились. */
57
+ const EN_TEXT = {
58
+ 'Меню': 'Menu',
59
+ 'Закрыть меню': 'Close menu',
60
+ 'Закрыть': 'Close',
61
+ 'Статистика': 'Stats',
62
+ 'Субагенты': 'Subagents',
63
+ 'Фоновые задачи': 'Background tasks',
64
+ 'Токены': 'Tokens',
65
+ 'Контекст': 'Context',
66
+ 'DDeck — это ваш компьютер': 'DDeck is your computer',
67
+ 'Чаты, файлы, терминал и настройки работают на Mac. Телефон только показывает и управляет.': 'Chats, files, terminal and settings run on your Mac. The phone only displays and controls them.',
68
+ 'Нижняя панель — быстрые инструменты': 'The bottom bar holds your tools',
69
+ 'Листайте её влево-вправо. Кнопка с точками открывает все инструменты; свайп вверх по панели делает то же самое.': 'Swipe it left and right. The dots button opens all tools; swiping up on the bar does the same.',
70
+ 'Соберите панель под себя': 'Build the bar for yourself',
71
+ 'В сетке нажмите «Изменить» и коснитесь плитки: инструмент убирается из панели или возвращается. Лишние остаются в меню.': 'In the grid tap “Edit” and then a tile: the tool leaves the bar or comes back. Hidden ones stay in the grid.',
72
+ 'Порядок и управление': 'Order and controls',
73
+ 'Долгое нажатие на иконке — перетащить. Шеврон справа сворачивает панель. Настройки — «Вы вошли в DeepSeek».': 'Long-press an icon to drag it. The chevron on the right collapses the bar. Settings live under your account.',
74
+ 'Как пользоваться DDeck': 'How to use DDeck',
75
+ 'Пропустить': 'Skip',
76
+ 'Понятно': 'Got it',
77
+ 'Далее': 'Next',
78
+ 'Все инструменты': 'All tools',
79
+ 'Инструменты': 'Tools',
80
+ 'Изменить': 'Edit',
81
+ 'Настройка нижней панели': 'Customize the bottom bar',
82
+ 'Сбросить': 'Reset',
83
+ 'Вернуть все инструменты в панель': 'Restore all tools to the bar',
84
+ 'Готово': 'Done',
85
+ 'Как пользоваться': 'How to use',
86
+ 'Поиск инструмента': 'Search tools',
87
+ 'Инструменты появятся, когда откроется чат': 'Tools appear when a chat is open',
88
+ 'Боковое меню': 'Sidebar',
89
+ 'Новая сессия': 'New session',
90
+ 'Поиск сессий': 'Search sessions',
91
+ 'Плагины': 'Plugins',
92
+ 'Автоматические задачи': 'Automations',
93
+ 'Правая панель': 'Right panel',
94
+ 'Развернуть панель инструментов': 'Expand tools bar',
95
+ 'Свернуть панель инструментов': 'Collapse tools bar',
96
+ 'Свернуть боковую панель': 'Collapse sidebar',
97
+ 'Свернуть панель': 'Collapse panel',
98
+ 'Листайте панель · удерживайте иконку, чтобы переставить': 'Swipe the bar · long-press an icon to reorder',
99
+ 'Команда агентов': 'Agent team',
100
+ 'Очередь уйдёт после ответа': 'The queue will be sent after the reply',
101
+ 'Задача завершена': 'Task finished',
102
+ 'Задача в DSH завершена': 'Your DSH task finished',
103
+ 'Нужен ваш ответ': 'Input needed',
104
+ 'DSH ждёт вашего выбора': 'DSH waits for your choice',
105
+ 'Уведомления отключены — включите их в настройках приложения': 'Notifications are off — enable them in the app settings',
106
+ 'Подключить телефон': 'Connect a phone',
107
+ 'Сменить компьютер': 'Switch computer',
108
+ 'Второй телефон': 'Second phone',
109
+ 'Просмотр субагента': 'Subagent view',
110
+ 'Отправка сообщений недоступна — это история выполнения.': 'Sending messages is unavailable — this is the execution history.',
111
+ 'Пароль': 'Password',
112
+ 'Введите пароль, который задан на компьютере': 'Enter the password set on the computer',
113
+ 'Открыть окно подключения': 'Open pairing',
114
+ 'Отмена': 'Cancel',
115
+ 'Покажите QR второму телефону': 'Show this QR to the second phone',
116
+ 'Код': 'Code',
117
+ 'Пароль не задан на компьютере. Откройте DDeck → «Второй телефон» и задайте его.': 'No password is set on the computer. Open DDeck → “Second phone” and set one.',
118
+ 'Неверный пароль': 'Wrong password',
119
+ 'Не удалось открыть подключение': 'Could not open pairing',
120
+ 'Пароль хранится только на компьютере': 'The password stays on the computer',
121
+ }
122
+
123
+ /** Русские подписи действий и инструментов по стабильному ключу. */
124
+ const ACTION_LABELS = {
125
+ menu: 'Боковое меню',
126
+ newSession: 'Новая сессия',
127
+ search: 'Поиск сессий',
128
+ plugins: 'Плагины',
129
+ tasks: 'Автоматические задачи',
130
+ files: 'Правая панель',
131
+ }
132
+ const TOOL_LABELS = {
133
+ agents: 'Команда агентов',
134
+ subagents: 'Субагенты',
135
+ jobs: 'Фоновые задачи',
136
+ }
137
+
138
+ /** Текущий язык слоя, чтобы не перерисовывать подписи без смены языка. */
139
+ let lastLocale = ''
140
+
141
+ /** Обновить статически созданные подписи при смене языка. */
142
+ function applyLocale() {
143
+ const next = uiLang()
144
+ if (next === lastLocale) return
145
+ lastLocale = next
146
+ if (menu !== null) menu.setAttribute('aria-label', tr('Меню'))
147
+ if (scrim !== null) scrim.setAttribute('aria-label', tr('Закрыть меню'))
148
+ for (const button of document.querySelectorAll('[data-dshm-action]')) {
149
+ const label = ACTION_LABELS[button.dataset.dshmAction]
150
+ if (label === undefined) continue
151
+ button.setAttribute('aria-label', tr(label))
152
+ button.title = tr(label)
153
+ }
154
+ for (const button of document.querySelectorAll('[data-dshm-tool]')) {
155
+ const label = TOOL_LABELS[button.dataset.dshmTool]
156
+ if (label === undefined) continue
157
+ button.setAttribute('aria-label', tr(label))
158
+ button.title = tr(label)
159
+ }
160
+ for (const button of document.querySelectorAll('[data-dshm-grid]')) {
161
+ button.setAttribute('aria-label', tr('Все инструменты'))
162
+ button.title = tr('Все инструменты')
163
+ }
164
+ const toggle = document.querySelector('.dshm-dock-toggle')
165
+ if (toggle !== null) {
166
+ const label = document.documentElement.getAttribute('data-dshm-dock-collapsed') === '1'
167
+ ? tr('Развернуть панель инструментов') : tr('Свернуть панель инструментов')
168
+ toggle.setAttribute('aria-label', label)
169
+ toggle.title = label
170
+ }
171
+ const collapse = document.querySelector('[data-dshm-collapse]')
172
+ if (collapse !== null) {
173
+ collapse.setAttribute('aria-label', tr('Свернуть боковую панель'))
174
+ const span = collapse.querySelector('span')
175
+ if (span !== null) span.textContent = tr('Свернуть панель')
176
+ }
177
+ const hint = document.querySelector('.dshm-dock-hint')
178
+ if (hint !== null) hint.textContent = tr('Листайте панель · удерживайте иконку, чтобы переставить')
179
+ // Открытые меню на языке, которого больше нет, закрываем: при следующем
180
+ // открытии они соберутся на новом языке.
181
+ if (document.querySelector('.dshm-grid') !== null) closeToolGrid()
182
+ if (document.querySelector('.dshm-onboard') !== null) showOnboarding(true)
183
+ }
184
+
185
+ /** Наблюдать за сменой языка документа без перезагрузки. */
186
+ function watchLocale() {
187
+ lastLocale = uiLang()
188
+ new MutationObserver(() => applyLocale()).observe(document.documentElement, {
189
+ attributes: true,
190
+ attributeFilter: ['lang'],
191
+ })
192
+ }
193
+
194
+ /**
195
+ * Перевести подпись слоя.
196
+ *
197
+ * @param {string} text русская подпись.
198
+ * @returns {string} подпись на языке интерфейса.
199
+ */
200
+ function tr(text) {
201
+ return uiLang() === 'en' ? (EN_TEXT[text] ?? text) : text
202
+ }
203
+
204
+ /* ── 1. СОСТОЯНИЕ И ОБЩИЕ ПОМОЩНИКИ ───────────────────────────────────────
205
+ * Флаги состояния слоя, DOM-помощники, подписи кнопок, снятие фокуса и два
206
+ * флага для замеров. Всё остальное ниже опирается на эти функции.
207
+ */
208
+
209
+ /**
210
+ * Открыта ли панель.
211
+ *
212
+ * Клиент не сообщает состояние ни атрибутом, ни устойчивым классом, а подписи
213
+ * переводимы. Единственный надёжный источник — то, что сделали мы сами.
214
+ */
215
+ let drawerOpen = false
216
+
217
+ /** Отложенная повторная попытка открыть ящик, когда дерево ещё не отрисовано. */
218
+ let drawerRetryTimer = 0
219
+
220
+ /** Когда ящик открыли: пустой клиентский ящик закрываем только после паузы. */
221
+ let drawerOpenedAt = 0
222
+
223
+ /**
224
+ * Есть ли в панели клиента хоть какое-то содержимое.
225
+ *
226
+ * Пустая/недорисованная колонка выглядит как «мёртвое меню»; показывать её нельзя.
227
+ *
228
+ * @returns {boolean} дерево, строки панелей или кнопка новой сессии.
229
+ */
230
+ function hasSidebarContent() {
231
+ const sidebar = document.querySelector(':is([class$="_sidebarCol"], [class*="_sidebarCol "])')
232
+ if (sidebar === null) return false
233
+ return [...sidebar.querySelectorAll('[role=treeitem], :is([class$="_newSession"], [class*="_newSession "])')]
234
+ .some((element) => {
235
+ const rect = element.getBoundingClientRect()
236
+ const style = getComputedStyle(element)
237
+ return rect.width > 0 && rect.height > 0 && style.visibility !== 'hidden' && style.display !== 'none'
238
+ })
239
+ }
240
+
241
+ /** Открыта ли правая панель клиента (следим по нажатиям её кнопок). */
242
+ let rightPanelVisible = false
243
+
244
+ let scrim = null
245
+ let pageScrim = null
246
+ let accountScrim = null
247
+
248
+
249
+ let menu = null
250
+
251
+ /** Привести панель и модель в согласие: один клик по кнопке клиента на переход. */
252
+ let suppressCloseUntil = 0
253
+
254
+ /**
255
+ * Окно, в котором авт фокус поля ввода подавляется.
256
+ *
257
+ * Клиент, открывая сессию, сам ставит курсор в композер. На телефоне это
258
+ * выглядит как моргнувшая клавиатура: замер — focus в композере через 80 мс
259
+ * после касания строки, высота окна 421 на 297-й мс (клавиатура выехала),
260
+ * снова 800 на 521-й (наш blur). Подавляем фокус сразу при событии focusin:
261
+ * проверено на телефоне — клавиатура не появляется вовсе.
262
+ */
263
+ let suppressTextFocusUntil = 0
264
+
265
+ /**
266
+ * Строка сессии, выбранная первым касанием.
267
+ *
268
+ * На настольном клиенте закрепить/архивировать сессию можно с наведения мыши.
269
+ * На телефоне наведения нет, а одиночное касание сразу открывало диалог, и
270
+ * добраться до действий было нечем. Поэтому первый тап по строке выбирает её
271
+ * (подсветка + кнопки действий), второй — открывает диалог. Клика мимо строки
272
+ * или закрытие панели выбор снимают.
273
+ */
274
+ let pickedRow = null
275
+
276
+ /**
277
+ * Пропустить выбор и выполнить переход программным нажатием.
278
+ *
279
+ * Программные `.click()` (возврат «назад» из вторичных экранов) должны сразу
280
+ * открывать диалог, а не выделять строку.
281
+ */
282
+ let bypassPickedUntil = 0
283
+
284
+ /**
285
+ * Прямоугольник элемента в координатах окна.
286
+ *
287
+ * @param {Element} element элемент.
288
+ * @returns {DOMRect} прямоугольник.
289
+ */
290
+ function rect(element) {
291
+ return element.getBoundingClientRect()
292
+ }
293
+
294
+ /**
295
+ * Нарисован ли элемент: у него есть ненулевая площадь.
296
+ *
297
+ * @param {Element} element элемент.
298
+ * @param {boolean} [height] требовать ли ненулевую высоту (по умолчанию да).
299
+ * @returns {boolean} есть ли у элемента видимая площадь.
300
+ */
301
+ function visible(element, height = true) {
302
+ const box = rect(element)
303
+ return box.width > 0 && (height === false || box.height > 0)
304
+ }
305
+
306
+ /**
307
+ * Виден ли элемент в текущем окне, а не просто существует в разметке.
308
+ *
309
+ * Кнопка правой панели существует и когда панель разложена, но лежит за правым
310
+ * краем (x ≈ 670 при экране 360) — её надо отличать от той, что на экране.
311
+ *
312
+ * @param {Element} element элемент.
313
+ * @param {boolean} [height] требовать ли ненулевую высоту.
314
+ * @returns {boolean} элемент на экране.
315
+ */
316
+ function onScreen(element, height = true) {
317
+ if (!visible(element, height)) return false
318
+ const box = rect(element)
319
+ return box.x >= 0 && box.x < window.innerWidth
320
+ }
321
+
322
+ /**
323
+ * Все кнопки в поддереве.
324
+ *
325
+ * @param {ParentNode} [root] где искать (по умолчанию документ).
326
+ * @returns {HTMLButtonElement[]} кнопки в порядке документа.
327
+ */
328
+ function buttons(root = document) {
329
+ return [...root.querySelectorAll('button')]
330
+ }
331
+
332
+ /**
333
+ * Кнопка, подпись которой удовлетворяет шаблону.
334
+ *
335
+ * Подписи интерфейса переводимы, поэтому ищем не точный текст, а шаблон. Шаблоны
336
+ * ниже содержат русские, английские и китайские варианты: с русским интерфейсом
337
+ * часть строк клиента остаётся английской.
338
+ *
339
+ * @param {RegExp} pattern шаблон подписи.
340
+ * @param {ParentNode} [root] где искать.
341
+ * @param {{ text?: boolean }} [options] искать и по видимому тексту, а не только по aria-label.
342
+ * @returns {HTMLButtonElement|undefined} найденная кнопка.
343
+ */
344
+ function findButton(pattern, root = document, { text = false } = {}) {
345
+ return buttons(root).find((button) => {
346
+ const aria = (button.getAttribute('aria-label') || '').trim()
347
+ if (!text) return pattern.test(aria)
348
+ return pattern.test(`${button.innerText || ''} ${aria}`.trim())
349
+ })
350
+ }
351
+
352
+ /**
353
+ * Поле, из-за которого поднимается клавиатура.
354
+ *
355
+ * Снимать фокус только с редактора мало: на экране автозадач есть поле поиска
356
+ * (`input`), и после него клавиатура оставалась висеть над панелью.
357
+ */
358
+ function isTextEntry(element) {
359
+ if (!(element instanceof HTMLElement)) return false
360
+ if (element.isContentEditable) return true
361
+ if (element instanceof HTMLInputElement) return !/^(button|checkbox|radio|submit|range)$/u.test(element.type)
362
+ return element instanceof HTMLTextAreaElement
363
+ }
364
+
365
+ /**
366
+ * Снять фокус с поля ввода через несколько задержек.
367
+ *
368
+ * Клиент может поставить фокус не сразу и не один раз: разметка перерисовывается
369
+ * после касания. Одна задержка ловила клавиатуру не всегда, поэтому мест было
370
+ * четыре с одинаковым циклом — теперь он один.
371
+ *
372
+ * @param {number[]} delays задержки в миллисекундах.
373
+ * @param {boolean} [ignoreNoBlur] снимать фокус даже под флагом замеров __dshmNoBlur.
374
+ */
375
+ function scheduleBlur(delays, ignoreNoBlur = false) {
376
+ for (const delay of delays) {
377
+ setTimeout(() => {
378
+ if (!ignoreNoBlur && noBlur()) return
379
+ const active = document.activeElement
380
+ if (isTextEntry(active)) active.blur()
381
+ }, delay)
382
+ }
383
+ }
384
+
385
+ /**
386
+ * Пауза фоновых таймеров — для замеров.
387
+ *
388
+ * `window.__dshmPause = true` останавливает всю периодическую работу слоя, чтобы
389
+ * отделить свою стоимость от стоимости клиента.
390
+ */
391
+ function paused() {
392
+ return window.__dshmPause === true
393
+ }
394
+
395
+ /** Флаг для замеров: не снимать фокус (проверяем его стоимость). */
396
+ function noBlur() {
397
+ return window.__dshmNoBlur === true
398
+ }
399
+
400
+ /* ── 2. БОКОВАЯ ПАНЕЛЬ И ВЫБОР СТРОКИ СЕССИИ ─────────────────────────────
401
+ * На телефоне панель клиента заменена нашим выдвижным ящиком: клиентская
402
+ * колонка сдвинута за край, но её дерево остаётся в DOM — список сессий,
403
+ * группы и отметки рисует клиент. Первый тап по строке выбирает её и
404
+ * показывает действия, второй — открывает диалог.
405
+ */
406
+
407
+ /**
408
+ * Закрыть панель, не дёргая родную кнопку клиента.
409
+ *
410
+ * `setDrawer(false)` кликает по кнопке сворачивания внутри колонки — и этот клик
411
+ * сбивает переход, который человек только что выбрал: экран «Плагины» не открывался.
412
+ * Здесь только наш слой: атрибут и затенение.
413
+ */
414
+ function closeDrawerLight() {
415
+ window.clearTimeout(drawerRetryTimer)
416
+ if (!drawerOpen) return
417
+ drawerOpen = false
418
+ document.documentElement.removeAttribute('data-dshm-drawer')
419
+ if (scrim !== null) scrim.setAttribute('aria-hidden', 'true')
420
+ // Вернуть правую панель/вкладки, которые прятали под ящиком.
421
+ for (const element of document.querySelectorAll('[data-dshm-under-drawer]')) element.removeAttribute('data-dshm-under-drawer')
422
+ scheduleBlur([120, 400, 900])
423
+ }
424
+
425
+ function setDrawer(open) {
426
+ if (!open) {
427
+ clearPickedRow()
428
+ window.clearTimeout(drawerRetryTimer)
429
+ }
430
+ // Пустой ящик не должен появляться никогда: если дерево клиента не нарисовано,
431
+ // просим клиент нарисовать его до показа.
432
+ if (open) {
433
+ keepClientPanelOpen()
434
+ // Пустой ящик — это «мёртвое» меню: клиент ещё не отрисовал дерево
435
+ // (переход/перезагрузка). Не показываем его; пробуем ещё раз чуть позже.
436
+ if (!hasSidebarContent()) {
437
+ window.clearTimeout(drawerRetryTimer)
438
+ drawerRetryTimer = window.setTimeout(() => {
439
+ if (!drawerOpen && hasSidebarContent()) setDrawer(true)
440
+ }, 300)
441
+ return
442
+ }
443
+ // Левое меню — единственный видимый слой: правую панель/вкладки/журнал и
444
+ // шторки прячем (не закрывая, чтобы не терять состояние), иначе интерфейс
445
+ // выглядит «слоёным». После закрытия меню они возвращаются как были.
446
+ if (typeof closeToolSheet === 'function') closeToolSheet()
447
+ if (document.querySelector('.dshm-grid') !== null) closeToolGrid()
448
+ for (const element of document.querySelectorAll('[class*="tabHost"], [data-sidebar-right-panel]')) {
449
+ element.dataset.dshmUnderDrawer = '1'
450
+ }
451
+ }
452
+ // Открывая панель, убираем фокус из поля ввода.
453
+ //
454
+ // Иначе клавиатура остаётся поднятой и накрывает панель: человек набрал текст,
455
+ // открыл меню — и вместо списка сессий видит половину экрана. Проверено: высота
456
+ // страницы падала с 800 до 421, фокус оставался в редакторе.
457
+ if (open && document.activeElement instanceof HTMLElement) document.activeElement.blur()
458
+ if (open === drawerOpen) {
459
+ if (open) document.documentElement.setAttribute('data-dshm-drawer', '')
460
+ else document.documentElement.removeAttribute('data-dshm-drawer')
461
+ return
462
+ }
463
+ // Родную кнопку больше не нажимаем: панель клиента держится открытой постоянно
464
+ // (см. keepClientPanelOpen), а её переключение на каждое открытие заставляло клиент
465
+ // заново строить дерево — замер показал кадр 175 мс и 257 мс длинных задач.
466
+ // Наш drawer — только CSS-состояние.
467
+ drawerOpen = open
468
+ if (open) {
469
+ drawerOpenedAt = Date.now()
470
+ document.documentElement.setAttribute('data-dshm-drawer', '')
471
+ // Не ждём медленного тика: верхние строки и нижняя навигация должны быть
472
+ // приведены к мобильному виду сразу при открытии панели.
473
+ ensureBottomSidebarNav()
474
+ }
475
+ else document.documentElement.removeAttribute('data-dshm-drawer')
476
+ if (!open) {
477
+ for (const element of document.querySelectorAll('[data-dshm-under-drawer]')) element.removeAttribute('data-dshm-under-drawer')
478
+ }
479
+ if (scrim !== null) scrim.setAttribute('aria-hidden', open ? 'false' : 'true')
480
+ // Закрытие панели тоже не должно поднимать клавиатуру: человек убрал меню, а не
481
+ // собрался печатать. Проверено: после закрытия высота падала до 421.
482
+ if (!open) scheduleBlur([120, 400, 900], true)
483
+ }
484
+
485
+ function closeDrawer() {
486
+ setDrawer(false)
487
+ }
488
+
489
+ /** Снять выбор со строки сессии. */
490
+ function clearPickedRow() {
491
+ if (pickedRow === null) return
492
+ pickedRow.removeAttribute('data-dshm-picked')
493
+ pickedRow = null
494
+ }
495
+
496
+ /**
497
+ * Выбрать строку сессии первым касанием.
498
+ *
499
+ * @param {Element} row строка сессии ([role=treeitem] без aria-expanded).
500
+ */
501
+ function pickRow(row) {
502
+ clearPickedRow()
503
+ pickedRow = row
504
+ row.setAttribute('data-dshm-picked', '')
505
+ }
506
+
507
+ /**
508
+ * Двухшаговый выбор сессии: первый тап показывает действия, второй открывает.
509
+ *
510
+ * Перехват идёт в фазе до React: событие, которое должно только выбрать
511
+ * строку, до клиента не доходит и диалог не открывается. Второй тап свободно
512
+ * проходит дальше — клиент открывает сессию, а наш обычный обработчик
513
+ * перехода закрывает меню.
514
+ */
515
+
516
+ /**
517
+ * Кнопка «Свернуть боковую панель» внутри панели клиента закрывает наш ящик.
518
+ *
519
+ * Клиент по этой кнопке прячет собственную панель. На телефоне это значит, что
520
+ * дерево сессий исчезает, а наш ящик остаётся и выглядит пустым — человек
521
+ * видит «другую пустую панель» при следующем открытии. Поэтому касание
522
+ * перехватываем до клиента и закрываем то, что действительно открыто.
523
+ */
524
+ function bindPanelCollapse() {
525
+ document.addEventListener('click', (event) => {
526
+ const target = event.target instanceof Element ? event.target.closest('button') : null
527
+ if (target === null) return
528
+ if (!/^(Свернуть боковую панель|Collapse sidebar|收起侧边栏)$/u.test((target.getAttribute('aria-label') || '').trim())) return
529
+ event.preventDefault()
530
+ event.stopPropagation()
531
+ closeDrawerLight()
532
+ }, true)
533
+ }
534
+
535
+ function bindSessionPicking() {
536
+ document.addEventListener('click', (event) => {
537
+ const target = event.target instanceof Element ? event.target : null
538
+ if (target === null) return
539
+ if (Date.now() < bypassPickedUntil) {
540
+ clearPickedRow()
541
+ return
542
+ }
543
+ const row = target.closest('[role=treeitem]')
544
+ if (row === null || row.getAttribute('aria-expanded') !== null) {
545
+ clearPickedRow()
546
+ return
547
+ }
548
+ // Двухшаговый выбор — только для настоящих строк сессий (`data-row-key`
549
+ // начинается с `session:`). Остальные treeitem'ы (результаты поиска,
550
+ // строки субагентов в правой панели) не должны перехватываться: иначе
551
+ // первый тап по стрелке «открыть на боковой панели» уходил в выбор строки,
552
+ // а не в кнопку, и требовался второй тап.
553
+ const rowKey = row.getAttribute('data-row-key') ?? ''
554
+ if (!rowKey.startsWith('session:')) {
555
+ clearPickedRow()
556
+ return
557
+ }
558
+ // Кнопки действий живут внутри выбранной строки: меню открывает клиент,
559
+ // но это действие над строкой, а не переход — панель закрывать нельзя.
560
+ // Ради этого касания обычный обработчик перехода на мгновение глушится.
561
+ if (target.closest(':is([class$="_rowActions"], [class*="_rowActions "])') !== null) {
562
+ suppressCloseUntil = Date.now() + 400
563
+ return
564
+ }
565
+ if (row.hasAttribute('data-dshm-picked')) {
566
+ clearPickedRow()
567
+ return
568
+ }
569
+ event.preventDefault()
570
+ event.stopPropagation()
571
+ pickRow(row)
572
+ }, true)
573
+ }
574
+
575
+ /** Когда последний раз пытались вернуть панель клиента, мс. */
576
+ let lastPanelOpenTry = 0
577
+
578
+ /**
579
+ * Держать дерево клиента отрисованным.
580
+ *
581
+ * Панель клиента на телефоне заменена нашим слоем: она сдвинута за край, но её
582
+ * содержимое должно существовать в DOM, потому что список сессий, группы и
583
+ * отметки рисует именно клиент. Клиент умеет её закрыть (кнопка «Свернуть
584
+ * боковую панель», восстановление состояния после сворачивания приложения), и
585
+ * тогда наш выдвижной ящик открывается пустым — ровно то, что человек видит
586
+ * как «какая-то совсем другая пустая панель».
587
+ *
588
+ * Поэтому проверка повторяется, а не срабатывает один раз: если дерева нет и
589
+ * есть кнопка «Открыть боковую панель», нажимаем её (сама панель остаётся за
590
+ * краем — её показывает только наш ящик). Не чаще раза в две секунды, чтобы не
591
+ * драться с человеком за состояние.
592
+ */
593
+ function keepClientPanelOpen() {
594
+ const hasTree = document.querySelector(':is([class$="_list"], [class*="_list "]) [role=treeitem]') !== null
595
+ if (hasTree) return
596
+ const toggle = findButton(/Открыть боковую панель|Open sidebar|打开侧边栏/iu)
597
+ if (toggle === undefined) return
598
+ if (Date.now() - lastPanelOpenTry < 2000) return
599
+ lastPanelOpenTry = Date.now()
600
+ suppressCloseUntil = Date.now() + 500
601
+ toggle.click()
602
+ }
603
+
604
+ /** Спрятать строку статистики под композером. */
605
+
606
+ /** Создать кнопку меню и затенение под панелью один раз. */
607
+ function build() {
608
+ if (menu === null) {
609
+ menu = document.createElement('button')
610
+ menu.type = 'button'
611
+ menu.className = 'dshm-menu'
612
+ menu.setAttribute('aria-label', tr('Меню'))
613
+ menu.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 7h16M4 12h16M4 17h16"/></svg>'
614
+ menu.addEventListener('click', () => { setDrawer(true) })
615
+ }
616
+ if (pageScrim === null) {
617
+ pageScrim = document.createElement('div')
618
+ pageScrim.className = 'dshm-page-scrim'
619
+ pageScrim.setAttribute('aria-hidden', 'true')
620
+ }
621
+ if (pageScrim.parentElement !== document.body) document.body.appendChild(pageScrim)
622
+ if (scrim === null) {
623
+ scrim = document.createElement('button')
624
+ scrim.type = 'button'
625
+ scrim.className = 'dshm-scrim'
626
+ scrim.setAttribute('aria-label', tr('Закрыть меню'))
627
+ scrim.setAttribute('aria-hidden', 'true')
628
+ scrim.addEventListener('click', closeDrawer)
629
+ }
630
+ // Оба узла живут на body: панель, из которой открывается меню, уезжает за край.
631
+ if (menu.parentElement !== document.body) {
632
+ document.body.appendChild(scrim)
633
+ document.body.appendChild(menu)
634
+ }
635
+ }
636
+
637
+ /* ── 3. ФОКУС И КЛАВИАТУРА ─────────────────────────────────────────────
638
+ * Клиент фокусирует композер программно при открытии сессии, и на телефоне
639
+ * это выглядит как моргнувшая клавиатура. Гасим авт фокус в самом событии
640
+ * focusin, а терминалу xterm, наоборот, показываем клавиатуру по касанию.
641
+ */
642
+
643
+ /** Не давать клиенту увести фокус в поле ввода после перехода. */
644
+ function suppressAutofocus(ms = 1800) {
645
+ suppressTextFocusUntil = Date.now() + ms
646
+ }
647
+
648
+ /**
649
+ * Гасить авт фокус поля ввода, пока открыто окно подавления.
650
+ *
651
+ * Клиент фокусирует композер программно при открытии сессии, и на телефоне это
652
+ * поднимает клавиатуру. Гасим не отложенно, а в самом событии focusin: только
653
+ * тогда клавиатура не успевает выехать. Касание самого человека по полю ввода
654
+ * снимает окно — если он собирается печатать, мешать нечему.
655
+ */
656
+ function guardAutofocus() {
657
+ document.addEventListener('focusin', (event) => {
658
+ if (Date.now() > suppressTextFocusUntil) return
659
+ const target = event.target
660
+ if (isTextEntry(target)) target.blur()
661
+ }, true)
662
+ document.addEventListener('pointerdown', (event) => {
663
+ const target = event.target instanceof Element ? event.target : null
664
+ if (target === null) return
665
+ if (target.closest('[data-lexical-editor], [contenteditable], input, textarea')) {
666
+ suppressTextFocusUntil = 0
667
+ }
668
+ }, true)
669
+ }
670
+
671
+ /**
672
+ * «Плюс» и скрепка в композере вызывают `keepFocus` и программно фокусируют
673
+ * редактор даже при закрытой клавиатуре. На Android это поднимает клавиатуру.
674
+ * Ставим окно подавления до React-обработчика: меню откроется, а фокус не
675
+ * успеет поднять IME.
676
+ */
677
+ function guardComposerMenuFocus() {
678
+ const isTrigger = (target) => {
679
+ if (!(target instanceof Element)) return false
680
+ if (target.closest(':is([class$="_composerStack"], [class*="_composerStack "])') === null) return false
681
+ return target.closest('button[class*="_add"], button[aria-label*="Добавить файлы"], button[aria-label*="выполнить команды"], button[aria-label*="Add files"], button[aria-label*="run commands"]') !== null
682
+ }
683
+ let restoreFocus = null
684
+ const patchEditorFocus = () => {
685
+ if (restoreFocus !== null) return
686
+ const root = document.querySelector('[contenteditable="true"][data-composer-input]')
687
+ if (root === null || document.activeElement === root) return
688
+ const original = root.focus
689
+ root.focus = () => {}
690
+ restoreFocus = () => {
691
+ if (root.isConnected && root.focus !== original) root.focus = original
692
+ restoreFocus = null
693
+ }
694
+ // React обрабатывает mousedown и вызывает focus() сразу после него;
695
+ // держим заглушку до окончания клика/открытия меню.
696
+ window.setTimeout(() => restoreFocus?.(), 350)
697
+ }
698
+ const guard = (event) => {
699
+ if (!isTrigger(event.target)) return
700
+ patchEditorFocus()
701
+ suppressTextFocusUntil = Date.now() + 900
702
+ }
703
+ const releaseOnEditor = (event) => {
704
+ if (event.target instanceof Element && event.target.closest('[contenteditable="true"][data-composer-input]') !== null) restoreFocus?.()
705
+ }
706
+ document.addEventListener('pointerdown', guard, true)
707
+ document.addEventListener('touchstart', guard, true)
708
+ document.addEventListener('mousedown', guard, true)
709
+ document.addEventListener('pointerdown', releaseOnEditor, true)
710
+ // Второе нажатие на плюс должно закрывать меню, а не открывать его снова.
711
+ document.addEventListener('click', (event) => {
712
+ if (!isTrigger(event.target)) return
713
+ const menu = [...document.querySelectorAll('[role=listbox], [role=menu]')]
714
+ .find((element) => {
715
+ const box = element.getBoundingClientRect()
716
+ return box.width > 100 && box.height > 40
717
+ })
718
+ if (menu === undefined) return
719
+ event.preventDefault()
720
+ event.stopImmediatePropagation()
721
+ closeClientPopover()
722
+ }, true)
723
+ }
724
+
725
+ /** Клик по кнопке не оставляет клавиатуру. */
726
+ function blurOnButtonClick() {
727
+ document.addEventListener('click', (event) => {
728
+ const target = event.target
729
+ if (!(target instanceof Element)) return
730
+ if (target.closest('[contenteditable="true"], input, textarea')) return
731
+ // Поле поиска по сессиям открывается само и сразу получает фокус. Общее
732
+ // правило «кнопка убирает клавиатуру» срабатывало через 180 мс после
733
+ // нажатия на поиск: клавиатура поднималась и тут же падала, а клиент
734
+ // успевал закрыть окно поиска. Не трогаем фокус для всей группы поиска.
735
+ if (target.closest(':is([class$="_search"], [class*="_search "]), :is([class$="_searchButton"], [class*="_searchButton "]), :is([class$="_searchInput"], [class*="_searchInput "])')) return
736
+ // Пока поиск раскрыт, касания внутри боковой панели — это результаты
737
+ // или скролл списка. Снимать фокус здесь нельзя: клиент закрывает окно
738
+ // поиска, и список схлопывается прямо во время прокрутки.
739
+ const query = document.querySelector('input[placeholder="Поиск сессий"]')
740
+ if (query !== null && query.getBoundingClientRect().width > 100
741
+ && target.closest(':is([class$="_sidebarCol"], [class*="_sidebarCol "])') !== null) return
742
+ // Кнопки композера и меню «плюс» не должны снимать фокус: иначе
743
+ // клавиатура моргает при каждом открытии/закрытии поповера.
744
+ if (target.closest('[class*="_composerStack"], [class*="_composer"], [data-dshm-tool], [data-dshm-action], .dshm-tool, .dshm-dock-toggle, [aria-label*="Добавить файлы"], [aria-label*="выполнить команды"], [aria-label*="Add files"], [aria-label*="commands"]')) return
745
+ // Крестик очистки, наоборот, должен убрать клавиатуру: поиск закрыт,
746
+ // оставшийся фокус в невидимом поле снова поднимал бы её.
747
+ if (target.closest(':is([class$="_clearButton"], [class*="_clearButton "])')) {
748
+ scheduleBlur([0, 180])
749
+ return
750
+ }
751
+ // Касание терминала — тоже click по div, и общее «клик убирает клавиатуру»
752
+ // снимало фокус с xterm сразу после того, как он его получил: клавиатура
753
+ // закрывалась через 180 мс (проверено: activeElement пуст, innerHeight 800).
754
+ if (target.closest('.xterm, [data-sidebar-terminal]')) return
755
+ scheduleBlur([180, 600])
756
+ }, true)
757
+ }
758
+
759
+ /**
760
+ * Открыть клавиатуру при касании терминала.
761
+ *
762
+ * xterm.js фокусирует свой скрытый textarea вне жеста касания, и Android-WebView
763
+ * клавиатуру в этом случае не показывает, а наше «клик убирает клавиатуру» ещё и
764
+ * снимало фокус. Слушатель один на документ (касание в терминале бывает редко):
765
+ * раньше проверка `document.querySelectorAll('.xterm')` шла каждые 800 мс, и на
766
+ * наборе текста это давало кадр 33 мс вместо 9. Фокус ставим с задержкой 80 мс и
767
+ * повторяем в 300 мс, если xterm успел его снять, и просим мост показать клавиатуру.
768
+ */
769
+ function installTerminalKeyboard() {
770
+ document.addEventListener('pointerdown', (event) => {
771
+ const target = event.target
772
+ if (!(target instanceof Element)) return
773
+ const terminal = target.closest('.xterm')
774
+ if (terminal === null) return
775
+ const input = terminal.querySelector('textarea')
776
+ if (input === null) return
777
+ const showKeyboard = () => {
778
+ input.focus()
779
+ invokeBridge('keyboard.show', 'keyboard.show', {}, true)
780
+ }
781
+ setTimeout(showKeyboard, 80)
782
+ setTimeout(() => {
783
+ if (document.activeElement !== input) showKeyboard()
784
+ }, 300)
785
+ }, { capture: true })
786
+ }
787
+
788
+ /* ── 3b. НЕ АВТОФОКУСИТЬ ПОИСК ──────────────────────────────────────────
789
+ * Клиент при раскрытии поиска ставит фокус в поле, из-за чего клавиатура
790
+ * выезжает в середине анимации и вызывает заметный лаг. Поле должно
791
+ * раскрыться без клавиатуры; человек сам коснётся его, когда захочет печатать.
792
+ */
793
+ let searchFocusGuardUntil = 0
794
+
795
+ function installSearchFocusGuard() {
796
+ document.addEventListener('click', (event) => {
797
+ const target = event.target instanceof Element ? event.target : null
798
+ if (target === null) return
799
+ if (target.closest(':is([class$="_searchButton"], [class*="_searchButton "]), [data-dshm-action="search"]') === null) return
800
+ searchFocusGuardUntil = Date.now() + 900
801
+ window.setTimeout(() => {
802
+ if (Date.now() < searchFocusGuardUntil) document.activeElement?.blur()
803
+ }, 30)
804
+ window.setTimeout(() => {
805
+ if (Date.now() < searchFocusGuardUntil) document.activeElement?.blur()
806
+ }, 180)
807
+ }, true)
808
+ document.addEventListener('pointerdown', (event) => {
809
+ const target = event.target instanceof Element ? event.target : null
810
+ if (target === null) return
811
+ if (target.closest('input[placeholder="Поиск сессий"]') !== null) searchFocusGuardUntil = 0
812
+ }, true)
813
+ document.addEventListener('focusin', (event) => {
814
+ if (Date.now() > searchFocusGuardUntil) return
815
+ const target = event.target
816
+ if (target instanceof HTMLInputElement && target.placeholder === 'Поиск сессий') target.blur()
817
+ }, true)
818
+ }
819
+
820
+ /* ── 4. НАВИГАЦИЯ И СИСТЕМНАЯ «НАЗАД» ───────────────────────────────────
821
+ * Приложение шлёт странице cancelable-событие dsh-mobile:native-back и
822
+ * уходит только если никто не позвал preventDefault. Порядок закрытия:
823
+ * наша панель Remote, поповер клиента, правая панель, модальное окно,
824
+ * диалог, детальный экран, вторичная страница, боковое меню. В самом
825
+ * диалоге «назад» открывает меню — привычный жест.
826
+ */
827
+
828
+ /**
829
+ * Вернуться из вторичного экрана в диалог и открыть меню.
830
+ *
831
+ * «Назад» на телефоне возвращает туда, откуда пришли: человек открыл «Плагины»
832
+ * из бокового меню — назад ждёт то же меню, а не выход из приложения. Клиент не
833
+ * использует history API (замер: history.length = 1 на любой странице), поэтому
834
+ * переход делается нажатием на строку текущей сессии — тот же путь, которым он
835
+ * ходит в диалог сам.
836
+ *
837
+ * @returns {boolean} удалось ли вернуться (false — строки текущей сессии нет).
838
+ */
839
+ function returnToChat() {
840
+ let current = ''
841
+ try { current = JSON.parse(localStorage.getItem('dsh.sessions.current') || '{}').sessionId || '' } catch {}
842
+ /** Найти строку текущей сессии среди отрисованных. */
843
+ const findRow = () => document.querySelector(`[role=treeitem][data-row-key="session:${current}"]`)
844
+ /**
845
+ * Перейти в переписку нажатием строки — тем же путём, которым ходит клиент.
846
+ *
847
+ * @param {Element} row строка текущей сессии.
848
+ * @returns {boolean} всегда true: жест обработан.
849
+ */
850
+ const go = (row) => {
851
+ suppressAutofocus()
852
+ suppressCloseUntil = Date.now() + 1500
853
+ // Меню открываем ДО перехода: клиент рисует переписку около секунды и
854
+ // блокирует главный поток, а выезд панели идёт на композиторе. Открытая
855
+ // сразу панель — это немедленный отклик на «назад»; иначе человек секунду
856
+ // смотрит на замерший экран и только потом видит анимацию. Замер до правки:
857
+ // переход в переписку 1073 мс, меню 1265 мс после нажатия.
858
+ setDrawer(true)
859
+ bypassPickedUntil = Date.now() + 200
860
+ row.click()
861
+ // Страховка: если клиент всё-таки сбросит состояние, вернуть меню.
862
+ setTimeout(() => { if (!paused() && !drawerOpen) setDrawer(true) }, 700)
863
+ return true
864
+ }
865
+ const row = findRow()
866
+ if (row !== null) return go(row)
867
+ // Строка не отрисована: текущая сессия может лежать в свёрнутой рабочей
868
+ // области или за «Показать ещё». Раскрываем группы и пробуем снова — без
869
+ // этого «назад» со вторичного экрана оставлял человека на «Плагины» с
870
+ // открытым меню (проверено: текущая сессия в свёрнутой области).
871
+ const collapsed = [...document.querySelectorAll('[role=treeitem][aria-expanded="false"]')]
872
+ if (collapsed.length > 0) {
873
+ setDrawer(true)
874
+ suppressCloseUntil = Date.now() + 1500
875
+ for (const group of collapsed) group.click()
876
+ setTimeout(() => {
877
+ if (paused()) return
878
+ const late = findRow()
879
+ if (late !== null) go(late)
880
+ }, 350)
881
+ return true
882
+ }
883
+ // Строки нет совсем (например, сессия скрыта фильтром): остаёмся в меню,
884
+ // чтобы человек выбрал чат сам, и не даём оболочке закрыть приложение.
885
+ setDrawer(true)
886
+ return true
887
+ }
888
+
889
+ /**
890
+ * Видимое модальное окно клиента (настройки и подобные).
891
+ *
892
+ * @returns {Element|undefined} окно или undefined, если модалок на экране нет.
893
+ */
894
+ function visibleClientModal() {
895
+ return [...document.querySelectorAll('[data-shortcut-modal]')].find((element) => visible(element))
896
+ }
897
+
898
+ /** Закрыть модальное окно клиента — кнопкой, которую он сам рисует. */
899
+ function closeClientModal() {
900
+ const modal = visibleClientModal()
901
+ if (modal === undefined) return false
902
+ // Две схемы имён: client-модули дают `_hash_close`, примитивы — `_close_hash_line`.
903
+ const close = modal.querySelector('[class*="_close"]')
904
+ ?? findButton(/^(Закрыть|Close|关闭)$/u, modal)
905
+ if (close === null || close === undefined) return false
906
+ close.click()
907
+ return true
908
+ }
909
+
910
+ /**
911
+ * Закрыть диалог клиента, у которого нет своей кнопки закрытия-крестика.
912
+ *
913
+ * Диалоги вроде «Переименовать сессию» объявляют `role="dialog"` и закрываются
914
+ * кнопкой «Отмена». Без этого «назад» закрывал боковое меню под диалогом, а сам
915
+ * диалог оставался висеть поверх диалога — и следующее нажатие снова открывало
916
+ * меню. Настройки сюда не попадают: у них `data-shortcut-modal` и свой крестик,
917
+ * их закрывает [closeClientModal].
918
+ *
919
+ * @returns {boolean} закрыли ли диалог.
920
+ */
921
+ function closeClientDialog() {
922
+ const dialogs = [...document.querySelectorAll('[role=dialog]')].filter((element) => {
923
+ if (element.id === 'dshm-remote-panel') return false
924
+ if (element.hasAttribute('data-shortcut-modal')) return false
925
+ return visible(element)
926
+ })
927
+ // Диалоги накладываются друг на друга (статистика, токены, контекст, команда
928
+ // агентов открываются из панели и не закрываются сами): «назад» должен снять
929
+ // верхний, а не искать первый в DOM.
930
+ const dialog = dialogs[dialogs.length - 1]
931
+ if (dialog === undefined) return false
932
+ const cancel = findButton(/^(Отмена|Cancel|Закрыть|Close|取消|关闭)$/u, dialog, { text: true })
933
+ if (cancel !== undefined) {
934
+ cancel.click()
935
+ return true
936
+ }
937
+ const close = dialog.querySelector('[class*="_close"], button[aria-label*="Закрыть"], button[aria-label*="Close"], button[aria-label*="关闭"]')
938
+ if (close !== null) {
939
+ close.click()
940
+ return true
941
+ }
942
+ // У панелей статистики нет своей кнопки: клиент закрывает их по Escape.
943
+ // Событие уходит в документ, поэтому страница ведёт себя как от нажатия Esc.
944
+ dialog.dispatchEvent(new KeyboardEvent('keydown', {
945
+ key: 'Escape', code: 'Escape', keyCode: 27, which: 27, bubbles: true, cancelable: true,
946
+ }))
947
+ return true
948
+ }
949
+
950
+ /**
951
+ * Вернуться из детального экрана в его список.
952
+ *
953
+ * У страницы плагина/задачи есть своя кнопка-крошка «К списку плагинов»; системная
954
+ * «назад» до неё не доходит и без этого открывала боковое меню поверх деталей.
955
+ *
956
+ * @returns {boolean} был ли детальный экран.
957
+ */
958
+ function closeClientDetail() {
959
+ const back = buttons().find((element) => {
960
+ if (!visible(element)) return false
961
+ const label = (element.getAttribute('aria-label') || element.innerText || '').trim()
962
+ // Детали автозадачи закрываются кнопкой «Закрыть подробности», у плагина —
963
+ // крошкой «К списку …».
964
+ return /^(К списку|Back to |Закрыть подробности|Close details|返回|关闭详情)/u.test(label)
965
+ })
966
+ if (back === undefined) return false
967
+ back.click()
968
+ return true
969
+ }
970
+
971
+ /**
972
+ * Вторичный экран клиента: плагины, автоматические задачи и подобные страницы.
973
+ *
974
+ * Своего признака маршрута у них нет (адрес остаётся /m), но есть заголовок
975
+ * страницы — его рисуют только такие экраны. У чата заголовка страницы нет.
976
+ */
977
+ function onSecondaryScreen() {
978
+ for (const selector of [':is([class$="_pageTitle"], [class*="_pageTitle "])', ':is([class$="_pageHeading"], [class*="_pageHeading "])']) {
979
+ const element = document.querySelector(selector)
980
+ if (element !== null && rect(element).width > 0) return true
981
+ }
982
+ return false
983
+ }
984
+
985
+ /**
986
+ * Закрыть открытый поповер клиента — меню команд, выбора модели и подобные.
987
+ *
988
+ * Системная «назад» до поповеров клиента не доходит: он закрывает их касанием
989
+ * в любом другом месте страницы. Без этого «назад» с открытым меню композера
990
+ * открывал поверх него боковое меню приложения. Шлём pointerdown в шапку —
991
+ * безопасную точку: своего обработчика у неё нет, а меню закрывается.
992
+ *
993
+ * @returns {boolean} был ли открыт поповер.
994
+ */
995
+ function closeClientPopover() {
996
+ const popover = [...document.querySelectorAll('[role=menu],[role=listbox]')]
997
+ .find((element) => {
998
+ const box = rect(element)
999
+ return visible(element) && box.x < window.innerWidth && box.right > 0
1000
+ })
1001
+ if (popover === undefined) return false
1002
+ const neutral = document.querySelector('header') ?? document.body
1003
+ neutral.dispatchEvent(new PointerEvent('pointerdown', {
1004
+ bubbles: true,
1005
+ cancelable: true,
1006
+ pointerType: 'touch',
1007
+ clientX: 180,
1008
+ clientY: 60,
1009
+ }))
1010
+ return true
1011
+ }
1012
+
1013
+ /**
1014
+ * Закрыть правую панель, если она открыта на экране.
1015
+ *
1016
+ * Панель открывается тапом по файлу/вкладке и накрывает диалог; на телефоне у
1017
+ * неё нет привычного места, а системная «назад» должна возвращать в диалог, а
1018
+ * не открывать боковое меню поверх панели.
1019
+ *
1020
+ * @returns {boolean} была ли панель открыта и закрыта.
1021
+ */
1022
+ function closeRightPanel() {
1023
+ const toggle = [...document.querySelectorAll('[data-sidebar-right-toggle]')]
1024
+ // Только та, что действительно на экране: кнопка существует и когда панель
1025
+ // разложена, но лежит за правым краем (x ≈ 670 при экране 360).
1026
+ .find((element) => onScreen(element))
1027
+ if (toggle === undefined) return false
1028
+ toggle.click()
1029
+ return true
1030
+ }
1031
+
1032
+ /**
1033
+ * Закрыть правую панель, если в ней не осталось ни одной вкладки.
1034
+ *
1035
+ * Проверено: после закрытия последней вкладки (терминал) клиент оставляет панель
1036
+ * открытой пустой — ни гида «Начало», ни вкладок, только полноэкранная пустота
1037
+ * поверх чата. Новый гид клиент сам не сеет (и после перезагрузки тоже). Пустую
1038
+ * панель закрываем: человек возвращается в чат, а нужную поверхность открывает
1039
+ * обычной карточкой входа.
1040
+ */
1041
+ function closeEmptyRightPanel() {
1042
+ const toggle = [...document.querySelectorAll('[data-sidebar-right-toggle]')]
1043
+ .find((element) => onScreen(element, false))
1044
+ if (toggle === undefined) return
1045
+ if (document.querySelectorAll('[data-dockkit-tab]').length > 0) return
1046
+ const panel = document.querySelector('[data-sidebar-right-panel]')
1047
+ if (panel === null) return
1048
+ if ((panel.innerText || '').trim() !== '') return
1049
+ closeRightPanel()
1050
+ }
1051
+
1052
+ /**
1053
+ * Горизонтальная лента вкладок настроек должна доскролливаться до выбранной
1054
+ * вкладки. На узком экране «Пресеты агента» и «Встроенные» находятся за краем.
1055
+ */
1056
+ function bindSettingsTabsScroll() {
1057
+ document.addEventListener('click', (event) => {
1058
+ const target = event.target instanceof Element ? event.target : null
1059
+ const cell = target?.closest('[class*="_navCell"]')
1060
+ if (cell === null || cell === undefined) return
1061
+ window.setTimeout(() => {
1062
+ const active = [...document.querySelectorAll('[class*="_navCell"][class*="_active"]')]
1063
+ .find((element) => element.getBoundingClientRect().width > 0)
1064
+ if (active === undefined) return
1065
+ active.scrollIntoView({ block: 'nearest', inline: 'center' })
1066
+ }, 30)
1067
+ }, true)
1068
+ }
1069
+
1070
+ /**
1071
+ * На телефоне одно касание строки сессии должно открывать сессию.
1072
+ *
1073
+ * Клиент на настольном hover сначала показывает действия строки, и первое
1074
+ * касание только выделяет строку. Второе открывает её. На телефоне это
1075
+ * выглядит как «кнопка не работает». Для обычного тапа (без скролла и без
1076
+ * нажатия на action-кнопки) повторяем клик программно.
1077
+ */
1078
+ function bindSessionRowTap() {
1079
+ let start = null
1080
+ let suppressUntil = 0
1081
+ document.addEventListener('click', (event) => {
1082
+ if (Date.now() > suppressUntil) return
1083
+ if (event.target instanceof Element && event.target.closest('[role=treeitem]') !== null) {
1084
+ event.preventDefault()
1085
+ event.stopPropagation()
1086
+ }
1087
+ }, true)
1088
+ document.addEventListener('pointerdown', (event) => {
1089
+ if (event.pointerType !== 'touch') return
1090
+ const target = event.target instanceof Element ? event.target : null
1091
+ if (target === null) return
1092
+ const row = target.closest('[role=treeitem]')
1093
+ if (row === null || !String(row.className).includes('sessionRow')) return
1094
+ if (target.closest('button, [role=button], a') !== null) return
1095
+ const state = { x: event.clientX, y: event.clientY, row, long: false, moved: false, timer: 0 }
1096
+ state.timer = window.setTimeout(() => {
1097
+ state.long = true
1098
+ // Долгое нажатие — это действия строки (закрепить/архивировать).
1099
+ state.row.click()
1100
+ suppressUntil = Date.now() + 800
1101
+ }, 450)
1102
+ start = state
1103
+ }, true)
1104
+ document.addEventListener('pointermove', (event) => {
1105
+ if (start === null || start.moved) return
1106
+ if (Math.abs(event.clientX - start.x) <= 10 && Math.abs(event.clientY - start.y) <= 10) return
1107
+ start.moved = true
1108
+ window.clearTimeout(start.timer)
1109
+ suppressUntil = Date.now() + 500
1110
+ }, true)
1111
+ document.addEventListener('pointercancel', () => {
1112
+ if (start === null) return
1113
+ window.clearTimeout(start.timer)
1114
+ start = null
1115
+ suppressUntil = Date.now() + 500
1116
+ }, true)
1117
+ document.addEventListener('pointerup', (event) => {
1118
+ if (start === null) return
1119
+ const current = start
1120
+ start = null
1121
+ window.clearTimeout(current.timer)
1122
+ if (current.long) return
1123
+ if (current.row !== document.body && !current.row.isConnected) return
1124
+ if (current.moved || Math.abs(event.clientX - current.x) > 8 || Math.abs(event.clientY - current.y) > 8) {
1125
+ // Это скролл списка, а не касание строки. Браузер может прислать click
1126
+ // после окончания жеста; не даём ему открыть сессию.
1127
+ suppressUntil = Date.now() + 350
1128
+ return
1129
+ }
1130
+ // Первый клиентский click выделяет строку и показывает действия; второй
1131
+ // уже открывает сессию.
1132
+ current.row.click()
1133
+ window.setTimeout(() => {
1134
+ if (current.row.isConnected) current.row.click()
1135
+ }, 20)
1136
+ }, true)
1137
+ }
1138
+
1139
+ /**
1140
+ * На вторичных страницах контент прокручивается под статус-бар и кнопку меню.
1141
+ * Пока страница прокручена, закрываем верхний край мягким скримом.
1142
+ */
1143
+ function trackSecondaryPageScroll() {
1144
+ document.addEventListener('scroll', () => {
1145
+ const pages = [...document.querySelectorAll(':is([class$="_page"], [class*="_page "])')]
1146
+ const scrolled = pages.some((page) => page.scrollTop > 4)
1147
+ const active = scrolled && onSecondaryScreen()
1148
+ setAttrIfChanged(document.documentElement, 'data-dshm-page-scrolled', active ? '' : null)
1149
+ }, true)
1150
+ }
1151
+
1152
+ /**
1153
+ * Системная кнопка «назад».
1154
+ *
1155
+ * Приложение не закрывает экран само: оно шлёт странице cancelable-событие
1156
+ * `dsh-mobile:native-back` и уходит только если никто не позвал preventDefault.
1157
+ * Мой слой это событие не слушал — и «назад» оставлял панель открытой вместе со
1158
+ * списком сессий (проверено: после нажатия drawer оставался true).
1159
+ */
1160
+ function bindBackButton() {
1161
+ window.addEventListener('dsh-mobile:native-back', (event) => {
1162
+ const panel = document.getElementById('dshm-remote-panel')
1163
+ if (panel !== null && getComputedStyle(panel).display !== 'none') {
1164
+ const scrim = document.getElementById('dshm-remote-scrim')
1165
+ if (scrim !== null) scrim.click()
1166
+ event.preventDefault()
1167
+ return
1168
+ }
1169
+ // Сетка инструментов закрывается раньше всего.
1170
+ if (document.querySelector('.dshm-grid') !== null) {
1171
+ closeToolGrid()
1172
+ event.preventDefault()
1173
+ return
1174
+ }
1175
+ // Поповер клиента (меню команд, выбор модели) закрывается первым: он
1176
+ // висит поверх содержимого и поверх правой панели.
1177
+ if (closeClientPopover()) {
1178
+ event.preventDefault()
1179
+ return
1180
+ }
1181
+ // Правая панель (файлы) закрывается следующей: она накрывает диалог.
1182
+ if (closeRightPanel()) {
1183
+ event.preventDefault()
1184
+ return
1185
+ }
1186
+ // Модальное окно клиента (настройки и подобные) закрывается своей кнопкой.
1187
+ if (closeClientModal()) {
1188
+ event.preventDefault()
1189
+ return
1190
+ }
1191
+ // Обычный диалог клиента («Переименовать сессию» и подобные) — кнопкой «Отмена».
1192
+ if (closeClientDialog()) {
1193
+ event.preventDefault()
1194
+ return
1195
+ }
1196
+ // Детальный экран (плагин, задача) возвращается сначала в свой список.
1197
+ if (closeClientDetail()) {
1198
+ event.preventDefault()
1199
+ return
1200
+ }
1201
+ // С вторичной страницы «назад» возвращает в диалог и открывает меню —
1202
+ // иначе человеку приходится искать кнопку меню заново.
1203
+ if (onSecondaryScreen() && returnToChat()) {
1204
+ event.preventDefault()
1205
+ return
1206
+ }
1207
+ if (document.documentElement.hasAttribute('data-dshm-drawer')) {
1208
+ closeDrawerLight()
1209
+ event.preventDefault()
1210
+ return
1211
+ }
1212
+ // В самом диалоге «назад» открывает боковое меню: это ожидаемый жест и
1213
+ // способ добраться до списка чатов, не поднимая палец к кнопке.
1214
+ setDrawer(true)
1215
+ event.preventDefault()
1216
+ })
1217
+ }
1218
+
1219
+ /**
1220
+ * На телефоне в правой панели должна быть одна активная вкладка на всю ширину.
1221
+ *
1222
+ * Dockkit строит вкладки как grid: при двух открытых вкладках (гайд + разговор
1223
+ * субагента) получались две колонки по 180 px, и текст чата сжимался в полоску.
1224
+ * У клиента ширина колонок задана inline-стилем, поэтому CSS с !important
1225
+ * недостаточно надёжен — выставляем inline-important сами.
1226
+ */
1227
+ function singleColumnRightPanel() {
1228
+ if (innerWidth > 600) return
1229
+ /** Выставить inline-important только если значение реально меняется. */
1230
+ const set = (element, property, value) => {
1231
+ if (element.style.getPropertyValue(property) !== value) element.style.setProperty(property, value, 'important')
1232
+ }
1233
+ for (const panel of document.querySelectorAll('[data-sidebar-right-panel]')) {
1234
+ for (const layout of panel.querySelectorAll('[class*="_tabLayout_"]')) {
1235
+ if (layout.style.getPropertyValue('grid-template-columns') !== '1fr') {
1236
+ set(layout, 'grid-template-columns', '1fr')
1237
+ }
1238
+ }
1239
+ // Одновременно «активными» могут быть несколько pane (старые вкладки
1240
+ // остаются смонтированными). Видимой должна быть только та ячейка, в
1241
+ // tab-strip которой выбран активный таб; иначе два заголовка складываются
1242
+ // друг на друга.
1243
+ const activeCells = [...panel.querySelectorAll('[class*="_tabCell_"]')]
1244
+ .filter((cell) => cell.querySelector('[data-dockkit-pane-active="true"]') !== null)
1245
+ const selected = activeCells.find((cell) => [...cell.querySelectorAll('[data-dockkit-tab]')]
1246
+ .some((tab) => String(tab.className).includes('_tabActive_')))
1247
+ ?? activeCells[activeCells.length - 1]
1248
+ ?? null
1249
+ for (const cell of panel.querySelectorAll('[class*="_tabCell_"]')) {
1250
+ const isActive = cell === selected
1251
+ set(cell, 'display', isActive ? 'block' : 'none')
1252
+ if (isActive) {
1253
+ set(cell, 'grid-area', '1 / 1 / 2 / 2')
1254
+ set(cell, 'width', '100%')
1255
+ // Cell height must stay inside the panel: otherwise _tabHostBody_
1256
+ // раскрывается на 3000+ px, панель обрезает его, а внутренний scroll
1257
+ // остаётся нулевой высоты — чат нельзя пролистать.
1258
+ set(cell, 'height', '100%')
1259
+ set(cell, 'max-height', '100%')
1260
+ set(cell, 'overflow', 'hidden')
1261
+ const host = cell.querySelector('[class*="_tabHost_"]')
1262
+ if (host !== null) {
1263
+ set(host, 'height', '100%')
1264
+ set(host, 'max-height', '100%')
1265
+ set(host, 'display', 'flex')
1266
+ set(host, 'flex-direction', 'column')
1267
+ set(host, 'overflow', 'hidden')
1268
+ }
1269
+ const hostBody = cell.querySelector('[class*="_tabHostBody_"]')
1270
+ if (hostBody !== null) {
1271
+ set(hostBody, 'flex', '1 1 auto')
1272
+ set(hostBody, 'min-height', '0')
1273
+ set(hostBody, 'height', 'auto')
1274
+ set(hostBody, 'overflow-y', 'auto')
1275
+ // Панель тянется под системную навигацию; оставляем нижний отступ,
1276
+ // чтобы композер и последние строки не уезжали под кнопки Android.
1277
+ set(hostBody, 'padding-bottom', 'calc(env(safe-area-inset-bottom) + .5rem)')
1278
+ set(hostBody, 'box-sizing', 'border-box')
1279
+ }
1280
+ }
1281
+ }
1282
+ // Разделитель между колонками на телефоне не нужен.
1283
+ for (const divider of panel.querySelectorAll('[class*="_tabLayoutDivider_"]')) {
1284
+ set(divider, 'display', 'none')
1285
+ }
1286
+ }
1287
+ }
1288
+
1289
+ /**
1290
+ * Следить за правой панелью по её стабильным data-атрибутам, а не по тексту
1291
+ * aria-label: подписи переводятся, и русская проверка ломалась бы в en/zh.
1292
+ * Состояние читаем из DOM после клика: `data-sidebar-right-open` есть, пока
1293
+ * панель развёрнута.
1294
+ */
1295
+ function trackRightPanel() {
1296
+ if (document.documentElement.hasAttribute('data-dshm-right-tracked')) return
1297
+ document.documentElement.setAttribute('data-dshm-right-tracked', '')
1298
+ const sync = () => {
1299
+ rightPanelVisible = document.querySelector('[data-sidebar-right-open]') !== null
1300
+ // Правая панель на телефоне полноэкранная; плавающая кнопка меню иначе
1301
+ // накрывает её заголовок и кнопку закрытия.
1302
+ setAttrIfChanged(document.documentElement, 'data-dshm-right-open', rightPanelVisible ? '' : null)
1303
+ if (rightPanelVisible) singleColumnRightPanel()
1304
+ if (rightPanelVisible && document.documentElement.hasAttribute('data-dshm-drawer')) closeDrawerLight()
1305
+ }
1306
+ document.addEventListener('click', (event) => {
1307
+ const target = event.target instanceof Element
1308
+ ? event.target.closest('[data-sidebar-right-expand], [data-sidebar-right-toggle], [data-sidebar-right-mode], [data-sidebar-right-panel] button')
1309
+ : null
1310
+ if (target === null) return
1311
+ window.setTimeout(sync, 0)
1312
+ }, true)
1313
+ // Панель могли восстановить открытой после перезагрузки — прочитать сразу.
1314
+ window.setTimeout(sync, 300)
1315
+ }
1316
+
1317
+ function closeOnNavigate() {
1318
+ const column = document.querySelector(':is([class$="_sidebarCol"], [class*="_sidebarCol "])')
1319
+ if (column === null || column.hasAttribute('data-dshm-bound')) return
1320
+ column.setAttribute('data-dshm-bound', '')
1321
+ column.addEventListener('click', (event) => {
1322
+ if (Date.now() < suppressCloseUntil) return
1323
+ const target = event.target instanceof Element ? event.target.closest('button, a, [role=treeitem]') : null
1324
+ if (target === null) return
1325
+ const label = (target.getAttribute('aria-label') || '').trim()
1326
+ if (/^(Поиск сессий|Очистить поиск|Добавить рабочую область|Настройки|Параметры вида)$/u.test(label)) return
1327
+ // Вся шапка «Рабочие области» — это управление списком, а не переход:
1328
+ // крестик поиска и шестерёнка не должны закрывать боковую панель.
1329
+ if (target.closest(':is([class$="_sectionHeader"], [class*="_sectionHeader "]), :is([class$="_searchSlot"], [class*="_searchSlot "])') !== null) return
1330
+ // «Вы вошли в DeepSeek» открывает настройки модалкой. Панель при этом не
1331
+ // должна сворачиваться: человек вернётся из настроек в ту же панель.
1332
+ if (/учётной записи|account menu|账号菜单/iu.test(label)) return
1333
+ // Внутренние действия списка не уводят с экрана: группа рабочей области
1334
+ // раскрывается/сворачивается, «Показать ещё» догружает строки — меню
1335
+ // должно остаться открытым. Раньше любое такое касание закрывало его, и
1336
+ // человек терял место, до которого листал.
1337
+ if (target.closest('[role=treeitem][aria-expanded]') !== null) return
1338
+ if (target.closest(':is([class$="_sessionOverflowButton"], [class*="_sessionOverflowButton "])') !== null) return
1339
+ // Переход в другой экран не должен поднимать клавиатуру: клиент фокусирует
1340
+ // композер при открытии сессии, и это выглядит как моргнувшая клавиатура.
1341
+ suppressAutofocus()
1342
+ setTimeout(closeDrawerLight, 120)
1343
+ scheduleBlur([300, 900, 1800])
1344
+ })
1345
+ }
1346
+
1347
+ /** Закрыть боковую панель клавишей Escape — привычный жест на телефоне с клавиатурой. */
1348
+ function bindEscapeClose() {
1349
+ window.addEventListener('keydown', (event) => {
1350
+ if (event.key === 'Escape' && document.documentElement.hasAttribute('data-dshm-drawer')) {
1351
+ closeDrawer()
1352
+ event.preventDefault()
1353
+ }
1354
+ })
1355
+ }
1356
+
1357
+ /* ── 5. ТЕМА И РАБОЧАЯ ОБЛАСТЬ ──────────────────────────────────────────
1358
+ * Тема читается у клиента (color-scheme на <html>), а не угадывается по
1359
+ * системным цветам: они не переключаются на лету и дают чёрный текст на
1360
+ * тёмном фоне. Последняя рабочая область запоминается и предлагается одной
1361
+ * кнопкой: клиент не помнит выбор между перезагрузками.
1362
+ */
1363
+
1364
+ /**
1365
+ * Тема: читаем у клиента и выставляем своей разметке.
1366
+ *
1367
+ * Клиент держит её в `color-scheme` на <html>. Системные цвета брать нельзя — они не
1368
+ * переключаются на лету, и в светлой теме получался чёрный текст на тёмном фоне.
1369
+ */
1370
+ function applyTheme() {
1371
+ // Цвет берём у клиента, а не из своих догадок: тема приложения может быть любой, и
1372
+ // единственный надёжный источник — то, что он сам нарисовал. Системные цвета не
1373
+ // годятся: они не переключаются на лету и дают чёрный текст на тёмном фоне.
1374
+ const body = getComputedStyle(document.body)
1375
+ const surface = body.backgroundColor
1376
+ const ink = body.color
1377
+ const root = document.documentElement.style
1378
+ if (surface && surface !== 'rgba(0, 0, 0, 0)' && surface !== 'transparent') {
1379
+ root.setProperty('--dshm-surface', surface)
1380
+ }
1381
+ if (ink) root.setProperty('--dshm-ink', ink)
1382
+ const scheme = String(getComputedStyle(document.documentElement).colorScheme || '')
1383
+ const theme = scheme.includes('light') ? 'light' : 'dark'
1384
+ if (document.documentElement.getAttribute('data-dshm-theme') !== theme) {
1385
+ document.documentElement.setAttribute('data-dshm-theme', theme)
1386
+ }
1387
+ }
1388
+
1389
+ /**
1390
+ * Последняя рабочая область — и кнопка «продолжить в ней».
1391
+ *
1392
+ * Клиент не запоминает выбор между перезагрузками (в localStorage лежит только
1393
+ * dsh.sessions.current), поэтому после каждого запуска человек заново проходит выбор
1394
+ * каталога. Слой запоминает последнюю область и предлагает её одной кнопкой: он сам
1395
+ * открывает диалог, находит строку с тем же именем и нажимает «Открыть».
1396
+ */
1397
+ const WORKSPACE_KEY = 'dsh-remote.workspace'
1398
+
1399
+ function rememberedWorkspace() {
1400
+ try { return JSON.parse(localStorage.getItem(WORKSPACE_KEY) ?? 'null') } catch { return null }
1401
+ }
1402
+
1403
+ function rememberWorkspace(name) {
1404
+ if (typeof name !== 'string' || name.length === 0) return
1405
+ const current = rememberedWorkspace()
1406
+ if (current !== null && current.name === name) return
1407
+ localStorage.setItem(WORKSPACE_KEY, JSON.stringify({ name }))
1408
+ }
1409
+
1410
+ /**
1411
+ * Запомнить область, по которой человек нажал в диалоге.
1412
+ *
1413
+ * Подпись рядом с композером читать нельзя: у кнопки пустой textContent и даже
1414
+ * innerText — видимое имя рисует вложенный элемент. Зато строка диалога, по которой
1415
+ * нажали, содержит имя прямо в тексте.
1416
+ */
1417
+ function captureWorkspaceClick() {
1418
+ document.addEventListener('click', (event) => {
1419
+ const dialog = document.querySelector('[role=dialog]')
1420
+ if (dialog === null || !/каталог|directory|workspace/i.test(dialog.innerText ?? '')) return
1421
+ const row = event.target instanceof Element ? event.target.closest('*') : null
1422
+ let node = row
1423
+ for (let depth = 0; depth < 4 && node !== null; depth += 1) {
1424
+ const text = (node.innerText ?? '').trim()
1425
+ if (text.length > 0 && text.length < 64 && !/Открыть|Отмена|Новая папка|скрытые|Open|Cancel|New folder|hidden/i.test(text)) {
1426
+ rememberWorkspace(text.split('\n')[0].trim())
1427
+ return
1428
+ }
1429
+ node = node.parentElement
1430
+ }
1431
+ }, true)
1432
+ }
1433
+
1434
+ /** Пройти диалог выбора каталога за человека. */
1435
+ function resumeWorkspace(name) {
1436
+ const chooser = findButton(/рабочую область|workspace/i)
1437
+ if (chooser === undefined) return false
1438
+ chooser.click()
1439
+ setTimeout(() => {
1440
+ const dialog = document.querySelector('[role=dialog]')
1441
+ if (dialog === null) return
1442
+ const row = [...dialog.querySelectorAll('*')]
1443
+ .filter((e) => (e.textContent ?? '').trim() === name && e.children.length < 3)
1444
+ .pop()
1445
+ if (row !== undefined) row.click()
1446
+ setTimeout(() => {
1447
+ const open = [...(dialog.querySelectorAll('button') ?? [])].find((b) => /Открыть|Open/i.test(b.textContent ?? ''))
1448
+ if (open !== undefined) open.click()
1449
+ }, 900)
1450
+ }, 1200)
1451
+ return true
1452
+ }
1453
+
1454
+ /** Кнопка предложения, когда композер просит область. */
1455
+ function rememberBar() {
1456
+ const existing = document.getElementById('dshm-resume')
1457
+ const editor = document.querySelector('[contenteditable]')
1458
+ const disabled = editor !== null && editor.getAttribute('contenteditable') === 'false'
1459
+ if (!disabled) {
1460
+ if (existing !== null) existing.remove()
1461
+ return
1462
+ }
1463
+ const saved = rememberedWorkspace()
1464
+ if (!disabled || saved === null) {
1465
+ if (existing !== null) existing.remove()
1466
+ return
1467
+ }
1468
+ if (existing !== null) return
1469
+ const bar = document.createElement('button')
1470
+ bar.id = 'dshm-resume'
1471
+ bar.type = 'button'
1472
+ bar.className = 'dshm-resume'
1473
+ bar.textContent = `Продолжить в «${saved.name}»`
1474
+ bar.addEventListener('click', () => { resumeWorkspace(saved.name); bar.remove() })
1475
+ document.body.appendChild(bar)
1476
+ }
1477
+
1478
+ /* ── 6. НАБЛЮДЕНИЕ ЗА СТРАНИЦЕЙ: СТАТИСТИКА ХОДА ───────────────────────
1479
+ * Один тикер вместо пяти: каждый проход делает срочное, а редкое — по
1480
+ * счётчику. Частый полный обход DOM на длинной переписке давал кадры по
1481
+ * 58–117 мс, поэтому строку статистики ищем в доке композера, а не по всей
1482
+ * странице.
1483
+ */
1484
+
1485
+ /**
1486
+ * Держать страницу в порядке.
1487
+ *
1488
+ * Клиент при запуске может помнить раскрытую панель, и тогда она накрывает экран
1489
+ * телефона при каждом открытии. Один раз при старте приводим её в закрытое состояние —
1490
+ * дальше состоянием управляем мы.
1491
+ */
1492
+ function watch() {
1493
+ trackRightPanel()
1494
+ trackSecondaryPageScroll()
1495
+ bindSessionRowTap()
1496
+ bindSettingsTabsScroll()
1497
+ let closedOnce = false
1498
+ const tick = () => {
1499
+ // Панель могла восстановиться открытой без клика; держим одну колонку.
1500
+ singleColumnRightPanel()
1501
+ markStats()
1502
+ syncOverlayVisibility()
1503
+ // Ящик мог открыться до отрисовки колонки или остаться после перехода.
1504
+ // Пустую панель закрываем; поле поиска внутри ящика не трогаем.
1505
+ if (drawerOpen && Date.now() - drawerOpenedAt > 700 && !hasSidebarContent()
1506
+ && document.activeElement?.closest(':is([class$="_sidebarCol"], [class*="_sidebarCol "])') === null) {
1507
+ closeDrawerLight()
1508
+ }
1509
+ if (!closedOnce && !drawerOpen && document.documentElement.hasAttribute('data-dshm-drawer')) {
1510
+ document.documentElement.removeAttribute('data-dshm-drawer')
1511
+ }
1512
+ closedOnce = true
1513
+ }
1514
+ // Пока человек ни к чему не прикасался, клавиатуру не показываем.
1515
+ //
1516
+ // Клиент при загрузке ставит фокус в поле ввода, и приложение открывается сразу с
1517
+ // поднятой клавиатурой — для человека это выглядит как «приложение само начало
1518
+ // печатать». Любое касание снимает это правило: дальше решает он.
1519
+ let userTouched = false
1520
+ const markTouched = () => { userTouched = true }
1521
+ for (const type of ['pointerdown', 'touchstart', 'keydown']) {
1522
+ window.addEventListener(type, markTouched, { once: true, capture: true, passive: true })
1523
+ }
1524
+ // Пустой ящик не должен висеть на экране даже один тик: проверяем часто,
1525
+ // пока он открыт, и закрываем, как только содержимое пропало.
1526
+ const drawerGuard = setInterval(() => {
1527
+ if (!drawerOpen || Date.now() - drawerOpenedAt <= 250) return
1528
+ if (hasSidebarContent()) return
1529
+ if (document.activeElement?.closest(':is([class$="_sidebarCol"], [class*="_sidebarCol "])') !== null) return
1530
+ closeDrawerLight()
1531
+ }, 250)
1532
+ const focusGuard = setInterval(() => {
1533
+ if (userTouched) {
1534
+ clearInterval(focusGuard)
1535
+ return
1536
+ }
1537
+ const active = document.activeElement
1538
+ if (isTextEntry(active)) active.blur()
1539
+ }, 300)
1540
+ // Один тикер вместо пяти: каждый проход делает срочное, а редкое — по счётчику.
1541
+ //
1542
+ // Было: 800 мс (пометки/вопросы/завершение/карточка плана), 1500 мс
1543
+ // (closeOnNavigate — на самом деле одноразовая привязка обработчика, его
1544
+ // достаточно вызвать при старте), 2500 мс (убрать настольные кнопки),
1545
+ // 1200 мс (панель клиента), 1200 мс (строка «Продолжить»). Каждый обход — это
1546
+ // поиск по всему DOM, и на длинной переписке они складывались.
1547
+ let passes = 0
1548
+ setInterval(() => {
1549
+ if (paused()) return
1550
+ tick()
1551
+ notifyQuestion()
1552
+ watchCompletion()
1553
+ keepPlanCardVisible()
1554
+ closeEmptyRightPanel()
1555
+ passes += 1
1556
+ if (passes % 2 === 0) {
1557
+ // closeOnNavigate вешает обработчик на колонку клиента; после перерисовки
1558
+ // колонка может замениться, поэтому привязку проверяем периодически.
1559
+ closeOnNavigate()
1560
+ keepClientPanelOpen()
1561
+ rememberBar()
1562
+ }
1563
+ if (passes % 4 === 0) hideLegacyMobileAccess()
1564
+ }, 800)
1565
+ blurOnButtonClick()
1566
+ guardComposerMenuFocus()
1567
+ installTerminalKeyboard()
1568
+ installQuestionEnterNewline()
1569
+ tick()
1570
+ }
1571
+
1572
+ /**
1573
+ * Пометить строку статистики хода (токены/с, попадание в кэш).
1574
+ *
1575
+ * Раньше здесь был полный обход `div, p, span` всей страницы с чтением
1576
+ * `getBoundingClientRect` у каждого кандидата — каждые 800 мс, и это давало
1577
+ * редкие кадры по 58–117 мс на длинной переписке (замер с выключенными
1578
+ * таймерами слоя: 42 мс против 117). Строка живёт в доке композера: девять
1579
+ * узлов вместо тысяч, и английский шаблон заменён на оба языка — русский
1580
+ * интерфейс раньше не помечался вовсе.
1581
+ */
1582
+ /**
1583
+ * Закрыть нижнюю шторку клиента (команда агентов/субагенты/фоновые задачи).
1584
+ *
1585
+ * Клиент закрывает такие панели по Escape; кнопка «×» в шторке делает то же.
1586
+ */
1587
+ function closeToolSheet() {
1588
+ const sheet = document.querySelector('[data-dshm-sheet]')
1589
+ if (sheet !== null) {
1590
+ const dispatchEscape = () => sheet.dispatchEvent(new KeyboardEvent('keydown', {
1591
+ key: 'Escape', code: 'Escape', keyCode: 27, which: 27, bubbles: true, cancelable: true,
1592
+ }))
1593
+ dispatchEscape()
1594
+ sheet.removeAttribute('data-dshm-sheet')
1595
+ sheet.querySelectorAll('.dshm-sheet-close, .dshm-sheet-handle').forEach((element) => element.remove())
1596
+ for (const scrim of document.querySelectorAll('.dshm-sheet-scrim')) {
1597
+ scrim.dataset.dshmShow = '0'
1598
+ window.setTimeout(() => scrim.remove(), 220)
1599
+ }
1600
+ // Страховка: некоторые панели клиента не слушают Escape. Тогда закрываем
1601
+ // их собственной кнопкой, чтобы шторка не осталась висеть.
1602
+ window.setTimeout(() => {
1603
+ if (!sheet.isConnected) return
1604
+ const close = sheet.querySelector('[class*="_close"], button[aria-label*="Закрыть"], button[aria-label*="Close"]')
1605
+ if (close !== null) close.click()
1606
+ }, 120)
1607
+ }
1608
+ for (const button of document.querySelectorAll('.dshm-tool[aria-expanded="true"]')) {
1609
+ button.setAttribute('aria-expanded', 'false')
1610
+ }
1611
+ }
1612
+
1613
+ /**
1614
+ * Найти панель клиента, которую только что открыла скрытая кнопка, и превратить
1615
+ * её в нижнюю шторку: выпадашка привязана к кнопке в скрытой шапке и рисуется в
1616
+ * левом верхнем углу без прокрутки.
1617
+ *
1618
+ * @param {Element} source скрытая кнопка шапки.
1619
+ * @param {Element} tool наша кнопка панели.
1620
+ */
1621
+ function markToolSheet(source, tool) {
1622
+ // Модуль клиента по хешу класса: кнопка `_2tNPVa_trigger` открывает панель
1623
+ // `_2tNPVa_panel`, `Z0Zh9q_trigger` — меню `Z0Zh9q_menu`. Так мы никогда не
1624
+ // примем за шторку уже открытую правую панель или чужой диалог.
1625
+ const firstClass = (String(source.className).split(/\s+/u)[0] ?? '').replace(/^_/u, '')
1626
+ const modulePrefix = firstClass.split('_')[0]
1627
+ const snapshot = new Set(document.querySelectorAll('[role=dialog], [class*="_menu"], [class*="_panel"]'))
1628
+ const pool = modulePrefix === ''
1629
+ ? '[role=dialog], [class*="_menu"], [class*="_panel"]'
1630
+ : `[role=dialog], [class*="${modulePrefix}"], [class*="_menu"], [class*="_panel"]`
1631
+ const ours = (element) => element.closest('[class*="_composerStack"]') !== null
1632
+ || element.closest('[class*="_sidebarCol"]') !== null
1633
+ || element.closest('[data-sidebar-right-panel]') !== null
1634
+ || element.closest('[class*="tabHost"]') !== null
1635
+ || element.closest('[data-queue-dock]') !== null
1636
+ || element.closest('[data-shortcut-modal]') !== null
1637
+ source.click()
1638
+ window.setTimeout(() => {
1639
+ const candidates = [...document.querySelectorAll(pool)]
1640
+ .filter((element) => {
1641
+ if (element.hasAttribute('data-dshm-sheet')) return false
1642
+ if (ours(element)) return false
1643
+ const className = String(element.className)
1644
+ const isNew = !snapshot.has(element)
1645
+ const sameModule = modulePrefix !== '' && className.includes(modulePrefix)
1646
+ // Модульный хеш — предпочтительный признак; если клиент сменил хеш,
1647
+ // спасает сравнение «что появилось после клика».
1648
+ if (!isNew && !sameModule) return false
1649
+ const rect = element.getBoundingClientRect()
1650
+ const style = getComputedStyle(element)
1651
+ return rect.width > 200 && rect.height > 60 && rect.bottom > 0 && rect.top < window.innerHeight
1652
+ && (style.position === 'fixed' || style.position === 'absolute')
1653
+ })
1654
+ .sort((a, b) => (Number(String(b.className).includes(modulePrefix)) - Number(String(a.className).includes(modulePrefix))))
1655
+ const sheet = candidates[0]
1656
+ if (sheet === undefined) return
1657
+ sheet.setAttribute('data-dshm-sheet', '')
1658
+ sheet.dataset.dshmSheetToken = SHEET_TOKEN
1659
+ const scrim = document.createElement('div')
1660
+ scrim.className = 'dshm-sheet-scrim'
1661
+ scrim.dataset.dshmShow = '0'
1662
+ scrim.addEventListener('click', closeToolSheet)
1663
+ document.body.appendChild(scrim)
1664
+ window.requestAnimationFrame(() => { scrim.dataset.dshmShow = '1' })
1665
+ const close = document.createElement('button')
1666
+ close.type = 'button'
1667
+ close.className = 'dshm-sheet-close'
1668
+ close.setAttribute('aria-label', tr('Закрыть'))
1669
+ close.addEventListener('click', (event) => {
1670
+ event.preventDefault()
1671
+ event.stopPropagation()
1672
+ closeToolSheet()
1673
+ })
1674
+ sheet.appendChild(close)
1675
+ const handle = document.createElement('div')
1676
+ handle.className = 'dshm-sheet-handle'
1677
+ sheet.appendChild(handle)
1678
+ let dragStart = null
1679
+ let dragY = 0
1680
+ handle.addEventListener('pointerdown', (event) => {
1681
+ dragStart = event.clientY
1682
+ dragY = 0
1683
+ sheet.style.transition = 'none'
1684
+ handle.setPointerCapture?.(event.pointerId)
1685
+ })
1686
+ handle.addEventListener('pointermove', (event) => {
1687
+ if (dragStart === null) return
1688
+ dragY = Math.max(0, event.clientY - dragStart)
1689
+ sheet.style.transform = `translateY(${String(dragY)}px)`
1690
+ const sheetScrim = document.querySelector('.dshm-sheet-scrim')
1691
+ if (sheetScrim !== null) sheetScrim.style.opacity = String(Math.max(0.1, 1 - dragY / 260))
1692
+ })
1693
+ const endDrag = () => {
1694
+ if (dragStart === null) return
1695
+ dragStart = null
1696
+ if (dragY > 110) {
1697
+ sheet.style.transition = 'transform .18s ease-in'
1698
+ sheet.style.transform = 'translateY(110%)'
1699
+ const sheetScrim = document.querySelector('.dshm-sheet-scrim')
1700
+ if (sheetScrim !== null) sheetScrim.style.opacity = '0'
1701
+ window.setTimeout(closeToolSheet, 180)
1702
+ } else {
1703
+ sheet.style.transition = 'transform .28s cubic-bezier(.2,1.15,.3,1)'
1704
+ sheet.style.transform = ''
1705
+ const sheetScrim = document.querySelector('.dshm-sheet-scrim')
1706
+ if (sheetScrim !== null) sheetScrim.style.opacity = ''
1707
+ }
1708
+ dragY = 0
1709
+ }
1710
+ handle.addEventListener('pointerup', endDrag)
1711
+ handle.addEventListener('pointercancel', endDrag)
1712
+ tool.setAttribute('aria-expanded', 'true')
1713
+ }, 140)
1714
+ }
1715
+
1716
+ /**
1717
+ * Создать кнопку нижней панели, которая открывает скрытую кнопку шапки шторкой.
1718
+ *
1719
+ * @param {string} key стабильный ключ кнопки.
1720
+ * @param {RegExp} ariaPattern поиск скрытой кнопки шапки по aria-label.
1721
+ * @param {string} label подпись/aria для нашей кнопки.
1722
+ * @param {string} svg иконка.
1723
+ * @param {Element} dock контейнер нижней панели.
1724
+ * @returns {Element|undefined} созданная кнопка.
1725
+ */
1726
+ function createToolButton(key, ariaPattern, label, svg, dock) {
1727
+ const existing = dock.querySelector(`[data-dshm-tool="${key}"]`)
1728
+ const findSource = () => [...document.querySelectorAll('button')]
1729
+ .find((candidate) => !candidate.classList.contains('dshm-tool')
1730
+ && ariaPattern.test(candidate.getAttribute('aria-label') ?? ''))
1731
+ const source = findSource()
1732
+ if (existing !== null) {
1733
+ // Кнопка, у которой больше нет исходной кнопки клиента, — мёртвая. Но
1734
+ // источник может исчезнуть на время анимации ящика/диалога: не дёргаем DOM
1735
+ // каждый тик, а убираем кнопку после нескольких подряд пропусков. Иначе
1736
+ // открытие панели само создаёт длинные задачи (замер: десятки long tasks).
1737
+ if (source !== undefined) {
1738
+ delete existing.dataset.dshmMissing
1739
+ return existing
1740
+ }
1741
+ const misses = Number(existing.dataset.dshmMissing ?? 0) + 1
1742
+ existing.dataset.dshmMissing = String(misses)
1743
+ if (misses < 5) return existing
1744
+ existing.remove()
1745
+ }
1746
+ if (source === undefined) return undefined
1747
+ const button = document.createElement('button')
1748
+ button.type = 'button'
1749
+ button.dataset.dshmTool = key
1750
+ button.className = 'dshm-tool'
1751
+ button.setAttribute('aria-label', findSource()?.getAttribute('aria-label') || label)
1752
+ button.title = label
1753
+ button.innerHTML = svg
1754
+ button.addEventListener('click', (event) => {
1755
+ event.preventDefault()
1756
+ event.stopPropagation()
1757
+ tapHaptic()
1758
+ if (button.getAttribute('aria-expanded') === 'true') {
1759
+ closeToolSheet()
1760
+ return
1761
+ }
1762
+ closeToolSheet()
1763
+ const source = findSource()
1764
+ if (source === undefined) return
1765
+ markToolSheet(source, button)
1766
+ })
1767
+ dock.appendChild(button)
1768
+ return button
1769
+ }
1770
+
1771
+ /**
1772
+ * Короткий тактильный отклик на нажатие инструмента.
1773
+ *
1774
+ * Сначала пробуем нативный мост (`haptic.tap`), иначе Vibration API. Наличие
1775
+ * возможности кешируем, чтобы не опрашивать мост на каждое касание.
1776
+ */
1777
+ let nativeHaptic = null
1778
+
1779
+ function tapHaptic() {
1780
+ const bridge = window.__DSH_MOBILE_NATIVE__
1781
+ if (bridge !== undefined && typeof bridge.invoke === 'function') {
1782
+ if (nativeHaptic === null && typeof bridge.capabilities === 'function') {
1783
+ bridge.capabilities().then((caps) => {
1784
+ nativeHaptic = Array.isArray(caps) && caps.includes('haptic.tap')
1785
+ }).catch(() => { nativeHaptic = false })
1786
+ }
1787
+ if (nativeHaptic === true) {
1788
+ try {
1789
+ const result = bridge.invoke('haptic.tap', { duration: 8 })
1790
+ if (result !== undefined && typeof result.catch === 'function') result.catch(() => { nativeHaptic = false })
1791
+ return
1792
+ } catch { nativeHaptic = false }
1793
+ }
1794
+ }
1795
+ if (navigator.vibrate) navigator.vibrate(6)
1796
+ }
1797
+
1798
+ /** Ключ закрепления для источника сетки ('' — источник не закрепляется). */
1799
+
1800
+ /**
1801
+ * Найти именно док композера.
1802
+ *
1803
+ * В `composerStack` бывает несколько доков: InputBar (data-composer-dock),
1804
+ * GoalBar (data-goal-bar, только при активной цели) и QueueDock (data-queue-dock,
1805
+ * только при непустой очереди). Раньше брали первый `:is([class$="_dock"], [class*="_dock "])` и при
1806
+ * активной цели вставляли кнопки в чужой бар — двойная реализация панели.
1807
+ *
1808
+ * @returns {Element|null} док композера.
1809
+ */
1810
+ function composerDock() {
1811
+ // В правой панели бывает встроенный разговор со своим composer-dock.
1812
+ // Выбираем док активной поверхности, а не первый в документе: иначе слой
1813
+ // вставляет кнопки в скрытый основной чат и проверки видят «два дока».
1814
+ const docks = [...document.querySelectorAll('[data-composer-dock="true"]')]
1815
+ if (docks.length > 0) {
1816
+ // Активной панель считаем только когда она реально открыта. После закрытия
1817
+ // dockkit оставляет её DOM (и встроенный composer-dock) в документе; если
1818
+ // брать его всегда, слой переносит инструменты в скрытую панель, а в
1819
+ // основном чате остаются стандартные кнопки.
1820
+ const activePanel = document.querySelector('[data-sidebar-right-open]') === null
1821
+ ? null
1822
+ : document.querySelector('[data-sidebar-right-panel] [data-dockkit-pane-active="true"]')
1823
+ let exact = activePanel === null
1824
+ ? undefined
1825
+ : docks.find((dock) => activePanel.contains(dock))
1826
+ if (exact === undefined) {
1827
+ exact = docks.find((dock) => visible(dock) && dock.closest('[data-sidebar-right-panel]') === null)
1828
+ ?? docks.find((dock) => visible(dock))
1829
+ ?? docks[0]
1830
+ }
1831
+ return exact
1832
+ }
1833
+ const fallback = [...document.querySelectorAll('[class*="_composerStack"] :is([class$="_dock"], [class*="_dock "])')]
1834
+ .find((element) => !element.hasAttribute('data-queue-dock') && !element.hasAttribute('data-goal-bar'))
1835
+ if (fallback === undefined) return null
1836
+ fallback.setAttribute('data-dshm-composer-dock', '')
1837
+ return fallback
1838
+ }
1839
+
1840
+ /**
1841
+ * Записать data-атрибут только если значение реально меняется.
1842
+ *
1843
+ * Тикер ходит каждые 800 мс; запись того же значения — лишняя мутация DOM,
1844
+ * лишний ResizeObserver/React-шум и риск гонок. Особенно заметно на
1845
+ * data-dshm-collapsed/context/dock-hidden.
1846
+ *
1847
+ * @param {Element} element элемент.
1848
+ * @param {string} name имя dataset-ключа.
1849
+ * @param {string|null} value новое значение; null — удалить.
1850
+ */
1851
+ function setAttrIfChanged(element, name, value) {
1852
+ if (value === null) {
1853
+ if (element.hasAttribute(name)) element.removeAttribute(name)
1854
+ return
1855
+ }
1856
+ if (element.getAttribute(name) !== value) element.setAttribute(name, value)
1857
+ }
1858
+
1859
+ /**
1860
+ * Убрать наши кнопки и состояние из чужих доков (цель/очередь), если старый слой
1861
+ * успел их туда вставить.
1862
+ *
1863
+ * @param {Element} dock настоящий док композера.
1864
+ */
1865
+ function cleanupForeignDocks(dock) {
1866
+ for (const foreign of document.querySelectorAll(':is([class$="_dock"], [class*="_dock "])')) {
1867
+ if (foreign === dock) continue
1868
+ foreign.querySelectorAll('.dshm-tool, .dshm-dock-toggle, [data-dshm-grid]').forEach((element) => element.remove())
1869
+ for (const name of ['dshmScroll', 'dshmCollapsed', 'dshmDragging', 'dshmBound', 'dshmEnter', 'dshmSeen', 'dshmTips', 'dshmDragBound', 'dshmReady', 'dshmTouched']) {
1870
+ delete foreign.dataset[name]
1871
+ }
1872
+ }
1873
+ }
1874
+
1875
+ /** Ключ скрытых из дока инструментов. */
1876
+ const HIDDEN_KEY = 'dshm.dockHidden'
1877
+
1878
+ /** Прочитать скрытые из дока инструменты. */
1879
+ function readHidden() {
1880
+ try {
1881
+ const value = JSON.parse(localStorage.getItem(HIDDEN_KEY) || '[]')
1882
+ return Array.isArray(value) ? value.filter((item) => typeof item === 'string') : []
1883
+ } catch { return [] }
1884
+ }
1885
+
1886
+ /** Записать скрытые из дока инструменты. */
1887
+ function writeHidden(list) {
1888
+ try { localStorage.setItem(HIDDEN_KEY, JSON.stringify(list)) } catch {}
1889
+ }
1890
+
1891
+ /** Ключ инструмента для скрытия/показа: стат-кнопки тоже участвуют. */
1892
+ function dockKey(source) {
1893
+ const key = sourceKey(source)
1894
+ if (key !== '') return key
1895
+ const aria = (source.getAttribute('aria-label') || '').trim()
1896
+ if (/^Ходов|Turns\b/iu.test(aria)) return 'stat:activity'
1897
+ if (/токен|oken/iu.test(aria)) return 'stat:tokens'
1898
+ if (/контекст|context/iu.test(aria)) return 'stat:context'
1899
+ return ''
1900
+ }
1901
+
1902
+ /** Ключ «док свёрнут». */
1903
+ const COLLAPSED_KEY = 'dshm.dockCollapsed'
1904
+
1905
+ /** Прочитать состояние «док свёрнут». */
1906
+ function readCollapsed() {
1907
+ try { return localStorage.getItem(COLLAPSED_KEY) === '1' } catch { return false }
1908
+ }
1909
+
1910
+ /** Записать состояние «док свёрнут». */
1911
+ function writeCollapsed(value) {
1912
+ try { localStorage.setItem(COLLAPSED_KEY, value ? '1' : '0') } catch {}
1913
+ }
1914
+
1915
+ /** Ключ сохранённого порядка дока. */
1916
+ const ORDER_KEY = 'dshm.dockOrder'
1917
+
1918
+ /** Прочитать сохранённый порядок инструментов. */
1919
+ function readOrder() {
1920
+ try {
1921
+ const value = JSON.parse(localStorage.getItem(ORDER_KEY) || '[]')
1922
+ return Array.isArray(value) ? value.filter((item) => typeof item === 'string') : []
1923
+ } catch { return [] }
1924
+ }
1925
+
1926
+ /** Записать порядок инструментов. */
1927
+ function writeOrder(list) {
1928
+ try { localStorage.setItem(ORDER_KEY, JSON.stringify(list)) } catch {}
1929
+ }
1930
+
1931
+ /** Ключ источника для сетки/закрепления. */
1932
+ function sourceKey(source) {
1933
+ if (source.dataset.dshmTool !== undefined) return source.dataset.dshmTool
1934
+ if (source.dataset.dshmAction !== undefined) return source.dataset.dshmAction
1935
+ return ''
1936
+ }
1937
+
1938
+ /**
1939
+ * Короткая подпись для плитки сетки инструментов.
1940
+ *
1941
+ * @param {Element} source кнопка дока или клиента.
1942
+ * @returns {string} подпись.
1943
+ */
1944
+ function toolLabel(source) {
1945
+ const aria = (source.getAttribute('aria-label') || source.title || '').trim()
1946
+ if (/^(Ходов|Turns)[:\s]/iu.test(aria)) return tr('Статистика')
1947
+ if (/субагент|subagent/iu.test(aria)) return tr('Субагенты')
1948
+ if (/фоновая задача|background task/iu.test(aria)) return tr('Фоновые задачи')
1949
+ if (/токен|oken/i.test(aria)) return tr('Токены')
1950
+ if (/контекст|context/i.test(aria)) return tr('Контекст')
1951
+ return aria.length > 28 ? `${aria.slice(0, 27)}…` : aria
1952
+ }
1953
+
1954
+ /** Токен экземпляра слоя: помечает шторки, созданные именно этим слоем. */
1955
+ const SHEET_TOKEN = `${String(Date.now())}-${String(Math.random()).slice(2, 8)}`
1956
+
1957
+ /** Показывался ли брифинг в этой загрузке страницы. */
1958
+ let onboardingShown = false
1959
+
1960
+ /**
1961
+ * Показать короткий брифинг первого запуска.
1962
+ *
1963
+ * @param {boolean} force показать заново, даже если уже видели.
1964
+ */
1965
+ function showOnboarding(force = false) {
1966
+ if (!force) {
1967
+ if (onboardingShown || document.querySelector('.dshm-onboard') !== null) return
1968
+ try { if (localStorage.getItem('dshm.onboardingSeen') === '1') return } catch { return }
1969
+ } else {
1970
+ document.querySelector('.dshm-onboard')?.remove()
1971
+ }
1972
+ onboardingShown = true
1973
+ const steps = [
1974
+ { icon: '⌘', title: 'DDeck — это ваш компьютер', text: 'Чаты, файлы, терминал и настройки работают на Mac. Телефон только показывает и управляет.' },
1975
+ { icon: '⋯', title: 'Нижняя панель — быстрые инструменты', text: 'Листайте её влево-вправо. Кнопка с точками открывает все инструменты; свайп вверх по панели делает то же самое.' },
1976
+ { icon: '☑', title: 'Соберите панель под себя', text: 'В сетке нажмите «Изменить» и коснитесь плитки: инструмент убирается из панели или возвращается. Лишние остаются в меню.' },
1977
+ { icon: '↔', title: 'Порядок и управление', text: 'Долгое нажатие на иконке — перетащить. Шеврон справа сворачивает панель. Настройки — «Вы вошли в DeepSeek».' },
1978
+ ]
1979
+ let step = 0
1980
+ const overlay = document.createElement('div')
1981
+ overlay.className = 'dshm-onboard'
1982
+ overlay.dataset.dshmShow = '0'
1983
+ overlay.setAttribute('role', 'dialog')
1984
+ overlay.setAttribute('aria-modal', 'true')
1985
+ overlay.setAttribute('aria-label', tr('Как пользоваться DDeck'))
1986
+ const card = document.createElement('div')
1987
+ card.className = 'dshm-onboard-card'
1988
+ const icon = document.createElement('div')
1989
+ icon.className = 'dshm-onboard-icon'
1990
+ const title = document.createElement('div')
1991
+ title.className = 'dshm-onboard-title'
1992
+ const text = document.createElement('p')
1993
+ text.className = 'dshm-onboard-text'
1994
+ const foot = document.createElement('div')
1995
+ foot.className = 'dshm-onboard-foot'
1996
+ const dots = document.createElement('div')
1997
+ dots.className = 'dshm-onboard-dots'
1998
+ const actions = document.createElement('div')
1999
+ actions.className = 'dshm-onboard-actions'
2000
+ const skip = document.createElement('button')
2001
+ skip.type = 'button'
2002
+ skip.className = 'dshm-onboard-skip'
2003
+ skip.textContent = tr('Пропустить')
2004
+ const next = document.createElement('button')
2005
+ next.type = 'button'
2006
+ next.className = 'dshm-onboard-next'
2007
+ actions.append(skip, next)
2008
+ foot.append(dots, actions)
2009
+ card.append(icon, title, text, foot)
2010
+ overlay.append(card)
2011
+
2012
+ const finish = () => {
2013
+ try { localStorage.setItem('dshm.onboardingSeen', '1') } catch {}
2014
+ overlay.dataset.dshmShow = '0'
2015
+ window.setTimeout(() => overlay.remove(), 220)
2016
+ }
2017
+ const render = () => {
2018
+ const current = steps[step]
2019
+ icon.textContent = current.icon
2020
+ title.textContent = tr(current.title)
2021
+ text.textContent = tr(current.text)
2022
+ dots.replaceChildren(...steps.map((_, index) => {
2023
+ const dot = document.createElement('span')
2024
+ dot.className = 'dshm-onboard-dot'
2025
+ if (index === step) dot.dataset.dshmActive = '1'
2026
+ return dot
2027
+ }))
2028
+ next.textContent = step === steps.length - 1 ? tr('Понятно') : tr('Далее')
2029
+ skip.textContent = step === steps.length - 1 ? tr('Закрыть') : tr('Пропустить')
2030
+ }
2031
+ skip.addEventListener('click', finish)
2032
+ next.addEventListener('click', () => {
2033
+ if (step === steps.length - 1) {
2034
+ finish()
2035
+ return
2036
+ }
2037
+ step += 1
2038
+ render()
2039
+ })
2040
+ render()
2041
+ document.body.appendChild(overlay)
2042
+ window.requestAnimationFrame(() => { overlay.dataset.dshmShow = '1' })
2043
+ }
2044
+
2045
+ /** Закрыть сетку инструментов. */
2046
+ function closeToolGrid() {
2047
+ const grid = document.querySelector('.dshm-grid')
2048
+ const scrim = document.querySelector('.dshm-grid-scrim')
2049
+ if (grid !== null) grid.dataset.dshmShow = '0'
2050
+ if (scrim !== null) scrim.dataset.dshmShow = '0'
2051
+ window.setTimeout(() => {
2052
+ grid?.remove()
2053
+ scrim?.remove()
2054
+ }, 220)
2055
+ }
2056
+
2057
+ /**
2058
+ * Открыть сетку всех инструментов: док листается, а сетка показывает всё сразу.
2059
+ */
2060
+ function openToolGrid() {
2061
+ if (document.querySelector('.dshm-grid') !== null) {
2062
+ closeToolGrid()
2063
+ return
2064
+ }
2065
+ const dock = composerDock()
2066
+ if (dock === null) return
2067
+ const scrim = document.createElement('div')
2068
+ scrim.className = 'dshm-grid-scrim'
2069
+ scrim.dataset.dshmShow = '0'
2070
+ scrim.addEventListener('click', closeToolGrid)
2071
+ const grid = document.createElement('div')
2072
+ grid.className = 'dshm-grid'
2073
+ grid.setAttribute('role', 'dialog')
2074
+ grid.setAttribute('aria-label', tr('Все инструменты'))
2075
+ grid.dataset.dshmShow = '0'
2076
+ const header = document.createElement('div')
2077
+ header.className = 'dshm-grid-header'
2078
+ const title = document.createElement('div')
2079
+ title.className = 'dshm-grid-title'
2080
+ title.textContent = tr('Инструменты')
2081
+ const edit = document.createElement('button')
2082
+ edit.type = 'button'
2083
+ edit.className = 'dshm-grid-edit'
2084
+ edit.textContent = tr('Изменить')
2085
+ edit.setAttribute('aria-label', tr('Настройка нижней панели'))
2086
+ const reset = document.createElement('button')
2087
+ reset.type = 'button'
2088
+ reset.className = 'dshm-grid-edit dshm-grid-reset'
2089
+ reset.textContent = tr('Сбросить')
2090
+ reset.setAttribute('aria-label', tr('Вернуть все инструменты в панель'))
2091
+ reset.dataset.dshmHidden = '1'
2092
+ edit.addEventListener('click', () => {
2093
+ const active = grid.dataset.dshmEdit === '1'
2094
+ grid.dataset.dshmEdit = active ? '0' : '1'
2095
+ edit.dataset.dshmActive = active ? '0' : '1'
2096
+ edit.textContent = active ? tr('Изменить') : tr('Готово')
2097
+ reset.dataset.dshmHidden = active ? '1' : '0'
2098
+ })
2099
+ reset.addEventListener('click', () => {
2100
+ writeHidden([])
2101
+ for (const cell of grid.querySelectorAll('.dshm-grid-cell')) {
2102
+ cell.dataset.dshmDockHidden = '0'
2103
+ }
2104
+ })
2105
+ const info = document.createElement('button')
2106
+ info.type = 'button'
2107
+ info.className = 'dshm-grid-info'
2108
+ info.setAttribute('aria-label', tr('Как пользоваться'))
2109
+ info.textContent = '?'
2110
+ info.addEventListener('click', () => {
2111
+ closeToolGrid()
2112
+ window.setTimeout(() => showOnboarding(true), 120)
2113
+ })
2114
+ const close = document.createElement('button')
2115
+ close.type = 'button'
2116
+ close.className = 'dshm-grid-close'
2117
+ close.setAttribute('aria-label', tr('Закрыть'))
2118
+ close.textContent = '×'
2119
+ close.addEventListener('click', closeToolGrid)
2120
+ header.append(title, edit, reset, info, close)
2121
+ const search = document.createElement('input')
2122
+ search.className = 'dshm-grid-search'
2123
+ search.type = 'search'
2124
+ search.placeholder = tr('Поиск инструмента')
2125
+ search.setAttribute('aria-label', tr('Поиск инструмента'))
2126
+ const cells = document.createElement('div')
2127
+ cells.className = 'dshm-grid-cells'
2128
+
2129
+ const sources = [
2130
+ ...[...dock.querySelectorAll('button')].filter((button) => button.dataset.dshmTool === undefined
2131
+ && button.dataset.dshmAction === undefined && button.dataset.dshmGrid === undefined
2132
+ && !button.classList.contains('dshm-dock-toggle')),
2133
+ ...[...dock.querySelectorAll('[data-dshm-tool], [data-dshm-action]')],
2134
+ ]
2135
+ const synonyms = uiLang() === 'en'
2136
+ ? {
2137
+ menu: 'sidebar sessions drawer',
2138
+ newSession: 'new chat session',
2139
+ search: 'search find',
2140
+ plugins: 'plugins extensions',
2141
+ tasks: 'automations schedule tasks',
2142
+ files: 'files terminal right panel',
2143
+ agents: 'agent team agents',
2144
+ subagents: 'subagents agents',
2145
+ jobs: 'background tasks jobs',
2146
+ }
2147
+ : {
2148
+ menu: 'сессии ящик боковая панель',
2149
+ newSession: 'новый чат сессия',
2150
+ search: 'поиск найти',
2151
+ plugins: 'плагины расширения',
2152
+ tasks: 'автозадачи расписание автоматические',
2153
+ files: 'файлы терминал правая панель',
2154
+ agents: 'команда агентов команда',
2155
+ subagents: 'субагенты агенты',
2156
+ jobs: 'фоновые задачи фон',
2157
+ }
2158
+ const hidden = readHidden()
2159
+ for (const source of sources) {
2160
+ const key = dockKey(source)
2161
+ const cell = document.createElement('button')
2162
+ cell.type = 'button'
2163
+ cell.className = 'dshm-grid-cell'
2164
+ cell.dataset.dshmKey = key
2165
+ cell.dataset.dshmPinnable = key === '' ? '0' : '1'
2166
+ const label = toolLabel(source)
2167
+ const extra = key === '' ? '' : (synonyms[key] ?? '')
2168
+ cell.dataset.dshmLabel = `${label} ${extra}`.toLowerCase()
2169
+ cell.dataset.dshmDockHidden = key !== '' && hidden.includes(key) ? '1' : '0'
2170
+ const icon = source.querySelector('svg')
2171
+ if (icon !== null) cell.appendChild(icon.cloneNode(true))
2172
+ const labelElement = document.createElement('span')
2173
+ labelElement.textContent = label
2174
+ cell.appendChild(labelElement)
2175
+ cell.addEventListener('click', () => {
2176
+ tapHaptic()
2177
+ if (grid.dataset.dshmEdit === '1') {
2178
+ if (cell.dataset.dshmPinnable !== '1') return
2179
+ const list = readHidden()
2180
+ const index = list.indexOf(key)
2181
+ if (index >= 0) list.splice(index, 1)
2182
+ else list.push(key)
2183
+ writeHidden(list)
2184
+ cell.dataset.dshmDockHidden = list.includes(key) ? '1' : '0'
2185
+ return
2186
+ }
2187
+ closeToolGrid()
2188
+ window.setTimeout(() => source.click(), 120)
2189
+ })
2190
+ cells.appendChild(cell)
2191
+ }
2192
+ if (cells.children.length === 0) {
2193
+ const empty = document.createElement('div')
2194
+ empty.className = 'dshm-grid-empty'
2195
+ empty.textContent = tr('Инструменты появятся, когда откроется чат')
2196
+ cells.appendChild(empty)
2197
+ }
2198
+ search.addEventListener('input', () => {
2199
+ const query = search.value.trim().toLowerCase()
2200
+ for (const cell of cells.querySelectorAll('.dshm-grid-cell')) {
2201
+ cell.dataset.dshmHidden = query !== '' && !cell.dataset.dshmLabel.includes(query) ? '1' : '0'
2202
+ }
2203
+ })
2204
+ grid.append(header, search, cells)
2205
+ document.body.append(scrim, grid)
2206
+ window.requestAnimationFrame(() => {
2207
+ scrim.dataset.dshmShow = '1'
2208
+ grid.dataset.dshmShow = '1'
2209
+ })
2210
+ }
2211
+
2212
+ /**
2213
+ * Долгое нажатие с перетаскиванием: инструменты дока можно менять местами.
2214
+ *
2215
+ * Короткое нажатие остаётся нажатием, горизонтальный свайп — обычная прокрутка.
2216
+ * После удержания 300 мс кнопка «приподнимается», движение меняет порядок, отпускание
2217
+ * сохраняет его в localStorage.
2218
+ *
2219
+ * @param {Element} dock контейнер дока.
2220
+ */
2221
+ function bindDockDrag(dock) {
2222
+ if (dock.dataset.dshmDragBound === '1') return
2223
+ dock.dataset.dshmDragBound = '1'
2224
+ // Android WebView открывает меню выделения/контекстное меню на долгом
2225
+ // нажатии и отбирает жест — перетаскивание не начиналось. Гасим его.
2226
+ dock.addEventListener('contextmenu', (event) => event.preventDefault(), { capture: true })
2227
+ let candidate = null
2228
+ let dragging = null
2229
+ let timer = 0
2230
+ let startX = 0
2231
+ let startY = 0
2232
+ let suppressClickUntil = 0
2233
+ // Горизонтальная прокрутка дока: touch-action:none нужен для долгого
2234
+ // нажатия/перетаскивания, поэтому прокрутку ведём вручную, с инерцией.
2235
+ let scrolling = false
2236
+ let lastX = 0
2237
+ let velocity = 0
2238
+ let momentumFrame = 0
2239
+
2240
+ /** Все переставляемые кнопки, кроме несъёмной сетки. */
2241
+ const movable = () => [...dock.querySelectorAll('[data-dshm-tool], [data-dshm-action]')]
2242
+ .filter((button) => !button.hasAttribute('data-dshm-grid'))
2243
+
2244
+ const stopMomentum = () => {
2245
+ if (momentumFrame !== 0) {
2246
+ window.cancelAnimationFrame(momentumFrame)
2247
+ momentumFrame = 0
2248
+ }
2249
+ }
2250
+ const startMomentum = () => {
2251
+ stopMomentum()
2252
+ const step = () => {
2253
+ momentumFrame = 0
2254
+ if (Math.abs(velocity) < 0.5) return
2255
+ dock.scrollLeft += velocity
2256
+ velocity *= 0.92
2257
+ momentumFrame = window.requestAnimationFrame(step)
2258
+ }
2259
+ if (Math.abs(velocity) >= 0.5) momentumFrame = window.requestAnimationFrame(step)
2260
+ }
2261
+
2262
+ const finish = () => {
2263
+ window.clearTimeout(timer)
2264
+ if (scrolling) startMomentum()
2265
+ scrolling = false
2266
+ edgeDirection = 0
2267
+ if (edgeFrame !== 0) {
2268
+ window.cancelAnimationFrame(edgeFrame)
2269
+ edgeFrame = 0
2270
+ }
2271
+ if (dragging !== null) {
2272
+ dragging.classList.remove('dshm-dragging')
2273
+ dock.dataset.dshmDragging = '0'
2274
+ dock.style.touchAction = ''
2275
+ writeOrder(movable().map((button) => sourceKey(button)).filter((key) => key !== ''))
2276
+ suppressClickUntil = Date.now() + 350
2277
+ }
2278
+ candidate = null
2279
+ dragging = null
2280
+ }
2281
+
2282
+ dock.addEventListener('pointerdown', (event) => {
2283
+ stopMomentum()
2284
+ scrolling = false
2285
+ velocity = 0
2286
+ const target = event.target instanceof Element ? event.target.closest('[data-dshm-tool], [data-dshm-action]') : null
2287
+ if (target === null || target.hasAttribute('data-dshm-grid')) return
2288
+ candidate = target
2289
+ startX = event.clientX
2290
+ startY = event.clientY
2291
+ lastX = event.clientX
2292
+ // Захват указателя: движение продолжает приходить в док, даже если палец
2293
+ // ушёл за пределы кнопки/контейнера.
2294
+ try { target.setPointerCapture(event.pointerId) } catch { /* старые WebView */ }
2295
+ window.clearTimeout(timer)
2296
+ timer = window.setTimeout(() => {
2297
+ if (candidate === null) return
2298
+ stopMomentum()
2299
+ dragging = candidate
2300
+ tapHaptic()
2301
+ dragging.classList.add('dshm-dragging')
2302
+ dock.dataset.dshmDragging = '1'
2303
+ dock.style.touchAction = 'none'
2304
+ for (const tip of document.querySelectorAll('.dshm-tip')) tip.dataset.dshmShow = '0'
2305
+ }, 250)
2306
+ }, { passive: true })
2307
+
2308
+ /** Переставить перетаскиваемую кнопку под координату x с FLIP-анимацией соседей. */
2309
+ const reorderAt = (x) => {
2310
+ const order = movable()
2311
+ const before = new Map(order.map((button) => [button, button.getBoundingClientRect()]))
2312
+ const target = order.find((button) => {
2313
+ if (button === dragging) return false
2314
+ const rect = button.getBoundingClientRect()
2315
+ return x < rect.left + rect.width / 2
2316
+ })
2317
+ if (target === undefined) dock.appendChild(dragging)
2318
+ else dock.insertBefore(dragging, target)
2319
+ // FLIP: остальные кнопки плавно доезжают на новое место.
2320
+ for (const [button, oldRect] of before) {
2321
+ if (button === dragging) continue
2322
+ const newRect = button.getBoundingClientRect()
2323
+ const delta = oldRect.left - newRect.left
2324
+ if (Math.abs(delta) < 1) continue
2325
+ button.animate(
2326
+ [{ transform: `translateX(${String(delta)}px)` }, { transform: 'translateX(0)' }],
2327
+ { duration: 140, easing: 'cubic-bezier(.2,.8,.2,1)' },
2328
+ )
2329
+ }
2330
+ }
2331
+
2332
+ // Магнит к краям: у края дока прокрутка продолжается сама, чтобы перетащить
2333
+ // инструмент через весь список.
2334
+ let edgeDirection = 0
2335
+ let edgeFrame = 0
2336
+ let lastPointerX = 0
2337
+ const edgeStep = () => {
2338
+ edgeFrame = 0
2339
+ if (dragging === null || edgeDirection === 0) return
2340
+ const before = dock.scrollLeft
2341
+ dock.scrollLeft += edgeDirection * 14
2342
+ if (dock.scrollLeft !== before) reorderAt(lastPointerX)
2343
+ edgeFrame = window.requestAnimationFrame(edgeStep)
2344
+ }
2345
+ const updateEdge = (x) => {
2346
+ const rect = dock.getBoundingClientRect()
2347
+ const next = x < rect.left + 36 ? -1 : x > rect.right - 36 ? 1 : 0
2348
+ if (next === edgeDirection) return
2349
+ edgeDirection = next
2350
+ if (next !== 0 && edgeFrame === 0) edgeFrame = window.requestAnimationFrame(edgeStep)
2351
+ }
2352
+
2353
+ dock.addEventListener('pointermove', (event) => {
2354
+ if (dragging !== null) {
2355
+ if (event.cancelable) event.preventDefault()
2356
+ lastPointerX = event.clientX
2357
+ reorderAt(lastPointerX)
2358
+ updateEdge(lastPointerX)
2359
+ return
2360
+ }
2361
+ if (scrolling) {
2362
+ if (event.cancelable) event.preventDefault()
2363
+ const delta = lastX - event.clientX
2364
+ dock.scrollLeft += delta
2365
+ velocity = delta
2366
+ lastX = event.clientX
2367
+ suppressClickUntil = Date.now() + 350
2368
+ return
2369
+ }
2370
+ if (candidate === null) return
2371
+ const dx = event.clientX - startX
2372
+ const dy = event.clientY - startY
2373
+ if (Math.abs(dx) > 10 && Math.abs(dx) >= Math.abs(dy)) {
2374
+ window.clearTimeout(timer)
2375
+ candidate = null
2376
+ scrolling = true
2377
+ lastX = event.clientX
2378
+ velocity = 0
2379
+ suppressClickUntil = Date.now() + 350
2380
+ if (event.cancelable) event.preventDefault()
2381
+ return
2382
+ }
2383
+ if (Math.abs(dy) > 14 || Math.abs(dx) > 14) {
2384
+ window.clearTimeout(timer)
2385
+ candidate = null
2386
+ }
2387
+ }, { passive: false })
2388
+
2389
+ dock.addEventListener('pointerup', finish)
2390
+ dock.addEventListener('pointercancel', finish)
2391
+ dock.addEventListener('click', (event) => {
2392
+ if (Date.now() < suppressClickUntil) {
2393
+ event.preventDefault()
2394
+ event.stopPropagation()
2395
+ }
2396
+ }, true)
2397
+ }
2398
+
2399
+ /**
2400
+ * Кнопка-действие нижней панели: нажатие просто вызывает скрытую кнопку клиента.
2401
+ *
2402
+ * Так в док попадают навигация и панели без дублирования логики клиента.
2403
+ *
2404
+ * @param {string} key стабильный ключ.
2405
+ * @param {string} label подпись/aria.
2406
+ * @param {string} svg иконка.
2407
+ * @param {() => Element|undefined|null} findSource поиск исходной кнопки.
2408
+ * @param {Element} dock контейнер дока.
2409
+ * @returns {Element|undefined} созданная кнопка.
2410
+ */
2411
+ function createActionButton(key, label, svg, findSource, dock) {
2412
+ const existing = dock.querySelector(`[data-dshm-action="${key}"]`)
2413
+ const source = findSource()
2414
+ if (existing !== null) {
2415
+ if (source !== null && source !== undefined) {
2416
+ delete existing.dataset.dshmMissing
2417
+ return existing
2418
+ }
2419
+ const misses = Number(existing.dataset.dshmMissing ?? 0) + 1
2420
+ existing.dataset.dshmMissing = String(misses)
2421
+ if (misses < 5) return existing
2422
+ existing.remove()
2423
+ }
2424
+ if (source === null || source === undefined) return undefined
2425
+ const button = document.createElement('button')
2426
+ button.type = 'button'
2427
+ button.dataset.dshmAction = key
2428
+ button.className = 'dshm-tool'
2429
+ button.setAttribute('aria-label', label)
2430
+ button.title = label
2431
+ button.innerHTML = svg
2432
+ button.addEventListener('click', (event) => {
2433
+ event.preventDefault()
2434
+ event.stopPropagation()
2435
+ tapHaptic()
2436
+ const dockElement = button.parentElement
2437
+ if (dockElement !== null) delete dockElement.dataset.dshmUnread
2438
+ const source = findSource()
2439
+ if (source === null || source === undefined) return
2440
+ source.click()
2441
+ // Поиск живёт в шапке рабочей области внутри бокового ящика. Клик по
2442
+ // скрытой кнопке открывает сам поиск, но ящик остаётся за краем экрана —
2443
+ // поле ввода оказывалось за пределами viewport. Открываем ящик вместе
2444
+ // с поиском.
2445
+ if (key === 'search' && !drawerOpen) setDrawer(true)
2446
+ // Подсветка «инструмент открыт»: панель/ящик появляются после клика,
2447
+ // поэтому состояние снимаем чуть позже.
2448
+ const sync = () => {
2449
+ if (key === 'menu') {
2450
+ button.setAttribute('aria-expanded', document.documentElement.hasAttribute('data-dshm-drawer') ? 'true' : 'false')
2451
+ } else if (key === 'files') {
2452
+ button.setAttribute('aria-expanded', rightPanelVisible ? 'true' : 'false')
2453
+ }
2454
+ }
2455
+ for (const delay of [80, 300, 800]) window.setTimeout(sync, delay)
2456
+ })
2457
+ dock.appendChild(button)
2458
+ return button
2459
+ }
2460
+
2461
+
2462
+ /**
2463
+ * Кнопка-шеврон в строке композера, как у нативных меню модели/доступа.
2464
+ *
2465
+ * Сам док при сворачивании исчезает целиком; шеврон остаётся на своём месте
2466
+ * и меняет направление стрелки. Раньше он жил внутри дока и исчезал вместе с
2467
+ * ним, поэтому свернуть/развернуть можно было только сеткой «Все инструменты».
2468
+ */
2469
+ function ensureDockToggle(dock) {
2470
+ const trailing = [...document.querySelectorAll(':is([class$="_trailing"], [class*="_trailing "])')]
2471
+ .find((element) => element.closest('[class*="_composerStack"]') !== null
2472
+ && element.getBoundingClientRect().width > 0
2473
+ && element.querySelector('button') !== null)
2474
+ const oldDockToggle = dock?.querySelector('.dshm-dock-toggle')
2475
+ if (oldDockToggle !== null && oldDockToggle !== undefined) oldDockToggle.remove()
2476
+ if (trailing === undefined) {
2477
+ document.querySelector('.dshm-dock-toggle')?.remove()
2478
+ return
2479
+ }
2480
+ let toggle = trailing.querySelector('.dshm-dock-toggle')
2481
+ if (toggle === null) {
2482
+ toggle = document.createElement('button')
2483
+ toggle.type = 'button'
2484
+ toggle.className = 'dshm-dock-toggle'
2485
+ toggle.innerHTML = '<span class="dshm-dock-toggle-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 7h16M4 12h10M4 17h16"/></svg></span><span class="dshm-dock-toggle-arrow" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg></span>'
2486
+ toggle.addEventListener('click', (event) => {
2487
+ event.preventDefault()
2488
+ event.stopPropagation()
2489
+ tapHaptic()
2490
+ const next = document.documentElement.getAttribute('data-dshm-dock-collapsed') !== '1'
2491
+ writeCollapsed(next)
2492
+ setAttrIfChanged(document.documentElement, 'data-dshm-dock-collapsed', next ? '1' : '0')
2493
+ })
2494
+ }
2495
+ const activity = [...trailing.children]
2496
+ .find((element) => /activity/iu.test(String(element.className)))
2497
+ const primary = [...trailing.querySelectorAll('button')]
2498
+ .find((button) => /primary/iu.test(String(button.className)))
2499
+ const anchor = activity ?? primary ?? null
2500
+ if (toggle.parentElement !== trailing) trailing.insertBefore(toggle, anchor)
2501
+ else if (toggle.nextElementSibling !== anchor) trailing.insertBefore(toggle, anchor)
2502
+ const collapsed = readCollapsed()
2503
+ const label = collapsed ? tr('Развернуть панель инструментов') : tr('Свернуть панель инструментов')
2504
+ setAttrIfChanged(toggle, 'aria-expanded', collapsed ? 'false' : 'true')
2505
+ setAttrIfChanged(toggle, 'aria-label', label)
2506
+ setAttrIfChanged(toggle, 'title', label)
2507
+ }
2508
+
2509
+ /**
2510
+ * Обновить градиенты-подсказки прокрутки дока по его позиции.
2511
+ *
2512
+ * @param {Element} dock контейнер дока.
2513
+ */
2514
+ function syncDockScroll(dock) {
2515
+ const max = dock.scrollWidth - dock.clientWidth
2516
+ const left = dock.scrollLeft
2517
+ const next = max <= 1 ? 'none' : left <= 8 ? 'start' : left >= max - 8 ? 'end' : 'middle'
2518
+ setAttrIfChanged(dock, 'data-dshm-scroll', next)
2519
+ }
2520
+
2521
+ /**
2522
+ * Нижняя панель инструментов и кнопка сворачивания ящика.
2523
+ *
2524
+ * Владелец продукта: длинные подписи статистики не нужны — нужны кнопки. Клиент
2525
+ * уже даёт кнопки статистики с полным текстом в aria-label и открывает по нажатию
2526
+ * родные диалоги, поэтому подписи прячем, а кнопки делаем квадратными 44 px.
2527
+ * Скрытые кнопки шапки (команда агентов, субагенты, фоновые задачи) переносим в
2528
+ * панель: до них нельзя дотянуться, а их панели превращаем в нижние шторки.
2529
+ */
2530
+ function markStats() {
2531
+ const dock = composerDock()
2532
+ if (dock !== null) {
2533
+ cleanupForeignDocks(dock)
2534
+ for (const element of dock.querySelectorAll('[data-dshm-meta]')) element.removeAttribute('data-dshm-meta')
2535
+ const icons = {
2536
+ agents: '<svg viewBox="0 0 24 24" aria-hidden="true" fill="currentColor"><path d="M9 11a4 4 0 1 0-4-4 4 4 0 0 0 4 4Zm7 0a3 3 0 1 0-3-3 3 3 0 0 0 3 3Zm-7 2c-3.3 0-6 1.8-6 4v2h12v-2c0-2.2-2.7-4-6-4Zm7 0c-.4 0-.9 0-1.3.1A5.7 5.7 0 0 1 17 17v2h5v-2c0-1.9-2.2-3.5-5-3.5Z"/></svg>',
2537
+ subagents: '<svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="5" r="2.2"/><circle cx="5" cy="19" r="2.2"/><circle cx="19" cy="19" r="2.2"/><path d="M12 7.2V12M12 12H5v4.8M12 12h7v4.8"/></svg>',
2538
+ jobs: '<svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="8.5"/><path d="M12 7.5V12l3 2"/></svg>',
2539
+ }
2540
+ createToolButton('agents', /Команда агентов|Agent team/i, tr('Команда агентов'), icons.agents, dock)
2541
+ createToolButton('subagents', /^\d+\s+(субагент|subagent)/iu, tr('Субагенты'), icons.subagents, dock)
2542
+ createToolButton('jobs', /^\d*\s*(фоновая задача|background task)/iu, tr('Фоновые задачи'), icons.jobs, dock)
2543
+
2544
+ // Навигация и панели — рядом со статистикой, всё под большим пальцем.
2545
+ const navIcons = {
2546
+ menu: '<svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 7h16M4 12h10M4 17h16"/></svg>',
2547
+ newSession: '<svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>',
2548
+ search: '<svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="6.5"/><path d="m16 16 4 4"/></svg>',
2549
+ plugins: '<svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="6" height="6" rx="1"/><rect x="14" y="4" width="6" height="6" rx="1"/><rect x="4" y="14" width="6" height="6" rx="1"/><rect x="14" y="14" width="6" height="6" rx="1"/></svg>',
2550
+ tasks: '<svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="8.5"/><path d="M12 7.5V12l3 2"/></svg>',
2551
+ files: '<svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"><rect x="3.5" y="4" width="17" height="16" rx="2"/><path d="M9 4v16"/></svg>',
2552
+ }
2553
+ const findButton = (pattern) => [...document.querySelectorAll('button')]
2554
+ .find((button) => pattern.test((button.getAttribute('aria-label') || '').trim()))
2555
+ createActionButton('menu', tr('Боковое меню'), navIcons.menu, () => document.querySelector('.dshm-menu'), dock)
2556
+ createActionButton('newSession', tr('Новая сессия'), navIcons.newSession,
2557
+ () => findButton(/^(Новая сессия( в|$)|New session( in|$)|新建会话)/iu), dock)
2558
+ createActionButton('search', tr('Поиск сессий'), navIcons.search,
2559
+ () => findButton(/^(Поиск сессий|Search sessions|搜索会话)$/iu), dock)
2560
+ createActionButton('plugins', tr('Плагины'), navIcons.plugins,
2561
+ () => findButton(/^(Плагины|Plugins|插件)$/iu), dock)
2562
+ createActionButton('tasks', tr('Автоматические задачи'), navIcons.tasks,
2563
+ () => findButton(/^(Автоматические задачи|Automations|自动任务)$/iu), dock)
2564
+ createActionButton('files', tr('Правая панель'), navIcons.files,
2565
+ () => {
2566
+ // Стабильные data-хуки вместо переводимых aria-label.
2567
+ const expand = document.querySelector('[data-sidebar-right-expand]')
2568
+ const close = [...document.querySelectorAll('[data-sidebar-right-toggle]')]
2569
+ .find((button) => button.getBoundingClientRect().width > 0)
2570
+ return rightPanelVisible ? (close ?? expand) : (expand ?? close)
2571
+ }, dock)
2572
+
2573
+ // Бейджи-счётчики и подсветка открытых панелей: док показывает состояние,
2574
+ // а не только запускает действия.
2575
+ const setBadge = (button, count) => {
2576
+ if (button === null || button === undefined) return
2577
+ let badge = button.querySelector('.dshm-badge')
2578
+ if (count > 0) {
2579
+ if (badge === null) {
2580
+ badge = document.createElement('span')
2581
+ badge.className = 'dshm-badge'
2582
+ button.appendChild(badge)
2583
+ }
2584
+ badge.textContent = String(count)
2585
+ } else if (badge !== null) badge.remove()
2586
+ }
2587
+ const refreshCount = (key, pattern) => {
2588
+ const button = dock.querySelector(`[data-dshm-tool="${key}"]`)
2589
+ if (button === null) return
2590
+ const source = [...document.querySelectorAll('button')]
2591
+ .find((candidate) => !candidate.classList.contains('dshm-tool')
2592
+ && pattern.test(candidate.getAttribute('aria-label') ?? ''))
2593
+ if (source === undefined) return
2594
+ // Подпись кнопки остаётся локализованной; обновляем только счётчик.
2595
+ const aria = source.getAttribute('aria-label') || ''
2596
+ const match = /(\d+)/u.exec(aria)
2597
+ setBadge(button, match === null ? 0 : Number(match[1]))
2598
+ }
2599
+ refreshCount('subagents', /^\d+\s+(субагент|subagent)/iu)
2600
+ refreshCount('jobs', /^\d*\s*(фоновая задача|background task)/iu)
2601
+ // Кнопка-сетка: альтернатива свайпу вверх, всегда в начале дока.
2602
+ if (dock.querySelector('[data-dshm-grid]') === null) {
2603
+ const gridButton = document.createElement('button')
2604
+ gridButton.type = 'button'
2605
+ gridButton.dataset.dshmGrid = ''
2606
+ gridButton.className = 'dshm-tool'
2607
+ gridButton.setAttribute('aria-label', tr('Все инструменты'))
2608
+ gridButton.title = tr('Все инструменты')
2609
+ gridButton.innerHTML = '<svg viewBox="0 0 24 24" aria-hidden="true" fill="currentColor"><circle cx="6" cy="6" r="2"/><circle cx="12" cy="6" r="2"/><circle cx="18" cy="6" r="2"/><circle cx="6" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="18" cy="12" r="2"/><circle cx="6" cy="18" r="2"/><circle cx="12" cy="18" r="2"/><circle cx="18" cy="18" r="2"/></svg>'
2610
+ gridButton.addEventListener('click', (event) => {
2611
+ event.preventDefault()
2612
+ event.stopPropagation()
2613
+ tapHaptic()
2614
+ if (dock !== null) delete dock.dataset.dshmUnread
2615
+ openToolGrid()
2616
+ })
2617
+ dock.insertBefore(gridButton, dock.firstChild)
2618
+ } else if (dock.firstElementChild?.dataset.dshmGrid === undefined) {
2619
+ dock.insertBefore(dock.querySelector('[data-dshm-grid]'), dock.firstChild)
2620
+ }
2621
+
2622
+ ensureDockToggle(dock)
2623
+ setAttrIfChanged(document.documentElement, 'data-dshm-dock-collapsed', readCollapsed() ? '1' : '0')
2624
+ if (dock.dataset.dshmSeen !== '1') {
2625
+ dock.dataset.dshmSeen = '1'
2626
+ dock.dataset.dshmEnter = '1'
2627
+ window.setTimeout(() => { delete dock.dataset.dshmEnter }, 260)
2628
+ }
2629
+
2630
+ // Первый запуск: брифинг и подсказка, что док листается и раскрывается.
2631
+ showOnboarding(false)
2632
+ try {
2633
+ if (localStorage.getItem('dshm.dockHintShown') !== '1') {
2634
+ localStorage.setItem('dshm.dockHintShown', '1')
2635
+ const hint = document.createElement('div')
2636
+ hint.className = 'dshm-dock-hint'
2637
+ hint.textContent = tr('Листайте панель · удерживайте иконку, чтобы переставить')
2638
+ document.body.appendChild(hint)
2639
+ window.requestAnimationFrame(() => { hint.dataset.dshmShow = '1' })
2640
+ window.setTimeout(() => {
2641
+ hint.dataset.dshmShow = '0'
2642
+ window.setTimeout(() => hint.remove(), 300)
2643
+ }, 5_500)
2644
+ }
2645
+ } catch {}
2646
+
2647
+ // Бегущие фоновые задачи: пульсирующий бейдж.
2648
+ const jobsTool = dock.querySelector('[data-dshm-tool="jobs"]')
2649
+ if (jobsTool !== null) {
2650
+ setAttrIfChanged(jobsTool, 'data-dshm-running', /выполняется|running/i.test(jobsTool.getAttribute('aria-label') ?? '') ? '1' : '0')
2651
+ }
2652
+
2653
+ // Живое состояние: идёт ход, заполнение контекста, вспышка завершения.
2654
+ const busy = [...document.querySelectorAll('button')]
2655
+ .some((button) => /^(Остановить|Stop|停止)\b/u.test((button.getAttribute('aria-label') ?? '').trim()))
2656
+ const wasBusy = dock.dataset.dshmBusy === '1'
2657
+ setAttrIfChanged(dock, 'data-dshm-busy', busy ? '1' : '0')
2658
+ if (wasBusy && !busy) {
2659
+ dock.dataset.dshmDone = '1'
2660
+ window.setTimeout(() => { delete dock.dataset.dshmDone }, 1_200)
2661
+ const away = document.documentElement.hasAttribute('data-dshm-drawer')
2662
+ || rightPanelVisible || onSecondaryScreen() || document.hidden
2663
+ if (away) dock.dataset.dshmUnread = '1'
2664
+ }
2665
+ const contextSource = [...dock.querySelectorAll('button')]
2666
+ .find((button) => /контекст|context/iu.test(button.getAttribute('aria-label') ?? ''))
2667
+ if (contextSource !== undefined) {
2668
+ setAttrIfChanged(contextSource, 'data-dshm-context-tool', '1')
2669
+ const match = /(\d+)\s*%/u.exec(contextSource.getAttribute('aria-label') ?? '')
2670
+ const percent = match === null ? 0 : Number(match[1])
2671
+ setAttrIfChanged(dock, 'data-dshm-context', percent >= 95 ? 'crit' : percent >= 80 ? 'warn' : 'ok')
2672
+ }
2673
+
2674
+ // Скрытые в сетке инструменты не рисуются в доке (но остаются в меню).
2675
+ const hiddenKeys = readHidden()
2676
+ for (const element of dock.querySelectorAll('[data-dshm-tool], [data-dshm-action]')) {
2677
+ const key = sourceKey(element)
2678
+ setAttrIfChanged(element, 'data-dshm-dock-hidden', hiddenKeys.includes(key) ? '1' : null)
2679
+ }
2680
+ for (const button of dock.querySelectorAll('button')) {
2681
+ if (button.dataset.dshmTool !== undefined || button.dataset.dshmAction !== undefined) continue
2682
+ const key = dockKey(button)
2683
+ if (key === '') continue
2684
+ const wrapper = button.closest('[data-composer-stat]') ?? button.parentElement
2685
+ if (wrapper === null) continue
2686
+ setAttrIfChanged(wrapper, 'data-dshm-dock-hidden', hiddenKeys.includes(key) ? '1' : null)
2687
+ }
2688
+
2689
+ // Стабильный порядок: скрытые не мешают, дальше сохранённый порядок и новые.
2690
+ const defaultOrder = ['agents', 'subagents', 'jobs', 'menu', 'newSession', 'search', 'plugins', 'tasks', 'files']
2691
+ const keyOf = (element) => element.dataset.dshmTool || element.dataset.dshmAction || ''
2692
+ const currentOrder = [...dock.children].map(keyOf).filter((key) => key !== '')
2693
+ rightPanelVisible = document.querySelector('[data-sidebar-right-open]') !== null
2694
+ setAttrIfChanged(document.documentElement, 'data-dshm-right-open', rightPanelVisible ? '' : null)
2695
+ const rightOpen = rightPanelVisible
2696
+ const contextual = rightOpen && currentOrder.includes('files') ? ['files'] : []
2697
+ const savedOrder = readOrder().filter((key) => currentOrder.includes(key) && !contextual.includes(key))
2698
+ const desired = [
2699
+ ...contextual,
2700
+ ...savedOrder,
2701
+ ...defaultOrder.filter((key) => currentOrder.includes(key)
2702
+ && !contextual.includes(key) && !savedOrder.includes(key)),
2703
+ ]
2704
+ if (currentOrder.join(',') !== desired.join(',')) {
2705
+ for (const key of desired) {
2706
+ const element = dock.querySelector(`[data-dshm-tool="${key}"], [data-dshm-action="${key}"]`)
2707
+ if (element !== null) dock.appendChild(element)
2708
+ }
2709
+ }
2710
+ bindDockDrag(dock)
2711
+ if (dock.dataset.dshmBound !== '') {
2712
+ dock.dataset.dshmBound = ''
2713
+ dock.addEventListener('scroll', () => syncDockScroll(dock), { passive: true })
2714
+ const touched = () => { dock.dataset.dshmTouched = '1' }
2715
+ dock.addEventListener('pointerdown', touched, { passive: true })
2716
+ dock.addEventListener('touchstart', touched, { passive: true })
2717
+ // Свайп вверх по доку раскрывает сетку всех инструментов.
2718
+ let swipeStart = null
2719
+ dock.addEventListener('touchstart', (event) => {
2720
+ const touch = event.touches[0]
2721
+ if (touch === undefined) return
2722
+ swipeStart = { x: touch.clientX, y: touch.clientY }
2723
+ }, { passive: true })
2724
+ dock.addEventListener('touchmove', (event) => {
2725
+ if (swipeStart === null) return
2726
+ const touch = event.touches[0]
2727
+ if (touch === undefined) return
2728
+ const dy = swipeStart.y - touch.clientY
2729
+ const dx = Math.abs(touch.clientX - swipeStart.x)
2730
+ if (dy > 36 && dy > dx * 1.6) {
2731
+ swipeStart = null
2732
+ if (event.cancelable) event.preventDefault()
2733
+ openToolGrid()
2734
+ }
2735
+ }, { passive: false })
2736
+ dock.addEventListener('touchend', () => { swipeStart = null }, { passive: true })
2737
+ }
2738
+ // Пока человек не трогал док, держим его в начале: новые кнопки при отрисовке
2739
+ // иначе уводили прокрутку к концу (scroll anchoring клиента).
2740
+ if (dock.dataset.dshmTouched !== '1') dock.scrollLeft = 0
2741
+ syncDockScroll(dock)
2742
+
2743
+ // Иконки без подписей: долгое нажатие показывает название над кнопкой.
2744
+ if (dock.dataset.dshmTips !== '1') {
2745
+ dock.dataset.dshmTips = '1'
2746
+ let tip = null
2747
+ let timer = 0
2748
+ const hideTip = () => {
2749
+ window.clearTimeout(timer)
2750
+ if (tip !== null) tip.dataset.dshmShow = '0'
2751
+ }
2752
+ const showTip = (button) => {
2753
+ if (document.querySelector('.dshm-dragging') !== null) return
2754
+ const label = (button.getAttribute('aria-label') || button.title || '').trim()
2755
+ if (label === '') return
2756
+ if (tip === null) {
2757
+ tip = document.createElement('div')
2758
+ tip.className = 'dshm-tip'
2759
+ document.body.appendChild(tip)
2760
+ }
2761
+ tip.textContent = label
2762
+ const rect = button.getBoundingClientRect()
2763
+ tip.style.left = `${Math.min(Math.max(rect.left + rect.width / 2, 60), window.innerWidth - 60)}px`
2764
+ tip.style.top = `${Math.max(rect.top - 6, 8)}px`
2765
+ tip.dataset.dshmShow = '1'
2766
+ }
2767
+ dock.addEventListener('pointerdown', (event) => {
2768
+ const button = event.target instanceof Element ? event.target.closest('.dshm-tool') : null
2769
+ if (button === null) return
2770
+ window.clearTimeout(timer)
2771
+ timer = window.setTimeout(() => showTip(button), 420)
2772
+ }, { passive: true })
2773
+ for (const type of ['pointerup', 'pointercancel', 'pointerleave', 'scroll']) {
2774
+ dock.addEventListener(type, hideTip, { passive: true })
2775
+ }
2776
+ }
2777
+ // Если шторку закрыли системной «назад»/Escape, снимаем aria-expanded.
2778
+ const sheet = document.querySelector('[data-dshm-sheet]')
2779
+ for (const button of dock.querySelectorAll('.dshm-tool')) {
2780
+ if (button.getAttribute('aria-expanded') === 'true' && sheet === null) button.setAttribute('aria-expanded', 'false')
2781
+ }
2782
+ // Затемнение шторки не должно оставаться, если клиент закрыл панель сам.
2783
+ if (document.querySelector('[data-dshm-sheet]') === null) {
2784
+ for (const scrim of document.querySelectorAll('.dshm-sheet-scrim')) {
2785
+ scrim.dataset.dshmShow = '0'
2786
+ window.setTimeout(() => scrim.remove(), 220)
2787
+ }
2788
+ }
2789
+
2790
+ // Чужой элемент с нашей меткой (например, правая панель от старой версии
2791
+ // слоя) не должен оставаться нижней шторкой.
2792
+ for (const marked of document.querySelectorAll('[data-dshm-sheet]')) {
2793
+ // Метка от прошлого экземпляра слоя (старая версия/перезагрузка) снимается;
2794
+ // шторки текущего экземпляра живут по токену, а не по хэшу клиента.
2795
+ if (marked.dataset.dshmSheetToken !== SHEET_TOKEN) {
2796
+ marked.removeAttribute('data-dshm-sheet')
2797
+ delete marked.dataset.dshmSheetToken
2798
+ marked.querySelectorAll('.dshm-sheet-close, .dshm-sheet-handle').forEach((element) => element.remove())
2799
+ }
2800
+ }
2801
+ }
2802
+
2803
+ // Сворачивание ящика внизу, обычной строкой в подвале меню.
2804
+ const column = document.querySelector(':is([class$="_sidebarCol"], [class*="_sidebarCol "])')
2805
+ if (column !== null && column.querySelector('[data-dshm-collapse]') === null) {
2806
+ const collapse = document.createElement('button')
2807
+ collapse.type = 'button'
2808
+ collapse.dataset.dshmCollapse = ''
2809
+ collapse.className = 'dshm-collapse-row'
2810
+ collapse.setAttribute('aria-label', tr('Свернуть боковую панель'))
2811
+ collapse.innerHTML = '<svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7h16M4 12h10M4 17h16"/></svg><span>' + tr('Свернуть панель') + '</span>'
2812
+ collapse.addEventListener('click', (event) => {
2813
+ event.preventDefault()
2814
+ event.stopPropagation()
2815
+ closeDrawer()
2816
+ })
2817
+ const foot = column.querySelector('[class*="_footArea"]') ?? column.querySelector('[class*="_settingsArea"]')
2818
+ ;(foot ?? column).appendChild(collapse)
2819
+ }
2820
+ }
2821
+
2822
+ /* ── 7. УВЕДОМЛЕНИЯ ЧЕРЕЗ НАТИВНЫЙ МОСТ ───────────────────────────────
2823
+ * Штатный мобильный слой на нашей странице не включается — её отдаёт наш
2824
+ * шлюз, и для плагина она выглядит настольной. Поэтому наблюдатель за
2825
+ * вопросами и завершением хода живёт здесь и зовёт нативный мост
2826
+ * приложения, только когда страница скрыта.
2827
+ */
2828
+
2829
+ /**
2830
+ * Уведомления в фоне, пока страница скрыта.
2831
+ *
2832
+ * Почему это делает слой, а не плагин компьютера: штатный мобильный слой
2833
+ * `dsh-mobile` на нашей странице не включается — её отдаёт наш шлюз, и для
2834
+ * плагина она выглядит настольной (`__DSH_MOBILE_FRONTEND__` не равен
2835
+ * `dedicated`, host — частный IP). Поэтому его наблюдатель в фоне здесь не
2836
+ * работает: проверено — карточка `[data-question-key]` в фоне видна, а
2837
+ * вызовов моста ноль; завершение хода тоже молчит. Слой сам смотрит состояние
2838
+ * композера и карточки вопросов и зовёт нативный мост приложения.
2839
+ */
2840
+ const QUESTION_SELECTOR = '[data-question-key],[data-plan-review-key]'
2841
+
2842
+ const seenQuestionKeys = new Set()
2843
+
2844
+ const COMPLETION_GRACE_MS = 8000
2845
+
2846
+ const COMPLETION_MIN_BUSY_MS = 20000
2847
+
2848
+ let bridgeCapabilities = null
2849
+
2850
+ let completionBusy = null
2851
+
2852
+ let completionBusySince = 0
2853
+
2854
+ let completionTimer = 0
2855
+
2856
+ /**
2857
+ * Возможности нативного моста приложения.
2858
+ *
2859
+ * @param {boolean} fresh запросить заново, а не взять кэш.
2860
+ * @returns {Promise<unknown>} список возможностей; пустой, если моста нет.
2861
+ */
2862
+ function bridgeCapabilityList(fresh) {
2863
+ const bridge = window.__DSH_MOBILE_NATIVE__
2864
+ if (bridge === undefined || typeof bridge.invoke !== 'function') return Promise.resolve([])
2865
+ if (!fresh && bridgeCapabilities !== null) return bridgeCapabilities
2866
+ const list = Promise.resolve()
2867
+ .then(() => (typeof bridge.capabilities === 'function' ? bridge.capabilities() : []))
2868
+ .catch(() => [])
2869
+ if (!fresh) bridgeCapabilities = list
2870
+ return list
2871
+ }
2872
+
2873
+ /**
2874
+ * Вызвать метод нативного моста, если приложение объявило его возможность.
2875
+ *
2876
+ * @param {string} capability имя возможности (`notification.notify`, `keyboard.show`).
2877
+ * @param {string} method имя метода моста.
2878
+ * @param {object} payload аргументы вызова.
2879
+ * @param {boolean} [fresh] запросить возможности заново вместо кэша.
2880
+ */
2881
+ function invokeBridge(capability, method, payload, fresh = false) {
2882
+ const bridge = window.__DSH_MOBILE_NATIVE__
2883
+ if (bridge === undefined || typeof bridge.invoke !== 'function') return
2884
+ void bridgeCapabilityList(fresh).then((capabilities) => {
2885
+ if (!Array.isArray(capabilities) || !capabilities.includes(capability)) return
2886
+ return bridge.invoke(method, payload)
2887
+ }).catch(() => {})
2888
+ }
2889
+
2890
+ /**
2891
+ * Показать уведомление через нативный мост приложения, если он умеет.
2892
+ *
2893
+ * @param {string} title заголовок.
2894
+ * @param {string} body текст.
2895
+ * @param {string} tag метка, по которой Android схлопывает повторы.
2896
+ */
2897
+ /** Тексты уведомлений на языке интерфейса: страница знает локаль, мост — нет. */
2898
+ const NOTIFY_TEXT = {
2899
+ ru: { doneTitle: 'Задача завершена', doneBody: 'Задача в DSH завершена', questionTitle: 'Нужен ваш ответ', questionBody: 'DSH ждёт вашего выбора' },
2900
+ en: { doneTitle: 'Task finished', doneBody: 'Your DSH task finished', questionTitle: 'Input needed', questionBody: 'DSH waits for your choice' },
2901
+ zh: { doneTitle: '任务已完成', doneBody: 'DSH 任务已完成', questionTitle: '需要您的回答', questionBody: 'DSH 正在等待您的选择' },
2902
+ }
2903
+
2904
+ /** Выбрать язык уведомлений по языку документа/системы. */
2905
+ function notifyStrings() {
2906
+ return NOTIFY_TEXT[uiLang()] ?? NOTIFY_TEXT.ru
2907
+ }
2908
+
2909
+ /**
2910
+ * Показать подсказку, если система не разрешает уведомления.
2911
+ *
2912
+ * Без этого отказ в POST_NOTIFICATIONS выглядел как исчезающие уведомления без
2913
+ * причины. Подсказка ведёт в системные настройки уведомлений приложения.
2914
+ */
2915
+ function showNotifyPermissionHint() {
2916
+ if (document.querySelector('.dshm-notify-hint') !== null) return
2917
+ const hint = document.createElement('button')
2918
+ hint.type = 'button'
2919
+ hint.className = 'dshm-notify-hint'
2920
+ hint.textContent = tr('Уведомления отключены — включите их в настройках приложения')
2921
+ hint.addEventListener('click', () => {
2922
+ invokeBridge('notification.settings', 'notification.settings', {})
2923
+ hint.remove()
2924
+ })
2925
+ document.body.appendChild(hint)
2926
+ window.setTimeout(() => hint.remove(), 10_000)
2927
+ }
2928
+
2929
+ /**
2930
+ * Показать уведомление через нативный мост и отреагировать на отказ в разрешении.
2931
+ *
2932
+ * @param {string} title заголовок.
2933
+ * @param {string} body текст.
2934
+ * @param {string} tag метка, по которой Android схлопывает повторы.
2935
+ */
2936
+ function notifyThroughBridge(title, body, tag) {
2937
+ const bridge = window.__DSH_MOBILE_NATIVE__
2938
+ if (bridge === undefined || typeof bridge.invoke !== 'function') return
2939
+ void bridgeCapabilityList().then((capabilities) => {
2940
+ if (!Array.isArray(capabilities) || !capabilities.includes('notification.notify')) return
2941
+ return Promise.resolve(bridge.invoke('notification.notify', { title, body, tag: tag.slice(0, 64) }))
2942
+ }).then((result) => {
2943
+ let parsed = result
2944
+ if (typeof result === 'string') { try { parsed = JSON.parse(result) } catch {} }
2945
+ if (parsed?.error?.code === 'permission_denied') showNotifyPermissionHint()
2946
+ }).catch((error) => {
2947
+ // Нативный мост бросает Error с текстом «уведомления отключены…», а не
2948
+ // возвращает код ошибки. Без этой ветки отказ снова был бы молчаливым.
2949
+ const message = String(error?.message ?? error)
2950
+ if (/уведомлен|notification|permission|отключен|denied|disabled/iu.test(message)) showNotifyPermissionHint()
2951
+ })
2952
+ }
2953
+
2954
+ /**
2955
+ * Не дать автооткрытию плана накрыть карточку подтверждения.
2956
+ *
2957
+ * Клиент при появлении plan review сам открывает вкладку «План · Markdown»; на
2958
+ * телефоне правая панель презентуется во весь экран и закрывает кнопки
2959
+ * «Запросить изменения»/«Подтвердить» (проверено: elementFromPoint в центре
2960
+ * карточки отдавал список панели). Панель по-прежнему можно открыть ссылкой
2961
+ * «Открыть план целиком»; автоматически закрываем её только один раз на карточку,
2962
+ * чтобы не спорить с человеком.
2963
+ */
2964
+ const autoClosedPlanPanels = new Set()
2965
+
2966
+ function keepPlanCardVisible() {
2967
+ const card = document.querySelector('[data-plan-review-key]')
2968
+ if (card === null) return
2969
+ const key = card.getAttribute('data-plan-review-key')
2970
+ if (key === null || key === '' || autoClosedPlanPanels.has(key)) return
2971
+ autoClosedPlanPanels.add(key)
2972
+ if (autoClosedPlanPanels.size > 10) {
2973
+ const oldest = autoClosedPlanPanels.values().next()
2974
+ if (!oldest.done) autoClosedPlanPanels.delete(oldest.value)
2975
+ }
2976
+ closeRightPanel()
2977
+ }
2978
+
2979
+ /**
2980
+ * Уведомить о вопросе, который появился, пока страница скрыта.
2981
+ *
2982
+ * Вопрос, который человек видел на экране, не уведомляем: он его уже видел.
2983
+ */
2984
+ function notifyQuestion() {
2985
+ // Если штатный мобильный слой всё-таки включён (страница отдана им, а не
2986
+ // нашим шлюзом), у него есть свой наблюдатель — не дублируем уведомления.
2987
+ if (document.documentElement.classList.contains('dsh-native-mobile-active')) return
2988
+ const card = document.querySelector(QUESTION_SELECTOR)
2989
+ if (card === null) return
2990
+ const key = card.getAttribute('data-question-key') ?? card.getAttribute('data-plan-review-key')
2991
+ if (key === null || key === '') return
2992
+ if (!document.hidden) {
2993
+ seenQuestionKeys.add(key)
2994
+ return
2995
+ }
2996
+ if (seenQuestionKeys.has(key)) return
2997
+ seenQuestionKeys.add(key)
2998
+ if (seenQuestionKeys.size > 20) {
2999
+ const oldest = seenQuestionKeys.values().next()
3000
+ if (!oldest.done) seenQuestionKeys.delete(oldest.value)
3001
+ }
3002
+ const questionText = notifyStrings()
3003
+ notifyThroughBridge(questionText.questionTitle, questionText.questionBody, `dsh-task-question-${key}`)
3004
+ }
3005
+
3006
+ /**
3007
+ * Идёт ли ход агента — по состоянию композера или кнопке остановки.
3008
+ *
3009
+ * Кнопки ищем внутри композера, а не по всей странице: на длинной переписке
3010
+ * `querySelectorAll('button')` с проверкой каждой подписи каждые 800 мс давал
3011
+ * заметную работу (и редкие кадры по 60–120 мс вместе с остальным тиком).
3012
+ * Полный обход остаётся запасным путём, когда композера на экране нет.
3013
+ */
3014
+ function composerBusy() {
3015
+ const card = document.querySelector('[data-composer-card]')
3016
+ if (card !== null) {
3017
+ // Клиент не ставит aria-busy на композер: единственный честный признак —
3018
+ // кнопка остановки внутри карточки композера (ru/en/zh).
3019
+ return [...card.querySelectorAll('button')]
3020
+ .some((button) => /Останов|Stop|停止/iu.test(button.getAttribute('aria-label') || ''))
3021
+ }
3022
+ return [...document.querySelectorAll('button')]
3023
+ .some((button) => /Останов|Stop|停止/iu.test(button.getAttribute('aria-label') || ''))
3024
+ }
3025
+
3026
+ /** Уведомить о завершении хода, если оно случилось, пока страница скрыта. */
3027
+ function notifyCompletedTurn() {
3028
+ completionTimer = 0
3029
+ if (document.documentElement.classList.contains('dsh-native-mobile-active')) return
3030
+ if (!document.hidden) return
3031
+ if (document.querySelector(QUESTION_SELECTOR) !== null) return
3032
+ const doneText = notifyStrings()
3033
+ notifyThroughBridge(doneText.doneTitle, doneText.doneBody, 'dsh-task-done')
3034
+ }
3035
+
3036
+ /**
3037
+ * Отследить переход «работает → свободен» и уведомить о завершении.
3038
+ *
3039
+ * Короткий ход ждёт паузу в несколько секунд: композер мигает между шагами, и
3040
+ * уведомление на каждом шаге было бы шумом. Ход, который держал композер
3041
+ * дольше двадцати секунд, уведомляет сразу — это уже не мигание.
3042
+ */
3043
+ function watchCompletion() {
3044
+ const busy = composerBusy()
3045
+ if (completionBusy === null) {
3046
+ completionBusy = busy
3047
+ if (busy) completionBusySince = Date.now()
3048
+ return
3049
+ }
3050
+ if (busy === completionBusy) return
3051
+ completionBusy = busy
3052
+ if (busy) {
3053
+ completionBusySince = Date.now()
3054
+ if (completionTimer !== 0) {
3055
+ clearTimeout(completionTimer)
3056
+ completionTimer = 0
3057
+ }
3058
+ return
3059
+ }
3060
+ if (!document.hidden) return
3061
+ if (Date.now() - completionBusySince >= COMPLETION_MIN_BUSY_MS) {
3062
+ notifyCompletedTurn()
3063
+ return
3064
+ }
3065
+ if (completionTimer === 0) completionTimer = setTimeout(notifyCompletedTurn, COMPLETION_GRACE_MS)
3066
+ }
3067
+
3068
+ /* ── 8. КОМПОЗЕР, ОЧЕРЕДЬ И ПОДПИСИ ─────────────────────────────────────
3069
+ * Подсказка про Cmd/Ctrl+Enter — настольная: системная клавиатура телефона
3070
+ * такую комбинацию не присылает. Заменяем её на то, что произойдёт на самом
3071
+ * деле, и делаем это не чаще раза в секунду.
3072
+ */
3073
+
3074
+ /**
3075
+ * Подсказка в поле ввода про Cmd/Ctrl+Enter — настольная.
3076
+ *
3077
+ * Пока агент работает и в очереди есть сообщения, клиент пишет в подсказке
3078
+ * «Cmd/Ctrl+Enter — отправить всю очередь с вмешательством». На телефоне такой
3079
+ * комбинации нет: системная клавиатура не присылает Ctrl, и подсказка обещает
3080
+ * жест, которого человек не может сделать. Заменяем на то, что произойдёт на
3081
+ * самом деле: очередь уйдёт после ответа.
3082
+ *
3083
+ * Подсказку рисует клиент, поэтому её текст — его узел. Замена идёт через
3084
+ * MutationObserver ниже, и чтобы поток переписки не гонял поиск по странице на
3085
+ * каждом кадре, у перевода есть шаг в секунду: подсказка не настолько срочная.
3086
+ */
3087
+ const QUEUE_HINT = /^Cmd\/Ctrl\+Enter/u
3088
+
3089
+ const QUEUE_HINT_MOBILE = {
3090
+ ru: 'Очередь уйдёт после ответа',
3091
+ en: 'The queue will be sent after the reply',
3092
+ zh: '消息将在回复后发送',
3093
+ }
3094
+
3095
+ let translatedAt = 0
3096
+
3097
+ let placeholderNode = null
3098
+
3099
+ function translateQueueHint() {
3100
+ // Узел подсказки кэшируем: поиск по окончанию класса обходит всю страницу, а
3101
+ // перевод идёт на каждое изменение DOM — не хочется платить за это на каждом
3102
+ // символе ввода. Если React пересоздал узел, ключ теряет связь и поиск повторяется.
3103
+ if (placeholderNode === null || !placeholderNode.isConnected) {
3104
+ placeholderNode = document.querySelector(':is([class$="_placeholder"], [class*="_placeholder "])')
3105
+ }
3106
+ const placeholder = placeholderNode
3107
+ if (placeholder === null) return
3108
+ const text = (placeholder.textContent ?? '').trim()
3109
+ if (!QUEUE_HINT.test(text)) return
3110
+ const language = uiLang()
3111
+ placeholder.textContent = QUEUE_HINT_MOBILE[language] ?? QUEUE_HINT_MOBILE.ru
3112
+ }
3113
+
3114
+ /**
3115
+ * Поле поиска по сессиям: на телефоне полная подсказка «Поиск по названиям
3116
+ * сессий» не влезает между иконкой поиска и кнопкой очистки и обрезается.
3117
+ * Короткая подпись остаётся понятной, а поле — целым.
3118
+ */
3119
+ function shortenSessionSearchPlaceholder() {
3120
+ const pattern = /^(Поиск по названиям сессий|Search sessions by name|按名称搜索会话)$/iu
3121
+ for (const input of document.querySelectorAll('input[placeholder]')) {
3122
+ if (!pattern.test(input.placeholder)) continue
3123
+ const short = tr('Поиск сессий')
3124
+ if (input.placeholder !== short) input.placeholder = short
3125
+ }
3126
+ }
3127
+
3128
+ /** Перевод и адаптация подписей, которые зависят от телефона. */
3129
+ function translate() {
3130
+ const now = Date.now()
3131
+ if (now - translatedAt < 1000) return
3132
+ translatedAt = now
3133
+ translateQueueHint()
3134
+ shortenSessionSearchPlaceholder()
3135
+ }
3136
+
3137
+ /** Закрывать панель после перехода и не поднимать клавиатуру. */
3138
+
3139
+ /* ── 9. КАРТОЧКА ВОПРОСА АГЕНТА ─────────────────────────────────────────
3140
+ * Enter в поле свободного ответа — перевод строки, а не отправка. Раньше это
3141
+ * поведение давал чужой компонент dsh-mobile-question-fixes; мы от него уходим
3142
+ * и сохраняем поведение своим слоем — только для касания и без модификаторов.
3143
+ */
3144
+
3145
+ /**
3146
+ * Enter в поле свободного ответа карточки вопроса — перевод строки, не отправка.
3147
+ *
3148
+ * На телефоне Enter в textarea вопроса отправлял ответ: клиент вешает обработчик на
3149
+ * документ. Раньше это поведение давал чужой компонент `dsh-mobile-question-fixes`;
3150
+ * мы от него уходим, поэтому сохраняем поведение своим слоем (как и он — только для
3151
+ * касания и без модификаторов, до обработчика клиента).
3152
+ */
3153
+ function installQuestionEnterNewline() {
3154
+ const touchPrimary = window.matchMedia('(hover: none), (pointer: coarse)')
3155
+ document.addEventListener('keydown', (event) => {
3156
+ if (!touchPrimary.matches) return
3157
+ if (event.key !== 'Enter' || event.defaultPrevented || event.isComposing || event.keyCode === 229) return
3158
+ if (event.shiftKey || event.ctrlKey || event.altKey || event.metaKey) return
3159
+ const target = event.target
3160
+ if (!(target instanceof HTMLTextAreaElement) || target.disabled || target.readOnly) return
3161
+ if (target.closest('[data-question-key]') === null) return
3162
+ event.stopPropagation()
3163
+ }, true)
3164
+ }
3165
+
3166
+ /* ── 10. СКРЫТИЕ НАСТОЛЬНЫХ ЭЛЕМЕНТОВ ───────────────────────────────────
3167
+ * «Mobile access» заменён нашим «Remote», а «Открыть в Finder» и «Другие
3168
+ * способы открыть» открывают системный диалог компьютера — на телефоне они
3169
+ * бесполезны и занимают шапку чата.
3170
+ */
3171
+
3172
+ /**
3173
+ * Спрятать то, что на телефоне не имеет смысла.
3174
+ *
3175
+ * «Mobile access» заменён нашим «Remote», а «Открыть в Finder» и «Другие способы открыть»
3176
+ * открывают системный диалог компьютера — на телефоне они бесполезны и занимают шапку
3177
+ * чата (замер: кнопки в точках 215 и 238 при ширине экрана 360).
3178
+ */
3179
+ const DESKTOP_ONLY = /^(Mobile access|DDeck|Открыть в Finder|Open in Finder|Другие способы открыть|Other ways to open|Показать расположение файла|Reveal in Finder)$/iu
3180
+
3181
+ function hideLegacyMobileAccess() {
3182
+ for (const row of document.querySelectorAll(':is([class$="_panelRow"], [class*="_panelRow "]), button')) {
3183
+ const label = (row.getAttribute('aria-label') || '').trim()
3184
+ const text = (row.innerText || '').trim()
3185
+ if (!DESKTOP_ONLY.test(label) && !DESKTOP_ONLY.test(text)) continue
3186
+ if (row.getAttribute('data-dshm-hidden') === '') continue
3187
+ row.setAttribute('data-dshm-hidden', '')
3188
+ row.style.display = 'none'
3189
+ }
3190
+ ensureBottomSidebarNav()
3191
+ }
3192
+
3193
+ /* ── 10b. ВТОРОЙ ТЕЛЕФОН ────────────────────────────────────────────────
3194
+ * Настольная панель DDeck умеет открыть окно сопряжения; телефону нужен
3195
+ * такой же путь, но с паролем, иначе любой, кто получил разблокированный
3196
+ * телефон, добавил бы себе ещё один. Пароль задаётся на компьютере.
3197
+ */
3198
+
3199
+
3200
+ /**
3201
+ * Запросить у сервера окно сопряжения после проверки пароля.
3202
+ *
3203
+ * Пароль не сохраняется и не уходит в лог: поле очищается сразу после запроса.
3204
+ */
3205
+ function openMobilePair() {
3206
+ if (document.getElementById('dshm-pair') !== null) return
3207
+ const overlay = document.createElement('div')
3208
+ overlay.id = 'dshm-pair'
3209
+ overlay.className = 'dshm-pair-overlay'
3210
+ overlay.setAttribute('role', 'dialog')
3211
+ overlay.setAttribute('aria-modal', 'true')
3212
+ overlay.setAttribute('aria-label', tr('Подключить телефон'))
3213
+
3214
+ const card = document.createElement('div')
3215
+ card.className = 'dshm-pair-card'
3216
+ const title = document.createElement('div')
3217
+ title.className = 'dshm-pair-title'
3218
+ title.textContent = tr('Подключить телефон')
3219
+ const note = document.createElement('div')
3220
+ note.className = 'dshm-pair-note'
3221
+ note.textContent = tr('Введите пароль, который задан на компьютере')
3222
+ const input = document.createElement('input')
3223
+ input.type = 'password'
3224
+ input.className = 'dshm-pair-input'
3225
+ input.autocomplete = 'new-password'
3226
+ input.placeholder = tr('Пароль')
3227
+ input.setAttribute('aria-label', tr('Пароль'))
3228
+ const error = document.createElement('div')
3229
+ error.className = 'dshm-pair-error'
3230
+ error.setAttribute('role', 'alert')
3231
+ const submit = document.createElement('button')
3232
+ submit.type = 'button'
3233
+ submit.className = 'dshm-pair-submit'
3234
+ submit.textContent = tr('Открыть окно подключения')
3235
+ const cancel = document.createElement('button')
3236
+ cancel.type = 'button'
3237
+ cancel.className = 'dshm-pair-cancel'
3238
+ cancel.textContent = tr('Отмена')
3239
+ const result = document.createElement('div')
3240
+ result.className = 'dshm-pair-result'
3241
+ card.append(title, note, input, error, submit, cancel, result)
3242
+ overlay.appendChild(card)
3243
+ document.body.appendChild(overlay)
3244
+ const close = () => overlay.remove()
3245
+ cancel.addEventListener('click', close)
3246
+ overlay.addEventListener('click', (event) => { if (event.target === overlay) close() })
3247
+ // Не поднимаем клавиатуру автоматически: иначе диалог прыгает и «моргает».
3248
+ submit.addEventListener('click', async () => {
3249
+ const password = input.value
3250
+ if (password.length === 0) { error.textContent = tr('Введите пароль'); return }
3251
+ submit.disabled = true
3252
+ error.textContent = ''
3253
+ try {
3254
+ const response = await fetch('/dsh-remote/mobile-pair', {
3255
+ method: 'POST',
3256
+ headers: { 'content-type': 'application/json' },
3257
+ body: JSON.stringify({ password }),
3258
+ })
3259
+ const data = await response.json().catch(() => ({}))
3260
+ input.value = ''
3261
+ if (!response.ok || data.ok !== true) {
3262
+ if (data.error === 'pin_not_set') error.textContent = tr('Пароль не задан на компьютере. Откройте DDeck → «Второй телефон» и задайте его.')
3263
+ else if (data.error === 'invalid_password') error.textContent = tr('Неверный пароль')
3264
+ else error.textContent = tr('Не удалось открыть подключение')
3265
+ submit.disabled = false
3266
+ return
3267
+ }
3268
+ title.textContent = tr('Покажите QR второму телефону')
3269
+ note.textContent = tr('Отсканируйте код камерой DDeck на втором телефоне.')
3270
+ input.remove()
3271
+ submit.remove()
3272
+ cancel.textContent = tr('Закрыть')
3273
+ const qr = document.createElement('div')
3274
+ qr.className = 'dshm-pair-qr'
3275
+ qr.innerHTML = String(data.qrSvg ?? '')
3276
+ const code = document.createElement('div')
3277
+ code.className = 'dshm-pair-code'
3278
+ code.textContent = `${tr('Код')}: ${String(data.code ?? '')}`
3279
+ const hint = document.createElement('div')
3280
+ hint.className = 'dshm-pair-note'
3281
+ hint.textContent = tr('Пароль хранится только на компьютере')
3282
+ result.append(qr, code, hint)
3283
+ } catch {
3284
+ input.value = ''
3285
+ error.textContent = tr('Не удалось открыть подключение')
3286
+ submit.disabled = false
3287
+ }
3288
+ })
3289
+ }
3290
+
3291
+ /* ── 10c. КОМПАКТНАЯ НИЖНЯЯ НАВИГАЦИЯ ───────────────────────────────────
3292
+ * Настольные строки «Плагины», «Автоматические задачи» и «Ещё» занимают
3293
+ * верх панели, хотя на телефоне нужнее рабочие области. Переносим их в одну
3294
+ * иконную строку внизу; «Подключить телефон» и «Плагины» прячем в «Ещё».
3295
+ */
3296
+
3297
+ let sidebarMoreOpen = false
3298
+
3299
+ /** SVG иконки нижней навигации: только чёрно-белые, без подписей. */
3300
+ const BOTTOM_ICONS = {
3301
+ account: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><circle cx="12" cy="8" r="3.4"/><path d="M4.8 20c.5-3.3 3.2-5.3 7.2-5.3s6.7 2 7.2 5.3"/></svg>',
3302
+ automations: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="8.2"/><path d="M12 7.5V12l3 2"/><path d="M7 4.5 5 2.7M17 4.5l2-1.8"/></svg>',
3303
+ more: '<svg viewBox="0 0 24 24" fill="currentColor"><circle cx="5.5" cy="12" r="1.7"/><circle cx="12" cy="12" r="1.7"/><circle cx="18.5" cy="12" r="1.7"/></svg>',
3304
+ phoneN: '<span class="dshm-phone-n"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><rect x="6.5" y="2.5" width="11" height="19" rx="2.4"/><path d="M10 5.2h4"/><path d="M10.5 19.4h3"/></svg><b>N</b></span>',
3305
+ computer: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="12" rx="2"/><path d="M8 21h8M12 17v4"/></svg>',
3306
+ plugins: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="6" height="6" rx="1"/><rect x="14" y="4" width="6" height="6" rx="1"/><rect x="4" y="14" width="6" height="6" rx="1"/><rect x="14" y="14" width="6" height="6" rx="1"/></svg>',
3307
+ }
3308
+
3309
+ /** Убедиться, что нативная боковая панель клиента открыта, а не только наш CSS-ящик. */
3310
+ function ensureNativeSidebarPanel() {
3311
+ const toggle = findButton(/Открыть боковую панель|Open sidebar|打开侧边栏/iu)
3312
+ if (toggle === undefined) return
3313
+ suppressCloseUntil = Date.now() + 500
3314
+ toggle.click()
3315
+ }
3316
+
3317
+ /** Найти скрытую нативную кнопку по подписи. */
3318
+ function nativeSidebarButton(label) {
3319
+ return [...document.querySelectorAll('button')]
3320
+ .find((button) => label.test((button.getAttribute('aria-label') || button.innerText || '').trim()))
3321
+ }
3322
+
3323
+ /** Открыть меню «Ещё» над нижней строкой. */
3324
+ function openSidebarMore() {
3325
+ if (sidebarMoreOpen) return
3326
+ sidebarMoreOpen = true
3327
+ const scrim = document.createElement('button')
3328
+ scrim.type = 'button'
3329
+ scrim.className = 'dshm-more-scrim'
3330
+ scrim.setAttribute('aria-label', tr('Закрыть'))
3331
+ const menu = document.createElement('div')
3332
+ menu.className = 'dshm-more-menu'
3333
+ menu.setAttribute('role', 'menu')
3334
+ const close = () => {
3335
+ sidebarMoreOpen = false
3336
+ scrim.remove()
3337
+ menu.remove()
3338
+ }
3339
+ scrim.addEventListener('click', close)
3340
+ const item = (label, icon, action) => {
3341
+ const button = document.createElement('button')
3342
+ button.type = 'button'
3343
+ button.className = 'dshm-more-item'
3344
+ button.setAttribute('role', 'menuitem')
3345
+ button.innerHTML = `${icon}<span>${label}</span>`
3346
+ button.addEventListener('click', () => {
3347
+ close()
3348
+ action()
3349
+ })
3350
+ menu.appendChild(button)
3351
+ }
3352
+ const plugins = nativeSidebarButton(/^(Плагины|Plugins|插件)$/iu)
3353
+ if (plugins !== undefined) {
3354
+ item(tr('Плагины'), BOTTOM_ICONS.plugins, () => {
3355
+ ensureNativeSidebarPanel()
3356
+ window.setTimeout(() => plugins.click(), 60)
3357
+ })
3358
+ }
3359
+ item(tr('Подключить телефон'), BOTTOM_ICONS.phoneN, openMobilePair)
3360
+ item(tr('Сменить компьютер'), BOTTOM_ICONS.computer, () => {
3361
+ invokeBridge('connect.open', 'connect.open', {}, true)
3362
+ })
3363
+ document.body.append(scrim, menu)
3364
+ scrim.addEventListener('click', close)
3365
+ }
3366
+
3367
+ /** Убрать прозрачный перехватчик закрытия меню аккаунта. */
3368
+ function removeAccountScrim() {
3369
+ accountScrim?.remove()
3370
+ accountScrim = null
3371
+ }
3372
+
3373
+ /** Прозрачный слой над нижней строкой: второе касание закрывает меню. */
3374
+ function ensureAccountScrim() {
3375
+ if (accountScrim !== null) return
3376
+ accountScrim = document.createElement('button')
3377
+ accountScrim.type = 'button'
3378
+ accountScrim.className = 'dshm-account-scrim'
3379
+ accountScrim.setAttribute('aria-label', tr('Закрыть'))
3380
+ accountScrim.addEventListener('click', () => {
3381
+ const menu = document.querySelector('[data-dshm-account-menu]')
3382
+ if (menu !== null) {
3383
+ menu.setAttribute('data-dshm-account-closed', '')
3384
+ menu.style.setProperty('display', 'none', 'important')
3385
+ }
3386
+ removeAccountScrim()
3387
+ })
3388
+ document.body.appendChild(accountScrim)
3389
+ }
3390
+
3391
+ /** Открыть нативное меню аккаунта и прижать его над нижней строкой. */
3392
+ function openAccountMenu() {
3393
+ const source = nativeSidebarButton(/Меню учётной записи|Account menu|账户菜单/iu)
3394
+ if (source === undefined) return
3395
+ const hidden = document.querySelector('[data-dshm-account-closed]')
3396
+ if (hidden !== null) {
3397
+ hidden.removeAttribute('data-dshm-account-closed')
3398
+ hidden.style.removeProperty('display')
3399
+ ensureAccountScrim()
3400
+ return
3401
+ }
3402
+ const opened = [...document.querySelectorAll('[role=menu]')]
3403
+ .find((element) => /Настройки|Отзыв|Выйти|Settings|Feedback|Log out|Logout/u.test(element.textContent || ''))
3404
+ if (opened !== undefined && opened.getBoundingClientRect().height > 0) {
3405
+ // Тот же нативный триггер закрывает меню повторным нажатием.
3406
+ source.click()
3407
+ return
3408
+ }
3409
+ document.documentElement.setAttribute('data-dshm-account-opening', '')
3410
+ source.click()
3411
+ // Меню позиционируется относительно скрытой родной кнопки аккаунта и
3412
+ // оказывается в левом верхнем углу. После открытия переносим его над
3413
+ // нашей нижней строкой.
3414
+ const place = () => {
3415
+ const menu = [...document.querySelectorAll('[role=menu]')]
3416
+ .find((element) => /Настройки|Отзыв|Выйти|Settings|Feedback|Log out|Logout/u.test(element.textContent || element.innerText || ''))
3417
+ if (menu === undefined) return false
3418
+ menu.dataset.dshmAccountMenu = ''
3419
+ menu.style.setProperty('top', 'auto', 'important')
3420
+ menu.style.setProperty('left', '.5rem', 'important')
3421
+ menu.style.setProperty('bottom', 'calc(env(safe-area-inset-bottom) + 3.75rem)', 'important')
3422
+ menu.style.setProperty('max-height', 'calc(100dvh - 5rem)', 'important')
3423
+ document.documentElement.removeAttribute('data-dshm-account-opening')
3424
+ ensureAccountScrim()
3425
+ return true
3426
+ }
3427
+ // Нативный портал может смонтироваться позже таймеров (особенно после
3428
+ // первого запуска). Ждём его появления наблюдателем и коротким опросом.
3429
+ let placed = false
3430
+ const finish = () => {
3431
+ document.documentElement.removeAttribute('data-dshm-account-opening')
3432
+ observer.disconnect()
3433
+ window.clearInterval(poll)
3434
+ }
3435
+ const tryPlace = () => {
3436
+ if (placed) return
3437
+ if (place()) {
3438
+ placed = true
3439
+ finish()
3440
+ }
3441
+ }
3442
+ const observer = new MutationObserver(tryPlace)
3443
+ observer.observe(document.body, { childList: true, subtree: true })
3444
+ const poll = window.setInterval(tryPlace, 50)
3445
+ tryPlace()
3446
+ window.setTimeout(() => {
3447
+ if (!placed) finish()
3448
+ }, 2000)
3449
+ }
3450
+
3451
+ /**
3452
+ * Адресованный субагент — история выполнения, а не полноценный чат.
3453
+ * Клиент сам показывает read-only composer; делаем его заметной плашкой и
3454
+ * убираем из этой области плюс/док/микрофон, чтобы не было ложных действий.
3455
+ */
3456
+ function syncSubagentReadOnly() {
3457
+ const lineage = document.querySelector('[data-slot="conversation.session.header.lineage"]')
3458
+ const switcher = lineage?.querySelector('button[aria-label*="субагент"], button[aria-label*="subagent"], button[aria-label*="Switch subagent"]') ?? null
3459
+ const frame = [...document.querySelectorAll('[role=status]')]
3460
+ .find((element) => {
3461
+ const rect = element.getBoundingClientRect()
3462
+ if (rect.width < 80 || rect.height < 20) return false
3463
+ return /read.?only|readonly|只读|только.*чтени|one-shot subagent record/iu.test(element.innerText || '')
3464
+ })
3465
+ const composer = document.querySelector(':is([class$="_composerStack"], [class*="_composerStack "])')
3466
+ const editable = composer?.querySelector('[contenteditable="true"], textarea, input:not([type="hidden"])')
3467
+ const inComposer = frame?.closest(':is([class$="_composerStack"], [class*="_composerStack "])') !== null && frame !== undefined
3468
+ const readOnly = switcher !== null || (frame !== undefined && (inComposer || editable === undefined || editable === null))
3469
+ setAttrIfChanged(document.documentElement, 'data-dshm-subagent-readonly', readOnly ? '' : null)
3470
+ // Не даём печатать в карточку субагента и на телефоне: на десктопе это
3471
+ // история выполнения. Атрибут снимается при возврате в обычную сессию.
3472
+ for (const input of document.querySelectorAll('[data-dshm-readonly-editor]')) {
3473
+ if (readOnly) continue
3474
+ input.setAttribute('contenteditable', 'true')
3475
+ input.removeAttribute('data-dshm-readonly-editor')
3476
+ }
3477
+ if (readOnly) {
3478
+ for (const input of composer?.querySelectorAll('[contenteditable="true"]') ?? []) {
3479
+ input.setAttribute('contenteditable', 'false')
3480
+ input.setAttribute('data-dshm-readonly-editor', '')
3481
+ }
3482
+ }
3483
+ let banner = document.querySelector('.dshm-readonly-banner')
3484
+ if (!readOnly) {
3485
+ banner?.remove()
3486
+ return
3487
+ }
3488
+ if (banner === null) {
3489
+ banner = document.createElement('div')
3490
+ banner.className = 'dshm-readonly-banner'
3491
+ banner.setAttribute('role', 'status')
3492
+ if (frame !== undefined && frame.parentElement !== null) frame.parentElement.insertBefore(banner, frame)
3493
+ else if (composer?.parentElement !== null && composer?.parentElement !== undefined) composer.parentElement.insertBefore(banner, composer)
3494
+ else document.body.appendChild(banner)
3495
+ }
3496
+ banner.innerHTML = `<strong>${tr('Просмотр субагента')}</strong><span>${tr('Отправка сообщений недоступна — это история выполнения.')}</span>`
3497
+ if (frame !== undefined) frame.setAttribute('data-dshm-readonly-frame', '')
3498
+ }
3499
+
3500
+ /** Быстро синхронизировать видимость нижней строки при поиске/диалогах. */
3501
+ function syncOverlayVisibility() {
3502
+ syncSubagentReadOnly()
3503
+ if (accountScrim !== null && document.querySelector('[data-dshm-account-menu]') === null) removeAccountScrim()
3504
+ // Верхняя маска вторичной страницы не должна оставаться на чате после
3505
+ // возврата: клиент мог уйти со страницы без события scroll. Считаем
3506
+ // состояние каждый тик по видимой прокрученной странице.
3507
+ const secondaryScrolled = onSecondaryScreen()
3508
+ && [...document.querySelectorAll(':is([class$="_page"], [class*="_page "])')]
3509
+ .some((page) => page.scrollTop > 4 && page.getBoundingClientRect().width > 0
3510
+ && getComputedStyle(page).display !== 'none')
3511
+ setAttrIfChanged(document.documentElement, 'data-dshm-page-scrolled', secondaryScrolled ? '' : null)
3512
+ // Настройки/другие полноэкранные диалоги клиента не должны держать над собой
3513
+ // нашу нижнюю строку иконок от боковой панели.
3514
+ const modal = document.querySelector('[role=dialog]') !== null
3515
+ setAttrIfChanged(document.documentElement, 'data-dshm-modal', modal ? '' : null)
3516
+ const searchInput = document.querySelector('input[placeholder="Поиск сессий"]')
3517
+ const searchOpen = searchInput !== null
3518
+ && searchInput.getBoundingClientRect().width > 100
3519
+ && searchInput.getBoundingClientRect().x >= 0
3520
+ setAttrIfChanged(document.documentElement, 'data-dshm-search-open', searchOpen ? '' : null)
3521
+ // Поповеры (настройки рабочих областей, аккаунт) не должны пересекаться с
3522
+ // нижней строкой иконок; она нужна только на самой панели.
3523
+ }
3524
+
3525
+ /** Одна строка иконок внизу боковой панели. */
3526
+ function ensureBottomSidebarNav() {
3527
+ const column = document.querySelector(':is([class$="_sidebarCol"], [class*="_sidebarCol "])')
3528
+ if (column === null) return
3529
+ // Верхние настольные строки больше не нужны — рабочие области поднимаются
3530
+ // сразу под «Новая сессия».
3531
+ for (const row of column.querySelectorAll(':is([class$="_panelRow"], [class*="_panelRow "])')) {
3532
+ const label = (row.getAttribute('aria-label') || row.innerText || '').trim()
3533
+ if (!/^(Плагины|Автоматические задачи|Ещё|Plugins|Automations|More|插件|自动化|更多)$/iu.test(label)) continue
3534
+ row.setAttribute('data-dshm-top-hidden', '')
3535
+ row.style.display = 'none'
3536
+ }
3537
+ for (const element of column.querySelectorAll('[class*="_footerActions"], [data-dshm-collapse]')) {
3538
+ element.setAttribute('data-dshm-footer-hidden', '')
3539
+ }
3540
+ // Родная строка аккаунта лежит не в footerActions, а в отдельном triggerRow.
3541
+ // Скрываем её, оставляя саму кнопку в DOM: нижняя иконка вызывает её click().
3542
+ for (const row of column.querySelectorAll('[class*="_triggerRow"]')) {
3543
+ if (row.querySelector('button[aria-label*="Меню учётной записи"], button[aria-label*="Account menu"]') === null) continue
3544
+ row.setAttribute('data-dshm-footer-hidden', '')
3545
+ row.style.display = 'none'
3546
+ }
3547
+ let nav = column.querySelector('.dshm-bottom-nav')
3548
+ if (nav === null) {
3549
+ nav = document.createElement('div')
3550
+ nav.className = 'dshm-bottom-nav'
3551
+ const make = (label, icon, onclick) => {
3552
+ const button = document.createElement('button')
3553
+ button.type = 'button'
3554
+ button.className = 'dshm-bottom-button'
3555
+ button.setAttribute('aria-label', label)
3556
+ button.title = label
3557
+ button.innerHTML = icon
3558
+ button.addEventListener('click', (event) => {
3559
+ event.preventDefault()
3560
+ event.stopPropagation()
3561
+ tapHaptic()
3562
+ onclick()
3563
+ })
3564
+ nav.appendChild(button)
3565
+ return button
3566
+ }
3567
+ make(tr('Вы вошли в DeepSeek'), BOTTOM_ICONS.account, openAccountMenu)
3568
+ make(tr('Автоматические задачи'), BOTTOM_ICONS.automations, () => {
3569
+ ensureNativeSidebarPanel()
3570
+ window.setTimeout(() => nativeSidebarButton(/^(Автоматические задачи|Automations|自动化)$/iu)?.click(), 60)
3571
+ })
3572
+ make(tr('Ещё'), BOTTOM_ICONS.more, openSidebarMore)
3573
+ column.appendChild(nav)
3574
+ }
3575
+ }
3576
+
3577
+ /* ── 11. СТАРТ СЛОЯ ───────────────────────────────────────────────────── */
3578
+
3579
+ /**
3580
+ * Поднять слой: тема, кнопка меню, переходы, «назад», фокус и уведомления.
3581
+ *
3582
+ * Ошибка на старте не должна оставлять страницу без слоя молча: текст кладётся в
3583
+ * window.__dshmStartError, по нему инструменты отличают «слой не поднялся» от
3584
+ * «слой работает, но правило не сработало».
3585
+ */
3586
+ function start() {
3587
+ try {
3588
+ applyTheme()
3589
+ watchLocale()
3590
+ captureWorkspaceClick()
3591
+ new MutationObserver(applyTheme).observe(document.documentElement, { attributes: true, attributeFilter: ['style', 'class'] })
3592
+ build()
3593
+ // CSRF-заголовок здесь больше не ставим: шлюз принимает same-origin запросы без него
3594
+ // (заголовок браузера подделать нельзя, Origin проверяется строго). Вызов оставляли —
3595
+ // и он валил старт слоя целиком, когда функцию убрали: ниже не выполнялось ничего.
3596
+
3597
+ watch()
3598
+ guardAutofocus()
3599
+ translate()
3600
+ new MutationObserver(translate).observe(document.body, { childList: true, subtree: true })
3601
+ closeOnNavigate()
3602
+ installSearchFocusGuard()
3603
+ bindPanelCollapse()
3604
+ bindSessionPicking()
3605
+ bindBackButton()
3606
+ // Закрытие по системной кнопке «назад» — единственный жест, который на телефоне
3607
+ // ожидают все: открыть панель и вернуться назад должно закрывать панель, а не сайт.
3608
+ bindEscapeClose()
3609
+ } catch (error) { window.__dshmStartError = String(error && error.message || error) }
3610
+ }
3611
+
3612
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start, { once: true })
3613
+ else start()
3614
+ }())