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.
- package/LICENSE +202 -0
- package/NOTICE +40 -0
- package/README.md +96 -2
- package/THIRD-PARTY-LICENSES.md +69 -0
- package/cordis.patch.yml +22 -0
- package/icon.svg +6 -0
- package/install.cmd +4 -0
- package/install.ps1 +119 -0
- package/install.sh +95 -0
- package/lib/admin-trust.js +64 -0
- package/lib/client.js +751 -0
- package/lib/constants.js +11 -0
- package/lib/gateway-bundle.js +7063 -0
- package/lib/gateway.js +275 -0
- package/lib/index.js +1088 -0
- package/lib/mobile/index.js +82 -0
- package/lib/mobile/skin.css +1904 -0
- package/lib/mobile/skin.js +3614 -0
- package/lib/node-forge-bundle.js +17841 -0
- package/lib/qr-bundle.js +1990 -0
- package/lib/tls.js +173 -0
- package/locale/en.json +4 -0
- package/locale/ru.json +4 -0
- package/package.json +63 -4
- package/uninstall.ps1 +60 -0
- package/uninstall.sh +31 -0
|
@@ -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
|
+
}())
|