@goodandready/dsh-kanban 0.1.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js ADDED
@@ -0,0 +1,3197 @@
1
+ // Браузерная половина dsh-kanban: экран доски и карточка настроек.
2
+ //
3
+ // Идентификатор ниже обязан совпадать с `name` в package.json и с `name:` в
4
+ // cordis.patch.yml. Расхождение НЕ даёт ошибки в журнале: загрузчик молча не
5
+ // разрешает пакет, серверная половина работает, интерфейса нет.
6
+ //
7
+ // Модуль грузится загрузчиком и импортировать файлы репозитория не может,
8
+ // поэтому чистые помощники живут здесь же и выставляются наружу как `helpers`
9
+ // — тесты поднимают этот файл через node:vm и проверяют их без браузера.
10
+ window.__ModuleLoader__.load({
11
+ id: '@goodandready/dsh-kanban',
12
+ factory: (require) => {
13
+ var module = { exports: {} }
14
+ var exports = module.exports
15
+ const React = require('react')
16
+ const ReactDOM = require('react-dom/client')
17
+
18
+ const NS = 'dsh-kanban'
19
+ const COLUMN_ORDER = ['backlog', 'in-progress', 'review', 'deploy', 'cleanup', 'done']
20
+
21
+ // ---------------------------------------------------------- чистая часть
22
+
23
+ /**
24
+ * Соседи для переноса карточки. Хранилище работает не с числовым индексом,
25
+ * а с парой соседей: `afterId` — карточка выше, `beforeId` — ниже.
26
+ */
27
+ function neighboursFor(columnTasks, dragId, index) {
28
+ const rest = (columnTasks || []).filter((t) => t.id !== dragId)
29
+ const clamped = Math.max(0, Math.min(index, rest.length))
30
+ return {
31
+ afterId: clamped > 0 ? rest[clamped - 1].id : undefined,
32
+ beforeId: clamped < rest.length ? rest[clamped].id : undefined,
33
+ }
34
+ }
35
+
36
+ function tasksOf(tasks, column) {
37
+ return (tasks || []).filter((t) => t.column === column)
38
+ }
39
+
40
+ /**
41
+ * Порядок задач внутри колонки.
42
+ *
43
+ * `manual` — как прислал сервер: ручной порядок плюс живое вперёд.
44
+ * `old`/`new` сортируют по дате заведения, но НЕ ломают ни группировку по
45
+ * проектам, ни «живое вперёд»: карточки переставляются внутри своего
46
+ * проекта, а идущая работа остаётся наверху группы. Иначе заголовки
47
+ * проектов пришлось бы рисовать над каждой карточкой.
48
+ */
49
+ function sortTasks(tasks, order) {
50
+ const list = tasks || []
51
+ if (order !== 'old' && order !== 'new') return list
52
+ const sign = order === 'old' ? 1 : -1
53
+ const groups = []
54
+ const at = new Map()
55
+ for (const task of list) {
56
+ const key = task.repo || ''
57
+ if (!at.has(key)) { at.set(key, groups.length); groups.push([]) }
58
+ groups[at.get(key)].push(task)
59
+ }
60
+ const alive = (task) => (task.state === 'running' || task.state === 'waiting' || task.state === 'queued' ? 0 : 1)
61
+ const out = []
62
+ for (const group of groups) {
63
+ out.push(...group.slice().sort((a, b) => alive(a) - alive(b)
64
+ || sign * ((a.createdAt || 0) - (b.createdAt || 0))))
65
+ }
66
+ return out
67
+ }
68
+
69
+ /**
70
+ * Карточки одной сессии: номер и общее число.
71
+ *
72
+ * Пять карточек, запущенных пачкой, выглядят несвязанными, и очередь
73
+ * понятна только тому, кто её запускал, и только пока помнит. Порядок тот
74
+ * же, в каком агент их берёт: идущая первой, дальше по времени постановки.
75
+ *
76
+ * @returns {Object<string, {at: number, total: number}>} по идентификатору задачи
77
+ */
78
+ /**
79
+ * Применить одно действие ко всем отмеченным.
80
+ *
81
+ * Отдельного группового маршрута не заводим: это те же действия, что для
82
+ * одной задачи. Отказ части не срывает остальные — задачи независимы, — но
83
+ * и не съедается молча: итог называется числом, иначе человек останется
84
+ * уверен, что в архиве десять карточек, а их там семь.
85
+ *
86
+ * @returns {Promise<{done: number, failed: number}>}
87
+ */
88
+ async function applyToEach(ids, act) {
89
+ let done = 0
90
+ let failed = 0
91
+ for (const id of ids || []) {
92
+ try { await act(id); done += 1 } catch { failed += 1 }
93
+ }
94
+ return { done, failed }
95
+ }
96
+
97
+ /**
98
+ * Чем запускали в прошлый раз, на эту доску.
99
+ *
100
+ * Живёт у человека в браузере, а не в настройках плагина: это привычка
101
+ * одного, а не свойство доски. Хранилище может быть недоступно (закрытое
102
+ * окно, запрет на данные сайта) — тогда просто нечего вспомнить, и окно
103
+ * запуска обязано открыться как обычно.
104
+ */
105
+ const RECALL_KEY = 'dsh-kanban:launch:'
106
+
107
+ function recallLaunch(board) {
108
+ try {
109
+ const raw = window.localStorage.getItem(RECALL_KEY + (board || 'main'))
110
+ const saved = raw ? JSON.parse(raw) : null
111
+ return saved && typeof saved === 'object' ? saved : {}
112
+ } catch { return {} }
113
+ }
114
+
115
+ function rememberLaunch(board, choice) {
116
+ try {
117
+ window.localStorage.setItem(RECALL_KEY + (board || 'main'), JSON.stringify(choice))
118
+ } catch { /* нечем запомнить — не повод ломать запуск */ }
119
+ }
120
+
121
+ /**
122
+ * Подставить запомненное, но только то, что ядро всё ещё предлагает.
123
+ *
124
+ * Исчезнувший из сборки уровень доступа или профиль подставлять нельзя:
125
+ * задача пошла бы не с теми правами или не собралась бы вовсе. Тогда
126
+ * возвращаемся к тому, что предлагает харнесс.
127
+ *
128
+ * @param {Array<string>} known допустимые значения
129
+ */
130
+ function stillOffered(value, known, fallback) {
131
+ return typeof value === 'string' && (known || []).indexOf(value) >= 0 ? value : fallback
132
+ }
133
+
134
+ function packInfo(tasks) {
135
+ const bySession = new Map()
136
+ for (const task of tasks || []) {
137
+ const id = task && typeof task.sessionId === 'string' ? task.sessionId : ''
138
+ if (id === '') continue
139
+ if (!bySession.has(id)) bySession.set(id, [])
140
+ bySession.get(id).push(task)
141
+ }
142
+ const out = {}
143
+ for (const group of bySession.values()) {
144
+ // Одна задача в сессии — не пачка: отметка «1 из 1» ничего не сообщает.
145
+ if (group.length < 2) continue
146
+ const order = group.slice().sort((a, b) => (a.queuedAt || 0) - (b.queuedAt || 0))
147
+ order.forEach((task, at) => { out[task.id] = { at: at + 1, total: order.length } })
148
+ }
149
+ return out
150
+ }
151
+
152
+ function cardRef(task) {
153
+ if (!task || !task.repo) return ''
154
+ if (typeof task.issueNumber !== 'number') return task.repo
155
+ return task.repo + '#' + task.issueNumber
156
+ }
157
+
158
+ /**
159
+ * Проект и номер задачи РАЗДЕЛЬНО.
160
+ *
161
+ * Слипшиеся, они читаются как один непонятный идентификатор, а это две
162
+ * разные вещи: имя проекта — принадлежность, номер — адрес.
163
+ *
164
+ * Номер становится ссылкой на issue: чаще всего она нужна именно с
165
+ * карточки, а раньше пряталась в окне задачи.
166
+ */
167
+ function taskRef(task, t) {
168
+ if (!task || !task.repo) return null
169
+ const number = typeof task.issueNumber === 'number' ? '#' + task.issueNumber : ''
170
+ const href = typeof task.issueUrl === 'string' && /^https?:\/\//i.test(task.issueUrl)
171
+ ? task.issueUrl
172
+ : ''
173
+ return React.createElement('span', { className: 'dkb-ref' },
174
+ React.createElement('span', { className: 'dkb-refRepo', title: task.repo }, task.repo),
175
+ number === ''
176
+ ? null
177
+ : href === ''
178
+ ? React.createElement('span', { className: 'dkb-refNum' }, number)
179
+ : React.createElement('a', {
180
+ className: 'dkb-refNum dkb-refLink', href, target: '_blank', rel: 'noreferrer noopener',
181
+ title: t ? t('panel.issue') : undefined,
182
+ // Щелчок по номеру — «открыть issue», а не «открыть карточку».
183
+ // Два разных намерения, и путать их нельзя.
184
+ onClick: (e) => e.stopPropagation(),
185
+ }, number),
186
+ )
187
+ }
188
+
189
+ function cardStatus(task) {
190
+ const parts = []
191
+ if (task && typeof task.model === 'string' && task.model !== '') parts.push(task.model)
192
+ if (task && typeof task.branch === 'string' && task.branch !== '') parts.push(task.branch)
193
+ return parts.join(' · ')
194
+ }
195
+
196
+ /**
197
+ * Короткая строка плана для карточки: «3 из 7 · пишу тесты».
198
+ *
199
+ * План из одного пункта и план из двадцати выглядят одинаково, потому что
200
+ * на карточку едут только числа и текущий пункт; сам список остаётся в
201
+ * окне задачи.
202
+ */
203
+ function planLine(task, t) {
204
+ const progress = task && task.plan && task.plan.progress
205
+ if (!progress || !progress.total) return ''
206
+ // На идущем пункте показываем ЕГО номер, а не число сделанных: «3 из 7 ·
207
+ // пишу тесты» читается как «идёт третий», и так оно и есть. Когда
208
+ // текущего пункта нет — план не начат или закончен, — показываем сделанные.
209
+ const shown = progress.text ? progress.current : progress.done
210
+ const counter = t('plan.counter', { done: shown, total: progress.total })
211
+ return progress.text ? counter + ' · ' + progress.text : counter
212
+ }
213
+
214
+ /**
215
+ * Подпись состояния. Ожидание рисуется отдельной точкой и здесь молчит,
216
+ * иначе одно и то же сказано дважды.
217
+ */
218
+ function stateLabel(task, t) {
219
+ const state = task && task.state
220
+ if (state === 'running') return t('state.running')
221
+ if (state === 'stopped') return t('state.stopped')
222
+ return ''
223
+ }
224
+
225
+ /**
226
+ * Свёрнута ли колонка.
227
+ *
228
+ * Пустая сжимается сама: шесть колонок по 220 пикселей не помещаются на
229
+ * ноутбуке. Ручное решение человека сильнее автоматики в обе стороны —
230
+ * иначе развёрнутая пустая колонка схлопывалась бы у него на глазах.
231
+ *
232
+ * @param {object} column колонка из ответа доски
233
+ * @param {object} manual карта id -> явное решение человека
234
+ */
235
+ function isCollapsed(column, manual) {
236
+ const own = manual && manual[column.id]
237
+ if (own === true || own === false) return own
238
+ return (column.count || 0) === 0
239
+ }
240
+
241
+ /**
242
+ * Относительное время одной строкой: «5 мин», «3 ч», «3 дня».
243
+ *
244
+ * Разбор на единицу и число делает сервер незачем — правило простое и без
245
+ * настроек, а лишний круг данных дороже пяти строк здесь. Абсолютную дату
246
+ * отдаём подсказкой при наведении: точная дата требует чтения и сравнения
247
+ * с сегодняшним числом, а на доску смотрят ради скорости.
248
+ */
249
+ function agoText(now, then, t) {
250
+ if (typeof then !== 'number' || then <= 0) return ''
251
+ const delta = Math.max(0, now - then)
252
+ const min = Math.floor(delta / 60000)
253
+ if (min < 1) return t('time.now')
254
+ if (min < 60) return t('time.min', { n: min })
255
+ const hours = Math.floor(min / 60)
256
+ if (hours < 24) return t('time.hour', { n: hours })
257
+ return t('time.day', { n: Math.floor(hours / 24) })
258
+ }
259
+
260
+ /** Дата без времени: «26 авг». Год добавляем, только когда он не нынешний. */
261
+ function shortDate(then) {
262
+ if (typeof then !== 'number' || then <= 0) return ''
263
+ try {
264
+ const d = new Date(then)
265
+ const sameYear = d.getFullYear() === new Date().getFullYear()
266
+ return d.toLocaleDateString(undefined, sameYear
267
+ ? { day: 'numeric', month: 'short' }
268
+ : { day: 'numeric', month: 'short', year: 'numeric' })
269
+ } catch { return '' }
270
+ }
271
+
272
+ /** Абсолютная дата для подсказки. Нечитаемая отметка — пустая подсказка. */
273
+ function exactAt(then) {
274
+ if (typeof then !== 'number' || then <= 0) return ''
275
+ try { return new Date(then).toLocaleString() } catch { return '' }
276
+ }
277
+
278
+ /**
279
+ * Что сказать о сверке.
280
+ *
281
+ * Доска, переставшая обновляться, не имеет права молчать: отличить
282
+ * «ничего не изменилось» от «мы ослепли» иначе нечем.
283
+ *
284
+ * @returns {{tone: 'ok'|'bad'|'idle', text: string, title: string}}
285
+ */
286
+ function syncLine(sync, now, t) {
287
+ const state = (sync && sync.state) || 'never'
288
+ if (state === 'running') return { tone: 'idle', text: t('sync.running'), title: '' }
289
+ if (state === 'never') return { tone: 'idle', text: t('sync.never'), title: '' }
290
+
291
+ if (state === 'failed') {
292
+ const where = (sync.problem && sync.problem.where) || ''
293
+ const why = (sync.problem && sync.problem.message) || ''
294
+ // Время ПОСЛЕДНЕГО УСПЕХА, а не последней попытки: вопрос человека —
295
+ // когда доска в последний раз видела правду.
296
+ const seen = sync.okAt
297
+ ? t('sync.lastSeen', { ago: agoText(now, sync.okAt, t) })
298
+ : t('sync.neverSeen')
299
+ return {
300
+ tone: 'bad',
301
+ text: t('sync.failed'),
302
+ title: [where, why, seen].filter(Boolean).join(' · '),
303
+ }
304
+ }
305
+ return {
306
+ tone: 'ok',
307
+ text: t('sync.okAgo', { ago: agoText(now, sync.okAt, t) }),
308
+ title: exactAt(sync.okAt),
309
+ }
310
+ }
311
+
312
+ /**
313
+ * Каким цветом писать поверх фона метки.
314
+ *
315
+ * Считаем воспринимаемую яркость и выбираем чёрный либо белый — так же
316
+ * делает сам Gitea. Без этого `priority/low` на светло-зелёном становится
317
+ * нечитаемой, а `type/security` на тёмно-красном сливается.
318
+ *
319
+ * @param {string} hex шесть шестнадцатеричных цифр без решётки
320
+ */
321
+ function readableOn(hex) {
322
+ const n = parseInt(String(hex || ''), 16)
323
+ if (!isFinite(n)) return ''
324
+ const r = (n >> 16) & 255
325
+ const g = (n >> 8) & 255
326
+ const b = n & 255
327
+ // Коэффициенты воспринимаемой яркости: глаз чувствительнее к зелёному.
328
+ return (r * 299 + g * 587 + b * 114) / 1000 > 140 ? '#111' : '#fff'
329
+ }
330
+
331
+ /**
332
+ * Цвет полосы срочности слева на карточке.
333
+ *
334
+ * Только `priority`: полоса одна, и отдавать её первой попавшейся метке
335
+ * нельзя. Задача без срочности остаётся без полосы, а не получает серую —
336
+ * пустая полоса читалась бы как «низкий приоритет», а это неправда.
337
+ */
338
+ function priorityColor(task) {
339
+ const values = (task && task.facets && task.facets.priority) || []
340
+ for (const value of values) {
341
+ const hex = task.labelColors && task.labelColors['priority/' + value]
342
+ if (hex) return '#' + hex
343
+ }
344
+ return ''
345
+ }
346
+
347
+ /** Стиль метки. Без цвета — прежний нейтральный вид, а не чёрный прямоугольник. */
348
+ function tagStyle(task, name) {
349
+ const hex = task && task.labelColors ? task.labelColors[name] : undefined
350
+ if (!hex) return undefined
351
+ return { background: '#' + hex, color: readableOn(hex), borderColor: 'transparent' }
352
+ }
353
+
354
+ /**
355
+ * Отрисовать разобранное тело задачи.
356
+ *
357
+ * Собираем ЭЛЕМЕНТАМИ, а не строкой HTML. Тело пишет кто угодно, и
358
+ * `dangerouslySetInnerHTML` здесь означал бы выполнение чужого кода в
359
+ * интерфейсе владельца. При сборке элементами подстановка невозможна по
360
+ * построению — не потому, что кто-то не забыл про экранирование.
361
+ */
362
+ function renderSpans(spans, keyBase) {
363
+ return (spans || []).map((span, i) => {
364
+ const key = keyBase + '-' + i
365
+ if (span.kind === 'code') return React.createElement('code', { className: 'dkb-code', key }, span.text)
366
+ if (span.kind === 'strong') return React.createElement('strong', { key }, span.text)
367
+ if (span.kind === 'em') return React.createElement('em', { key }, span.text)
368
+ if (span.kind === 'link') {
369
+ return React.createElement('a', {
370
+ key, href: span.href, target: '_blank', rel: 'noreferrer noopener',
371
+ }, span.text)
372
+ }
373
+ return span.text
374
+ })
375
+ }
376
+
377
+ function renderBlocks(blocks) {
378
+ return (blocks || []).map((block, i) => {
379
+ const key = 'b' + i
380
+ if (block.kind === 'heading') {
381
+ // Заголовки тела не спорят с заголовком окна: рисуем их своим
382
+ // классом, а не h1..h6, у которых свой размер в оболочке.
383
+ return React.createElement('div', {
384
+ key, className: 'dkb-mdHead dkb-mdHead' + Math.min(block.level, 3),
385
+ }, renderSpans(block.spans, key))
386
+ }
387
+ if (block.kind === 'code') {
388
+ return React.createElement('pre', { key, className: 'dkb-mdCode' }, block.text)
389
+ }
390
+ if (block.kind === 'rule') return React.createElement('hr', { key, className: 'dkb-mdRule' })
391
+ if (block.kind === 'quote') {
392
+ return React.createElement('blockquote', { key, className: 'dkb-mdQuote' },
393
+ renderSpans(block.spans, key))
394
+ }
395
+ if (block.kind === 'list') {
396
+ return React.createElement(block.ordered ? 'ol' : 'ul', { key, className: 'dkb-mdList' },
397
+ (block.items || []).map((item, j) => React.createElement('li', { key: key + '-' + j },
398
+ renderSpans(item, key + '-' + j))))
399
+ }
400
+ return React.createElement('p', { key, className: 'dkb-mdPara' }, renderSpans(block.spans, key))
401
+ })
402
+ }
403
+
404
+ /**
405
+ * Подпись пространства меток.
406
+ *
407
+ * Ключа нет — показываем имя как есть. Это не пропуск, а решение: набор
408
+ * меток в Gitea меняют без нас, и подставлять свой перевод новому
409
+ * пространству значило бы врать о том, чего мы не знаем.
410
+ */
411
+ /**
412
+ * Название колонки: своё, если задано в настройках, иначе переведённое.
413
+ *
414
+ * Настройка одна на все колонки — парами `идентификатор=Название` через
415
+ * запятую. Шесть отдельных полей ради переименования, которое делают раз в
416
+ * жизни, засорили бы карточку настроек сильнее, чем помогли.
417
+ */
418
+ function columnTitle(col, t) {
419
+ const own = col && col.name
420
+ return own !== undefined && own !== '' ? own : t('column.' + (col && col.id))
421
+ }
422
+
423
+ function facetLabel(ns, t) {
424
+ const key = 'facet.' + ns
425
+ const text = t(key)
426
+ return text === key ? ns : text
427
+ }
428
+
429
+ /**
430
+ * Название доски.
431
+ *
432
+ * Заведённые нами `main` и `simple` переводим по идентификатору: их
433
+ * названия лежат в базе на одном языке и на другом интерфейсе остались бы
434
+ * чужими. Переименованную человеком доску не трогаем — это его слово.
435
+ */
436
+ function boardTitle(board, t) {
437
+ const key = 'boardName.' + (board && board.id)
438
+ const text = t(key)
439
+ return text === key ? (board && board.title) || '' : text
440
+ }
441
+
442
+ function normalizeBoard(payload) {
443
+ const columns = payload && Array.isArray(payload.columns) && payload.columns.length > 0
444
+ ? payload.columns
445
+ : COLUMN_ORDER.map((id) => ({ id, count: 0, limit: undefined, overLimit: false }))
446
+ return {
447
+ board: (payload && payload.board) || 'main',
448
+ kind: (payload && payload.kind) || 'project',
449
+ // Своё «сейчас» на случай ответа без него: часы браузера и сервера
450
+ // расходятся, и лучше считать по одному из них, чем по нулю.
451
+ now: (payload && typeof payload.now === 'number') ? payload.now : Date.now(),
452
+ boards: payload && Array.isArray(payload.boards) ? payload.boards : [],
453
+ facets: payload && Array.isArray(payload.facets) ? payload.facets : [],
454
+ sync: (payload && payload.sync) || { state: 'never' },
455
+ columns,
456
+ tasks: payload && Array.isArray(payload.tasks) ? payload.tasks : [],
457
+ // Фактический корень проектов: {path, set} — путь и признак «задан
458
+ // настройкой». Сервер знает и настройку, и рабочую папку харнесса,
459
+ // браузеру ни то ни другое не доступно.
460
+ projectRoot: payload && typeof payload.projectRoot === 'object' && payload.projectRoot !== null
461
+ ? { path: String(payload.projectRoot.path || ''), set: payload.projectRoot.set === true }
462
+ : { path: '', set: false },
463
+ }
464
+ }
465
+
466
+ /** Отбор по репозиторию стоит в одном ряду с метками; имя должно совпадать с сервером. */
467
+ const REPO_FACET = 'repo'
468
+
469
+ /**
470
+ * Подходит ли задача под выбранное.
471
+ *
472
+ * Между пространствами — И, внутри пространства — ИЛИ: срочность у задачи
473
+ * одна, и «И» внутри пространства всегда давало бы пусто.
474
+ *
475
+ * Метки уже разобраны сервером и приезжают в `task.facets`. Разбирать их
476
+ * здесь во второй раз значило бы завести вторую правду о том, где кончается
477
+ * пространство и начинается значение.
478
+ */
479
+ function matchesFilters(task, selected) {
480
+ const keys = Object.keys(selected || {})
481
+ for (const ns of keys) {
482
+ const wanted = selected[ns] || []
483
+ if (wanted.length === 0) continue
484
+ const have = ns === REPO_FACET
485
+ ? [task && task.repo ? task.repo : '']
486
+ : ((task && task.facets && task.facets[ns]) || [])
487
+ if (!have.some((v) => wanted.indexOf(v) >= 0)) return false
488
+ }
489
+ return true
490
+ }
491
+
492
+ /** Выбрано ли что-нибудь: пустой набор доску не сужает. */
493
+ function anySelected(selected) {
494
+ const keys = Object.keys(selected || {})
495
+ for (const ns of keys) if ((selected[ns] || []).length > 0) return true
496
+ return false
497
+ }
498
+
499
+ /** Переключить значение. Новая карта, а не правка на месте: иначе React не перерисует. */
500
+ function toggleValue(selected, ns, value) {
501
+ const current = (selected || {})[ns] || []
502
+ const next = current.indexOf(value) >= 0
503
+ ? current.filter((v) => v !== value)
504
+ : current.concat([value])
505
+ const out = Object.assign({}, selected || {})
506
+ if (next.length === 0) delete out[ns]
507
+ else out[ns] = next
508
+ return out
509
+ }
510
+
511
+ /**
512
+ * Человеческая причина отказа. Пустой список без объяснения читается как
513
+ * «issue нет», и человек идёт искать несуществующую проблему.
514
+ */
515
+ function errorKey(error) {
516
+ switch (error) {
517
+ case 'gitea-absent': return 'error.giteaAbsent'
518
+ case 'gitea-unconfigured': return 'error.giteaUnconfigured'
519
+ case 'task-not-found': return 'error.taskNotFound'
520
+ case 'task-has-no-issue': return 'error.taskHasNoIssue'
521
+ case 'issue-not-found': return 'error.issueNotFound'
522
+ case 'title-required': return 'error.titleRequired'
523
+ case 'cross-site': return 'error.crossSite'
524
+ case 'model-not-selected': return 'error.modelNotSelected'
525
+ case 'start-failed': return 'error.startFailed'
526
+ default: return 'error.unknown'
527
+ }
528
+ }
529
+
530
+ /**
531
+ * Открыть чат поднятой сессии.
532
+ *
533
+ * Служба сессий берётся через `get`, а не объявляется в `inject`: если её в
534
+ * сборке не окажется, доска обязана сказать об этом человеку, а не
535
+ * отказаться загружаться целиком.
536
+ *
537
+ * @returns {boolean} удалось ли открыть
538
+ */
539
+ function openSession(ctx, sessionId) {
540
+ if (!sessionId) return false
541
+ const sessions = ctx && typeof ctx.get === 'function' ? ctx.get('sessions') : undefined
542
+ if (!sessions || typeof sessions.open !== 'function') return false
543
+ try {
544
+ sessions.open(sessionId)
545
+ return true
546
+ } catch {
547
+ return false
548
+ }
549
+ }
550
+
551
+ /**
552
+ * Подпись репозитория в списке: имя и сколько в нём открытых задач.
553
+ * Архивный помечается словом, а не только положением в списке.
554
+ */
555
+ function repoOption(r, t) {
556
+ const full = r.fullName || ((r.owner || '') + '/' + (r.repo || ''))
557
+ const parts = [full]
558
+ if ((r.openIssues || 0) > 0) parts.push('· ' + r.openIssues)
559
+ if (r.archived) parts.push('· ' + t('dialog.archived'))
560
+ return parts.join(' ')
561
+ }
562
+
563
+ /**
564
+ * Разобрать `владелец/репозиторий` в пару.
565
+ *
566
+ * Владельца отдельным полем не спрашиваем: он однозначно следует из
567
+ * выбранного репозитория, а лишнее поле — лишний способ ошибиться.
568
+ */
569
+ function splitFullName(fullName) {
570
+ const parts = String(fullName || '').split('/')
571
+ if (parts.length !== 2 || parts[0] === '' || parts[1] === '') return undefined
572
+ return { owner: parts[0], repo: parts[1] }
573
+ }
574
+
575
+ /**
576
+ * Отбор карточек по строке поиска: заголовок, репозиторий, номер issue.
577
+ *
578
+ * Фильтр держится на стороне браузера намеренно: доска и так вся в памяти,
579
+ * а поход на сервер за каждой буквой сделал бы набор дёрганым.
580
+ */
581
+ function matchesQuery(task, query) {
582
+ const needle = String(query || '').trim().toLowerCase()
583
+ if (needle === '') return true
584
+ const hay = [
585
+ task && task.title,
586
+ task && task.repo,
587
+ task && typeof task.issueNumber === 'number' ? '#' + task.issueNumber : '',
588
+ task && Array.isArray(task.labels) ? task.labels.join(' ') : '',
589
+ ].filter(Boolean).join(' ').toLowerCase()
590
+ return hay.indexOf(needle) >= 0
591
+ }
592
+
593
+ /**
594
+ * Идентификатор сессии для чипа. Ядро отдаёт его либо свойством, либо
595
+ * через useSession — берём первое, что есть.
596
+ */
597
+ function chipSessionId(props, session) {
598
+ return String((props && props.sessionId) || (session && (session.sessionId || session.id)) || '').trim()
599
+ }
600
+
601
+ const helpers = {
602
+ neighboursFor, tasksOf, sortTasks, packInfo, applyToEach, recallLaunch, rememberLaunch, stillOffered,
603
+ cardRef, taskRef, cardStatus, facetLabel, boardTitle,
604
+ columnTitle, planLine, stateLabel, isCollapsed,
605
+ agoText, exactAt, shortDate, syncLine, readableOn, tagStyle, priorityColor, renderBlocks, renderSpans, matchesFilters, anySelected, toggleValue, normalizeBoard, errorKey,
606
+ chipSessionId, matchesQuery, splitFullName, repoOption, openSession, COLUMN_ORDER,
607
+ // createToggle подставляется ниже, когда объявлен: он нужен тестам, а
608
+ // объявление живёт рядом со встраиванием в оболочку.
609
+ }
610
+
611
+ // ------------------------------------------------------------------ сеть
612
+
613
+ async function api(path, options) {
614
+ const res = await fetch('/dsh-kanban' + path, Object.assign({
615
+ headers: { 'Content-Type': 'application/json' },
616
+ }, options))
617
+ let payload = null
618
+ try { payload = await res.json() } catch { payload = null }
619
+ if (!res.ok) {
620
+ const err = new Error((payload && payload.error) || 'request-failed')
621
+ err.key = errorKey(payload && payload.error)
622
+ throw err
623
+ }
624
+ return payload
625
+ }
626
+
627
+ // ----------------------------------------------------------------- стили
628
+
629
+ const css =
630
+ '.dkb-card{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:12px;list-style:none}' +
631
+ '.dkb-cardOpen{background:var(--dsw-alias-bg-layer-2);border-color:var(--dsw-alias-label-dimmed)}' +
632
+ '.dkb-head{appearance:none;width:100%;font:inherit;color:inherit;text-align:left;cursor:pointer;background:0 0;border:0;border-radius:12px;display:flex;align-items:center;gap:12px;padding:14px 16px}' +
633
+ '.dkb-headText{flex-direction:column;flex:1;gap:4px;min-width:0;display:flex}' +
634
+ '.dkb-title{color:var(--dsw-alias-label-primary);font-size:15px;font-weight:600;line-height:1.4}' +
635
+ '.dkb-sub{color:var(--dsw-alias-label-secondary);font-size:13px}' +
636
+ '.dkb-chevron{color:var(--dsw-alias-label-secondary);flex:none;transition:transform .16s}' +
637
+ '.dkb-chevronOpen{transform:rotate(180deg)}' +
638
+ '.dkb-body{border-top:1px solid var(--dsw-alias-border-l2);margin:0 16px;padding-bottom:8px}' +
639
+ '.dkb-group{color:var(--dsw-alias-label-secondary);font-size:12px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;padding:14px 0 2px}' +
640
+ '.dkb-field{display:flex;flex-direction:column;gap:6px;padding:10px 0}' +
641
+ '.dkb-label{min-width:0;color:var(--dsw-alias-label-primary);flex:1;font-size:13px;font-weight:500;line-height:1.5}' +
642
+ '.dkb-hint{color:var(--dsw-alias-label-secondary);margin:0;font-size:12px;line-height:1.5}' +
643
+ '.dkb-input{height:34px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);color:var(--dsw-alias-label-primary);border-radius:8px;padding:0 12px;font-size:13px;box-sizing:border-box;width:100%}' +
644
+ '.dkb-area{min-height:96px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);color:var(--dsw-alias-label-primary);border-radius:8px;padding:8px 12px;font-size:13px;box-sizing:border-box;width:100%;resize:vertical;font:inherit}' +
645
+ '.dkb-input:disabled,.dkb-area:disabled{opacity:.55}' +
646
+ '.dkb-foot{border-top:1px solid var(--dsw-alias-border-l2);display:flex;justify-content:flex-end;align-items:center;gap:8px;padding:12px 0 4px}' +
647
+ '.dkb-footSplit{justify-content:space-between}' +
648
+ '.dkb-footLeft{display:flex;align-items:center;gap:8px}' +
649
+ '.dkb-note{min-width:0;flex:1;margin:0;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-secondary)}' +
650
+ '.dkb-failed{min-width:0;color:var(--dsw-alias-label-error);flex:1;margin:0;font-size:12px;line-height:1.5}' +
651
+ '.dkb-save{appearance:none;font:inherit;cursor:pointer;border:1px solid transparent;border-radius:8px;padding:5px 14px;font-size:13px;background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3)}' +
652
+ '.dkb-discard{appearance:none;font:inherit;cursor:pointer;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:5px 14px;font-size:13px;color:var(--dsw-alias-label-secondary);background:transparent}' +
653
+ '.dkb-discard:disabled,.dkb-save:disabled{opacity:.4;cursor:default}' +
654
+ // Строка в боковой панели. Метрики и переменные взяты у оболочки, чтобы
655
+ // строка не выделялась среди родных: та же высота, те же состояния.
656
+ // Кнопка — клон родной, поэтому вид берётся у оболочки. Своего здесь
657
+ // ровно столько, чтобы разложить значок и подпись внутри клона.
658
+ '.dkb-entry-clone{align-items:center;gap:8px;display:flex}' +
659
+ '.dkb-entry-clone[data-active]{font-weight:600}' +
660
+ '.dkb-entryIcon{flex:none;justify-content:center;align-items:center;display:inline-flex}' +
661
+ '.dkb-entryLabel{text-overflow:ellipsis;overflow:hidden}' +
662
+ '[data-dsh-frame][data-sidebar-collapsed] .dkb-entryLabel{display:none}' +
663
+ // Экран поверх колонки разговора. Показывается атрибутом на <html>, а
664
+ // соседи колонки прячутся — иначе доска легла бы поверх живого чата.
665
+ '[data-pane=conversation],[class*=centerCol]{position:relative}' +
666
+ '[data-dsh-kanban-view]{z-index:60;background:var(--dsw-alias-bg-base);display:none;position:absolute;inset:0}' +
667
+ 'html[data-dsh-kanban-active]:not([data-dsh-ssh-active]) [data-dsh-kanban-view]{display:block}' +
668
+ 'html[data-dsh-kanban-active]:not([data-dsh-ssh-active]) [data-pane=conversation]>:not([data-dsh-kanban-view]),' +
669
+ 'html[data-dsh-kanban-active]:not([data-dsh-ssh-active]) [class*=centerCol]>:not([data-dsh-kanban-view]){display:none!important}' +
670
+ // Сама доска
671
+ '.dkb-screen{box-sizing:border-box;background:var(--dsw-alias-bg-base);min-width:0;height:100%;min-height:0;color:var(--dsw-alias-label-primary);font-family:var(--dsw-font-family);flex-direction:column;gap:12px;padding:14px 16px 56px;display:flex}' +
672
+ '.dkb-bar{flex:none;align-items:center;gap:8px;display:flex;flex-wrap:wrap}' +
673
+ // Всё управление в шапке одной высоты: пять разных высот подряд читаются
674
+ // как ряд, собранный из чужих деталей.
675
+ '.dkb-bar .dkb-input,.dkb-bar .dkb-search,.dkb-bar .dkb-save,.dkb-bar .dkb-discard,.dkb-bar .dkb-facetHead,.dkb-bar .dkb-back{height:30px;box-sizing:border-box;padding-top:0;padding-bottom:0;border-radius:8px;font-size:13px;line-height:28px;display:inline-flex;align-items:center}' +
676
+ '.dkb-bar .dkb-facet{display:inline-flex}' +
677
+ '.dkb-back{appearance:none;font:inherit;cursor:pointer;color:var(--dsw-alias-label-secondary);background:0 0;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;align-items:center;gap:6px;padding:4px 10px;font-size:13px;display:flex;flex:none}' +
678
+ '.dkb-back:hover{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-interactive-bg-hover)}' +
679
+ '.dkb-backIcon{align-items:center;display:flex}' +
680
+ '.dkb-archive{flex:1;min-height:0;overflow-y:auto;display:flex;flex-direction:column;gap:8px}' +
681
+ '.dkb-archiveList{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:6px}' +
682
+ '.dkb-archiveRow{background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l1);border-radius:10px;align-items:center;gap:12px;padding:8px 12px;display:flex}' +
683
+ '.dkb-start{display:contents}' +
684
+ '.dkb-pack{color:var(--dsw-alias-label-secondary)}' +
685
+ '.dkb-sameSession{outline:1px solid var(--dsw-alias-label-secondary);outline-offset:1px}' +
686
+ '.dkb-waitCount{background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l1);border-radius:8px;padding:0 10px;height:30px;cursor:pointer;color:var(--dsw-alias-label-primary)}' +
687
+ '.dkb-archiveOpen{background:none;border:0;padding:0;text-align:left;cursor:pointer;font:inherit}' +
688
+ '.dkb-archiveTitle{flex:1;font-size:13px;color:var(--dsw-alias-label-primary)}' +
689
+ '.dkb-urgent{border-left-width:3px;border-left-style:solid;padding-left:9px}' +
690
+ '.dkb-cardMenu{position:relative;align-self:flex-start;opacity:0;transition:opacity .12s}' +
691
+ '.dkb-taskCard:hover .dkb-cardMenu,.dkb-taskCard:focus-within .dkb-cardMenu,.dkb-taskCard:focus-visible .dkb-cardMenu{opacity:1}' +
692
+ '.dkb-groupHead{appearance:none;width:100%;font:inherit;color:var(--dsw-alias-label-secondary);text-align:left;cursor:pointer;background:0 0;border:0;border-top:1px solid var(--dsw-alias-border-l2);align-items:center;gap:6px;padding:8px 2px 4px;font-size:11px;font-weight:600;display:flex}' +
693
+ '.dkb-groupHead:first-child{border-top:0;padding-top:2px}' +
694
+ '.dkb-groupHead:hover{color:var(--dsw-alias-label-primary)}' +
695
+ '.dkb-groupCount{margin-left:auto;color:var(--dsw-alias-label-tertiary);font-weight:400}' +
696
+ '.dkb-shown{color:var(--dsw-alias-label-tertiary);white-space:nowrap;font-size:12px;flex:none}' +
697
+ '.dkb-moveBtn{appearance:none;font:inherit;cursor:pointer;color:var(--dsw-alias-label-tertiary);background:0 0;border:0;padding:0;font-size:11px;text-decoration:underline dotted}' +
698
+ '.dkb-moveBtn:hover{color:var(--dsw-alias-label-primary)}' +
699
+ '.dkb-taskCard:focus-visible{outline:2px solid var(--dsw-alias-border-l3);outline-offset:2px}' +
700
+ '.dkb-sync{color:var(--dsw-alias-label-tertiary);white-space:nowrap;font-size:12px;flex:none}' +
701
+ '.dkb-syncBad{color:var(--dsw-alias-status-danger,#e5534b);cursor:help}' +
702
+ '.dkb-facet{position:relative;flex:none}' +
703
+ '.dkb-facetHead{appearance:none;font:inherit;cursor:pointer;color:var(--dsw-alias-label-secondary);background:0 0;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:4px 10px;font-size:13px;white-space:nowrap}' +
704
+ '.dkb-facetHead:hover{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-interactive-bg-hover)}' +
705
+ '.dkb-facetOn{color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-border-l3);background:var(--dsw-alias-interactive-bg-hover)}' +
706
+ '.dkb-facetList{position:absolute;top:calc(100% + 4px);left:0;z-index:60;min-width:180px;max-height:280px;overflow-y:auto;background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l2);border-radius:8px;box-shadow:var(--dsw-shadow-lv2);padding:6px;display:flex;flex-direction:column;gap:2px}' +
707
+ '.dkb-facetRow{align-items:center;gap:8px;padding:4px 6px;border-radius:6px;font-size:13px;cursor:pointer;display:flex}' +
708
+ '.dkb-facetRow:hover{background:var(--dsw-alias-interactive-bg-hover)}' +
709
+ '.dkb-facetName{flex:1;color:var(--dsw-alias-label-primary)}' +
710
+ '.dkb-barTitle{color:var(--dsw-alias-label-primary);white-space:nowrap;margin:0;font-size:16px;font-weight:700;margin-right:auto}' +
711
+ '.dkb-cols{overscroll-behavior-inline:contain;scrollbar-color:var(--dsw-alias-border-l3) var(--dsw-alias-interactive-bg-hover);scrollbar-width:thin;flex:1;gap:12px;min-height:0;padding-bottom:6px;display:flex;overflow:auto hidden}' +
712
+ '.dkb-cols::-webkit-scrollbar{height:10px}' +
713
+ '.dkb-cols::-webkit-scrollbar-track{background:var(--dsw-alias-interactive-bg-hover);border-radius:999px}' +
714
+ '.dkb-cols::-webkit-scrollbar-thumb{background:var(--dsw-alias-border-l3);background-clip:content-box;border:2px solid #0000;border-radius:999px}' +
715
+ '.dkb-col{flex:1 1 220px;min-width:220px;background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l1);border-radius:12px;flex-direction:column;min-height:0;display:flex;overflow:hidden}' +
716
+ '.dkb-colOver{border-color:var(--dsw-alias-border-l3)}' +
717
+ '.dkb-colHead{appearance:none;width:100%;font:inherit;color:inherit;text-align:left;cursor:pointer;background:0 0;border:0;flex:none;align-items:center;gap:6px;padding:10px 12px;display:flex}' +
718
+ '.dkb-colHead:hover{background:var(--dsw-alias-interactive-bg-hover)}' +
719
+ '.dkb-colShut{flex:0 0 auto;min-width:0;width:40px}' +
720
+ '.dkb-colShut .dkb-colHead{writing-mode:vertical-rl;height:100%;width:auto;padding:12px 10px;justify-content:flex-start;gap:10px}' +
721
+ '.dkb-colShut .dkb-colHead .dkb-dot{writing-mode:horizontal-tb}' +
722
+ '.dkb-colShut .dkb-count{writing-mode:horizontal-tb}' +
723
+ '.dkb-dot{border-radius:50%;flex:none;width:8px;height:8px}' +
724
+ '.dkb-dot[data-col=backlog]{background:var(--dsw-alias-label-tertiary)}' +
725
+ '.dkb-dot[data-col=in-progress]{background:var(--dsw-alias-state-warn-primary)}' +
726
+ '.dkb-dot[data-col=review]{background:var(--dsw-alias-state-business-primary)}' +
727
+ '.dkb-dot[data-col=deploy]{background:var(--dsw-alias-state-business-primary)}' +
728
+ '.dkb-dot[data-col=cleanup]{background:var(--dsw-alias-label-tertiary)}' +
729
+ '.dkb-dot[data-col=done]{background:var(--dsw-alias-state-success-primary)}' +
730
+ '.dkb-colName{color:var(--dsw-alias-label-primary);text-overflow:ellipsis;white-space:nowrap;flex:1;margin:0;font-size:13px;font-weight:700;overflow:hidden}' +
731
+ '.dkb-count{min-width:20px;text-align:center;color:var(--dsw-alias-bg-base);background:var(--dsw-alias-label-tertiary);border-radius:999px;flex:none;padding:1px 8px;font-size:12px;font-weight:600}' +
732
+ '.dkb-count[data-col=in-progress]{background:var(--dsw-alias-state-warn-primary)}' +
733
+ '.dkb-count[data-col=review]{background:var(--dsw-alias-state-business-primary)}' +
734
+ '.dkb-count[data-col=deploy]{background:var(--dsw-alias-state-business-primary)}' +
735
+ '.dkb-count[data-col=done]{background:var(--dsw-alias-state-success-primary)}' +
736
+ '.dkb-countOver{background:var(--dsw-alias-state-error-primary);color:#fff}' +
737
+ '.dkb-list{flex-direction:column;flex:1;gap:8px;min-height:0;padding:2px 8px 10px;display:flex;overflow-y:auto}' +
738
+ '.dkb-empty{text-align:center;color:var(--dsw-alias-label-tertiary);padding:24px 8px;font-size:12px}' +
739
+ '.dkb-taskCard{text-align:left;background:var(--dsw-alias-bg-base);border:1px solid var(--dsw-alias-border-l2);cursor:pointer;color:var(--dsw-alias-label-primary);border-radius:10px;flex-direction:column;gap:6px;padding:10px 12px;font-family:inherit;transition:box-shadow .12s,border-color .12s,transform .12s;display:flex}' +
740
+ '.dkb-taskCard:hover{box-shadow:var(--dsw-shadow-lv2);border-color:var(--dsw-alias-border-l3);transform:translateY(-1px)}' +
741
+ '.dkb-taskTitle{-webkit-line-clamp:2;-webkit-box-orient:vertical;font-size:13px;font-weight:600;line-height:1.35;display:-webkit-box;overflow:hidden}' +
742
+ '.dkb-mdBody{color:var(--dsw-alias-label-primary);font-size:13px;line-height:1.55;display:flex;flex-direction:column;gap:8px;max-height:46vh;overflow-y:auto}' +
743
+ '.dkb-mdPara{margin:0;overflow-wrap:anywhere}' +
744
+ '.dkb-mdHead{color:var(--dsw-alias-label-primary);font-weight:700;margin:6px 0 0;line-height:1.35}' +
745
+ '.dkb-mdHead1{font-size:15px}' +
746
+ '.dkb-mdHead2{font-size:14px}' +
747
+ '.dkb-mdHead3{font-size:13px;color:var(--dsw-alias-label-secondary)}' +
748
+ '.dkb-mdList{margin:0;padding-left:20px;display:flex;flex-direction:column;gap:3px}' +
749
+ '.dkb-mdCode{margin:0;padding:8px 10px;border-radius:8px;background:var(--dsw-alias-bg-layer-3);border:1px solid var(--dsw-alias-border-l2);font-size:12px;overflow-x:auto;white-space:pre}' +
750
+ '.dkb-mdQuote{margin:0;padding-left:10px;border-left:2px solid var(--dsw-alias-border-l3);color:var(--dsw-alias-label-secondary)}' +
751
+ '.dkb-mdRule{border:0;border-top:1px solid var(--dsw-alias-border-l2);margin:2px 0;width:100%}' +
752
+ '.dkb-code{background:var(--dsw-alias-bg-layer-3);border:1px solid var(--dsw-alias-border-l2);border-radius:4px;padding:0 4px;font-size:12px}' +
753
+ '.dkb-issueLink{color:var(--dsw-alias-label-secondary)}' +
754
+ '.dkb-taskHead{align-items:center;gap:6px;flex-wrap:wrap;display:flex}' +
755
+ '.dkb-ref{align-items:baseline;gap:5px;min-width:0;display:inline-flex}' +
756
+ '.dkb-refRepo{max-width:150px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-3);border:1px solid var(--dsw-alias-border-l2);border-radius:6px;padding:1px 7px;font-size:11px;font-weight:600}' +
757
+ '.dkb-refNum{flex:none;color:var(--dsw-alias-label-tertiary);font-size:11px;font-family:var(--dsw-font-family-mono,ui-monospace,SFMono-Regular,Menlo,monospace);letter-spacing:.02em}' +
758
+ '.dkb-refLink{text-decoration:none}' +
759
+ '.dkb-refLink:hover{color:var(--dsw-alias-label-primary);text-decoration:underline}' +
760
+ '.dkb-taskRef{color:var(--dsw-alias-label-tertiary);align-items:center;gap:8px;font-size:11px;display:flex}' +
761
+ '.dkb-planLine{color:var(--dsw-alias-label-secondary);font-size:11px;line-height:1.35;overflow:hidden;-webkit-line-clamp:2;-webkit-box-orient:vertical;display:-webkit-box}' +
762
+ '.dkb-state{border:1px solid var(--dsw-alias-border-l2);border-radius:6px;padding:0 5px;font-size:10px;line-height:16px}' +
763
+ '.dkb-stateLive{border-color:var(--dsw-alias-state-warn-primary);color:var(--dsw-alias-state-warn-primary);font-weight:600}' +
764
+ '.dkb-batchList{list-style:none;margin:0;padding:0;max-height:200px;overflow-y:auto;display:flex;flex-direction:column;gap:4px}' +
765
+ '.dkb-batchRow{align-items:baseline;gap:8px;font-size:13px;color:var(--dsw-alias-label-primary);display:flex}' +
766
+ '.dkb-pick{margin:0;flex:none;opacity:0;transition:opacity .12s}' +
767
+ '.dkb-taskCard:hover .dkb-pick,.dkb-taskCard:focus-within .dkb-pick,.dkb-pickOn{opacity:1}' +
768
+ '.dkb-toChat{appearance:none;font:inherit;cursor:pointer;color:var(--dsw-alias-label-secondary);background:0 0;border:0;padding:0;font-size:11px;text-decoration:underline dotted}' +
769
+ '.dkb-toChat:hover{color:var(--dsw-alias-label-primary)}' +
770
+ '.dkb-planList{display:flex;flex-direction:column;gap:4px;margin:0;padding:0;list-style:none}' +
771
+ '.dkb-planItem{color:var(--dsw-alias-label-secondary);font-size:12px;line-height:1.4;display:flex;gap:8px;align-items:baseline}' +
772
+ '.dkb-planItem.dkb-planDone{color:var(--dsw-alias-label-tertiary);text-decoration:line-through}' +
773
+ '.dkb-planItem.dkb-planActive{color:var(--dsw-alias-label-primary);font-weight:600;text-decoration:none}' +
774
+ '.dkb-planMark{flex:none;width:14px;text-align:center}' +
775
+ '.dkb-tags{display:flex;flex-wrap:wrap;gap:4px}' +
776
+ // Ожидание — не колонка, а состояние карточки: агент стоит и ждёт человека.
777
+ '.dkb-waiting{color:var(--dsw-alias-state-warn-primary);font-weight:600}' +
778
+ '.dkb-waitDot{border-radius:50%;flex:none;width:8px;height:8px;background:var(--dsw-alias-state-warn-primary);display:inline-block}' +
779
+ '.dkb-tag{white-space:nowrap;color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-interactive-bg-hover);border:1px solid transparent;border-radius:999px;padding:1px 7px;font-size:11px;line-height:1.5;font-weight:500}' +
780
+ // Снизу оставлен запас: поверх интерфейса висят плавающие виджеты соседних
781
+ // плагинов (например счётчик расходов), и без запаса последняя кнопка
782
+ // оказывается под ними — нажать нельзя, а выглядит как «ничего не работает».
783
+ '.dkb-modal{position:absolute;inset:0;background:rgba(0,0,0,.4);display:flex;align-items:center;justify-content:center;padding:24px 24px 84px;box-sizing:border-box;z-index:70}' +
784
+ '.dkb-panel{width:min(720px,100%);max-height:100%;background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l2);border-radius:12px;padding:16px;box-sizing:border-box;overflow-y:auto;display:flex;flex-direction:column;gap:12px}' +
785
+ '.dkb-panelBody{white-space:pre-wrap;color:var(--dsw-alias-label-secondary);font-size:13px;line-height:1.5;margin:0}' +
786
+ '.dkb-log{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:4px}' +
787
+ '.dkb-logRow{color:var(--dsw-alias-label-secondary);font-size:12px}' +
788
+ '.dkb-dialog{position:absolute;inset:0;background:rgba(0,0,0,.4);display:flex;align-items:center;justify-content:center;z-index:80}' +
789
+ '.dkb-dialogBox{width:min(560px,94%);max-height:86%;overflow-y:auto;background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l2);border-radius:12px;padding:16px;display:flex;flex-direction:column;gap:10px}' +
790
+ '.dkb-issue{appearance:none;font:inherit;text-align:left;cursor:pointer;background:0 0;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:8px 10px;color:var(--dsw-alias-label-primary);font-size:13px}' +
791
+ '.dkb-issueUsed{opacity:.5}' +
792
+ '.dkb-row{display:flex;gap:8px;align-items:center}' +
793
+ '.dkb-search{min-width:120px;color:var(--dsw-alias-label-primary);background:var(--dsw-specific-input-major);border:1px solid var(--dsw-alias-border-l2);border-radius:8px;outline:none;flex:0 240px;padding:6px 10px;font-size:13px}' +
794
+ '.dkb-search::placeholder{color:var(--dsw-alias-label-tertiary)}' +
795
+ // Указатель места вставки: без него карточку отпускают вслепую.
796
+ '.dkb-slot{height:2px;border-radius:2px;background:transparent;flex:none}' +
797
+ '.dkb-slotOn{background:var(--dsw-alias-state-business-primary)}' +
798
+ '.dkb-danger{appearance:none;font:inherit;cursor:pointer;border:1px solid var(--dsw-alias-state-error-primary);border-radius:8px;padding:5px 14px;font-size:13px;color:var(--dsw-alias-state-error-primary);background:transparent}' +
799
+ '.dkb-danger:hover{background:var(--dsw-alias-state-error-primary);color:var(--dsw-alias-bg-base)}'
800
+
801
+
802
+ // ---------------------------------------------------------------- строки
803
+
804
+ // Правило регистра, чтобы подписи не расползались:
805
+ //
806
+ // подпись УПРАВЛЕНИЯ — с прописной: «Архив», «Поиск», «Обновить». Это
807
+ // имя кнопки, а не продолжение фразы;
808
+ // встроенный текст и состояние — со строчной: «ждёт ответа», «здесь
809
+ // 3 дн», «показано 12 из 81». Это часть предложения, а не ярлык.
810
+ //
811
+ // Смешение того и другого в одном ряду и выглядит халтурой.
812
+ const en = {
813
+ 'title': 'Kanban',
814
+ 'subtitle': 'Task board: Gitea issues and a session per task',
815
+ 'section.label': 'Kanban',
816
+ 'group.gitea': 'Gitea',
817
+ 'group.general': 'General',
818
+ 'field.giteaUrl': 'Instance URL',
819
+ 'field.giteaTokenRef': 'Credential name',
820
+ 'hint.giteaUrl': 'For example https://gitea.example.com. Empty means importing is unavailable.',
821
+ 'hint.giteaTokenRef': 'The NAME of a DSH credential holding the token. Never type the token itself here.',
822
+ 'group.limits': 'Column limits',
823
+ 'group.sync': 'Automatic moves',
824
+ 'field.syncIntervalSec': 'Check Gitea every, seconds',
825
+ 'field.staleAfterMin': 'Call a task silent after, minutes',
826
+ 'field.giteaOwner': 'Gitea organisation',
827
+ 'hint.giteaOwner': 'Whose repositories the board watches. Empty is fine while the token belongs to exactly one organisation.',
828
+ 'field.columnNames': 'Your own column names',
829
+ 'hint.columnNames': 'Pairs «id=Name», comma separated, for example «backlog=Ideas, review=Check». Empty keeps the usual names.',
830
+ 'field.watchRepos': 'Narrow the pull to these repositories',
831
+ 'hint.watchRepos': 'Comma separated, «owner/repo» or just «repo». Empty means every repository of the organisation that has open issues.',
832
+ 'field.archiveAfterDays': 'Archive a done card after, days',
833
+ 'hint.archiveAfterDays': 'Counted from the moment the card entered Done. Zero keeps everything on the board.',
834
+ 'field.boardToolEnabled': 'Let the agent move cards',
835
+ 'field.pushToGitea': 'Close the issue in Gitea',
836
+ 'field.webhookSecretRef': 'Webhook credential name',
837
+ 'hint.pushToGitea': 'A card reaching Done closes its issue. The board sets no labels: the stage is already visible from the branch and the pull request.',
838
+ 'hint.webhookSecretRef': 'NAME of a DSH credential holding the Gitea webhook secret. Empty leaves only polling.',
839
+ 'hint.syncIntervalSec': 'Zero turns the check off. Below fifteen seconds is raised to fifteen.',
840
+ 'hint.staleAfterMin': 'Only tasks that have a session are marked. Zero turns the mark off.',
841
+ 'hint.boardToolEnabled': 'Off until the workflow skill stops forbidding this CLI to touch a kanban.',
842
+ 'field.defaultProjectRoot': 'Project root',
843
+ 'field.startPrompt': 'First message template',
844
+ 'field.replyInstruction': 'Standing note',
845
+ 'field.wipInProgress': 'In progress limit',
846
+ 'field.wipReview': 'Review limit',
847
+ 'hint.defaultProjectRoot': 'Where project working copies live. Empty means the harness working directory.',
848
+ 'hint.startPrompt': 'Template for the first message to the agent. Empty means the built-in template.',
849
+ 'hint.replyInstruction': 'Appended to every first message — the answer language, for instance. Empty adds nothing.',
850
+ 'hint.label': 'Empty means no label is set for this column.',
851
+ 'hint.limit': 'Zero means no limit. Going over is highlighted, never blocked.',
852
+ 'settings.loading': 'Loading settings…',
853
+ 'settings.unavailable': 'The host does not know the dsh-kanban settings namespace. Restart the Web UI after installing the plugin.',
854
+ 'settings.readOnly': 'Settings are read-only in this session.',
855
+ 'settings.saveFailed': 'Failed to save: {fields}',
856
+ 'settings.saved': 'Saved.',
857
+ 'save': 'Save',
858
+ 'discard': 'Discard',
859
+ 'column.backlog': 'Backlog',
860
+ 'column.in-progress': 'In progress',
861
+ 'column.review': 'Review',
862
+ 'column.deploy': 'Deploy',
863
+ 'column.cleanup': 'Cleanup',
864
+ 'column.done': 'Done',
865
+ 'board.loading': 'Loading the board…',
866
+ 'board.empty': 'No tasks yet.',
867
+ 'board.waiting': 'needs you',
868
+ 'board.sync': 'Check Gitea',
869
+ 'board.syncing': 'Checking…',
870
+ 'board.noMatch': 'Nothing matches the search.',
871
+ 'board.search': 'Search',
872
+ 'board.limit': 'limit {n}',
873
+ 'board.newTask': 'Task',
874
+ 'board.refresh': 'Refresh',
875
+ 'board.allRepos': 'All repositories',
876
+ 'board.repo': 'repository',
877
+ 'dialog.title': 'New task',
878
+ 'dialog.own': 'Own task',
879
+ 'dialog.fromGitea': 'From Gitea',
880
+ 'dialog.repo': 'Repository',
881
+ 'dialog.pickRepo': 'Pick a repository',
882
+ 'dialog.loadingRepos': 'Loading repositories…',
883
+ 'dialog.noRepos': 'No repositories available',
884
+ 'dialog.taskTitle': 'Title',
885
+ 'dialog.taskBody': 'Description',
886
+ 'dialog.taskLabels': 'Labels',
887
+ 'dialog.taskColumn': 'Column',
888
+ 'dialog.bodyHint': 'Goes to the agent as the first message when the task starts.',
889
+ 'dialog.labelsHint': 'comma separated',
890
+ 'dialog.archived': 'archived',
891
+ 'dialog.create': 'Create',
892
+ 'dialog.cancel': 'Cancel',
893
+ 'move.title': 'Move to “{column}”?',
894
+ 'board.expand': 'Expand the column',
895
+ 'card.move': 'move',
896
+ 'card.toChat': 'to the chat',
897
+ 'card.pick': 'Select for a group start',
898
+ 'card.queue': 'to a queue...',
899
+ 'card.unqueue': 'take out of the queue',
900
+ 'queue.title': 'Add to a running session',
901
+ 'queue.hint': 'The task joins the session queue: the agent takes it when it finishes the current work. Until then the card stays where it is.',
902
+ 'queue.session': 'Session',
903
+ 'queue.option': '{title} - tasks: {n}',
904
+ 'queue.add': 'To the queue',
905
+ 'error.no-live-sessions': 'No running session to queue into.',
906
+ 'error.session-not-live': 'That session is no longer running.',
907
+ 'error.task-not-queued': 'This task is not in a queue.',
908
+ 'batch.title': 'Start {n} tasks in one chat',
909
+ 'batch.hint': 'One session for all of them. Tasks arrive one at a time: the agent takes the next when it finishes the current one. Only the first moves to In progress — the rest wait their turn where they are.',
910
+ 'batch.start': 'Start',
911
+ 'state.queued': 'queued · {n}',
912
+ 'board.pickedCount': 'selected {n}',
913
+ 'board.pickNone': 'Clear selection',
914
+ 'board.pickStart': 'Start selected',
915
+ 'dialog.kind': 'What kind of task',
916
+ 'dialog.kindProject': 'Project',
917
+ 'dialog.kindPlain': 'Not a project',
918
+ 'dialog.kindProjectHint': 'An issue is created in Gitea and the card is bound to it. The card lives on the project board.',
919
+ 'dialog.kindPlainHint': 'A note that touches no repository. It lives on the simple board and never reaches Gitea.',
920
+ 'dialog.repoNew': '— a new repository —',
921
+ 'dialog.repoNamePlaceholder': 'name of the new repository',
922
+ 'dialog.repoNewHint': 'It will be created in the organisation: private and empty, no README and no scaffolding. Creating a repository cannot be undone from here.',
923
+ 'error.repo-required': 'Choose a repository or name a new one.',
924
+ 'error.bad-repo-name': 'A repository name may contain only Latin letters, digits, dot, dash and underscore.',
925
+ 'error.repo-not-created': 'The repository was not created; nothing else was done.',
926
+ 'error.issue-not-created': 'The issue was not created. If the repository was new, it stayed — nothing was deleted.',
927
+ 'board.archive': 'Archive',
928
+ 'panel.archive': 'To the archive',
929
+ 'archive.hint': 'Cards that spent their term in Done. Nothing is deleted: a restored card returns to the column it left.',
930
+ 'archive.empty': 'The archive is empty.',
931
+ 'archive.since': 'archived {ago} ago',
932
+ 'archive.restore': 'Back to the board',
933
+ 'sync.running': 'checking with Gitea…',
934
+ 'sync.never': 'not checked with Gitea yet',
935
+ 'sync.okAgo': 'checked {ago} ago',
936
+ 'sync.failed': 'the check is failing',
937
+ 'sync.lastSeen': 'last successful check {ago} ago',
938
+ 'sync.neverSeen': 'no successful check since the harness started',
939
+ 'facet.repo': 'Repository',
940
+ 'facet.author': 'Author',
941
+ 'board.pickArchive': 'To the archive',
942
+ 'board.pickUnqueue': 'Out of the queue',
943
+ 'board.pickMove': 'Move to…',
944
+ 'board.someFailed': 'Done for {done} of {total}; the rest refused.',
945
+ 'card.stop': 'stop',
946
+ 'card.pack': 'batch {at} of {total}',
947
+ 'card.packHint': 'Started together, one session; the agent takes them in this order.',
948
+ 'stop.idle': 'The agent was not running — there was nothing to stop.',
949
+ 'stop.no-session': 'This task has no session.',
950
+ 'board.waitCount': 'Waiting for you: {n}',
951
+ 'board.waitCountHint': 'Tasks where the agent asked a question or wants permission.',
952
+ 'order.manual': 'As laid out',
953
+ 'order.old': 'Oldest first',
954
+ 'order.new': 'Newest first',
955
+ 'facet.type': 'Type',
956
+ 'facet.priority': 'Priority',
957
+ 'facet.status': 'Status',
958
+ 'facet.scope': 'Scope',
959
+ 'facet.risk': 'Risk',
960
+ 'facet.signal': 'Signal',
961
+ 'facet.release': 'Release',
962
+ 'boardName.main': 'Project board',
963
+ 'boardName.simple': 'Simple board',
964
+ 'board.clearFilters': 'Clear filters',
965
+ 'board.shown': 'showing {shown} of {total}',
966
+ 'board.noRepo': 'no project',
967
+ 'board.back': 'To the chat',
968
+ 'board.backHint': 'Close the board and return to the conversation',
969
+ 'panel.openChat': 'Open the chat',
970
+ 'panel.bodyLoading': 'reading the description…',
971
+ 'panel.author': 'by {who}',
972
+ 'panel.created': 'created {ago} ago',
973
+ 'panel.updated': 'last change {ago} ago',
974
+ 'time.now': 'just now',
975
+ 'time.min': '{n} min',
976
+ 'time.hour': '{n} h',
977
+ 'time.day': '{n} d',
978
+ 'board.collapse': 'Collapse the column',
979
+ 'plan.title': 'Agent plan',
980
+ 'plan.counter': '{done} of {total}',
981
+ 'state.running': 'working',
982
+ 'state.stopped': 'stopped',
983
+ 'move.confirm': 'Move',
984
+ 'move.backlog': 'Work on the task stops: the agent’s running turn is aborted. With no session, the card just moves.',
985
+ 'move.in-progress': 'The agent is told to start or continue the implementation.',
986
+ 'move.review': 'The agent takes the work to review: drops the draft mark from the pull request and asks for a check.',
987
+ 'move.deploy': 'The agent merges the pull request and deploys. This move IS your explicit go-ahead for the deploy — the agent will not ask again.',
988
+ 'move.cleanup': 'The agent deletes the Gitea branch, the worktree and the local branch, and records that in the issue.',
989
+ 'move.done': 'The task counts as finished. This is a human decision: the agent never moves a card here itself.',
990
+ 'dialog.imported': 'already on the board',
991
+ 'dialog.noIssues': 'No open issues found.',
992
+ 'panel.close': 'Close',
993
+ 'panel.issue': 'Issue',
994
+ 'panel.log': 'Transitions',
995
+ 'panel.noLog': 'No transitions yet.',
996
+ 'panel.refresh': 'Refresh from Gitea',
997
+ 'panel.delete': 'Delete',
998
+ 'panel.deleteConfirm': 'Delete "{title}"? This cannot be undone.',
999
+ 'panel.start': 'Start',
1000
+ 'panel.agentPreset': 'Agent profile',
1001
+ 'panel.presetDefault': 'Harness default',
1002
+ 'panel.permission': 'Access level',
1003
+ 'error.permission-unavailable': 'This deployment offers no access levels — the task was not started, so it could not run with wider rights than asked.',
1004
+ 'error.permission-refused': 'The access level was refused; the task was not started.',
1005
+ 'panel.provider': 'Provider',
1006
+ 'panel.model': 'Model',
1007
+ 'panel.pickProvider': 'Pick a provider',
1008
+ 'panel.pickProviderFirst': 'Pick a provider first',
1009
+ 'panel.pickModel': 'Pick a model',
1010
+ 'panel.noProviders': 'No providers available',
1011
+ 'panel.noModels': 'This provider offers no models',
1012
+ 'panel.starting': 'Starting…',
1013
+ 'panel.continue': 'Continue',
1014
+ 'panel.continueHint': 'The task keeps its own chat: the existing session is opened, not a new one.',
1015
+ 'panel.message': 'Message to the agent',
1016
+ 'panel.messageHint': 'Edit before sending. Empty falls back to the built-in text.',
1017
+ 'panel.startHint': 'A dedicated agent session opens for this task. Branch and worktree are created by the agent after its preflight, not here.',
1018
+ 'panel.workDir': 'Working folder: {path}',
1019
+ 'panel.workDirHint': 'The project root is not set in settings, so this folder is used.',
1020
+ 'error.startFailed': 'Could not start the session.',
1021
+ 'error.sessionNotOpened': 'The session started ({id}) but this build cannot switch to it. Find it in the session list.',
1022
+ 'error.modelNotSelected': 'No model selected — pick one in Settings, or type it here.',
1023
+ 'chip.moveTo': 'Move to',
1024
+ 'chip.openCard': 'Open the card',
1025
+ 'chip.note': 'Leave a note',
1026
+ 'chip.noteHint': 'The note is appended to the task body. The body goes to the agent with the first message, so a note left before the start reaches the work and one left after does not.',
1027
+ 'chip.noteSave': 'Append',
1028
+ 'error.giteaAbsent': 'The Gitea plugin is not installed, so importing is unavailable.',
1029
+ 'error.giteaUnconfigured': 'Configure Gitea in the Gitea plugin card first.',
1030
+ 'error.taskNotFound': 'Task not found.',
1031
+ 'error.taskHasNoIssue': 'This task has no Gitea issue behind it.',
1032
+ 'error.issueNotFound': 'Issue not found.',
1033
+ 'error.titleRequired': 'A title is required.',
1034
+ 'error.crossSite': 'Request rejected as cross-site.',
1035
+ 'error.unknown': 'Request failed.',
1036
+ }
1037
+
1038
+ // Китайский — вторая встроенная локаль ядра. Русского здесь нет:
1039
+ // он приезжает языковым пакетом `dsh-russian-lang`, который
1040
+ // регистрирует `ru` для этого пространства извне.
1041
+ const zh = {
1042
+ 'title': '看板',
1043
+ 'subtitle': '任务看板:Gitea issue,每个任务一个会话',
1044
+ 'section.label': '看板',
1045
+ 'group.gitea': 'Gitea',
1046
+ 'group.general': '通用',
1047
+ 'field.giteaUrl': '实例地址',
1048
+ 'field.giteaTokenRef': '凭据名称',
1049
+ 'hint.giteaUrl': '例如 https://gitea.example.com。留空则无法导入。',
1050
+ 'hint.giteaTokenRef': '保存令牌的 DSH 凭据的名称。切勿在此直接填写令牌本身。',
1051
+ 'group.limits': '列的上限',
1052
+ 'group.sync': '自动移动',
1053
+ 'field.syncIntervalSec': '核对 Gitea 的间隔(秒)',
1054
+ 'field.staleAfterMin': '多久无动静视为沉默(分钟)',
1055
+ 'field.giteaOwner': 'Gitea 组织',
1056
+ 'hint.giteaOwner': '看板监视谁的仓库。若令牌只属于一个组织,可以留空。',
1057
+ 'field.columnNames': '自定义列名',
1058
+ 'hint.columnNames': '「id=名称」成对,用逗号分隔,例如「backlog=想法, review=检查」。留空则沿用默认名称。',
1059
+ 'field.watchRepos': '只收取这些仓库',
1060
+ 'hint.watchRepos': '逗号分隔,写「owner/repo」或只写「repo」。留空表示组织下所有有开放 issue 的仓库。',
1061
+ 'field.archiveAfterDays': '完成多少天后归档',
1062
+ 'hint.archiveAfterDays': '从卡片进入「已完成」时算起。填 0 表示都留在看板上。',
1063
+ 'field.boardToolEnabled': '允许 Agent 移动卡片',
1064
+ 'field.pushToGitea': '在 Gitea 中关闭 issue',
1065
+ 'field.webhookSecretRef': 'Webhook 凭据名称',
1066
+ 'hint.pushToGitea': '卡片进入「已完成」即关闭其 issue。看板不设置任何标签:阶段从分支和 pull request 上已经看得出来。',
1067
+ 'hint.webhookSecretRef': '保存 Gitea webhook 密钥的 DSH 凭据的名称。留空则只剩轮询。',
1068
+ 'hint.syncIntervalSec': '填 0 关闭核对。小于十五秒会被提到十五秒。',
1069
+ 'hint.staleAfterMin': '只标记有会话的任务。填 0 关闭该标记。',
1070
+ 'hint.boardToolEnabled': '在工作流技能仍禁止此 CLI 触碰看板之前保持关闭。',
1071
+ 'field.defaultProjectRoot': '项目根目录',
1072
+ 'field.startPrompt': '首条消息模板',
1073
+ 'field.replyInstruction': '固定附言',
1074
+ 'field.wipInProgress': '「进行中」上限',
1075
+ 'field.wipReview': '「评审」上限',
1076
+ 'hint.defaultProjectRoot': '项目工作副本所在之处。留空表示 harness 的工作目录。',
1077
+ 'hint.startPrompt': '发给 Agent 的首条消息模板。留空使用内置模板。',
1078
+ 'hint.replyInstruction': '追加到每条首消息之后——例如回答所用的语言。留空则不追加。',
1079
+ 'hint.label': '留空表示这一列不设置标签。',
1080
+ 'hint.limit': '填 0 表示不限。超出只会高亮,绝不阻拦。',
1081
+ 'settings.loading': '正在加载设置…',
1082
+ 'settings.unavailable': '宿主不认识 dsh-kanban 设置命名空间。安装插件后请重启 Web UI。',
1083
+ 'settings.readOnly': '本会话中设置为只读。',
1084
+ 'settings.saveFailed': '保存失败:{fields}',
1085
+ 'settings.saved': '已保存。',
1086
+ 'save': '保存',
1087
+ 'discard': '放弃',
1088
+ 'column.backlog': '待办',
1089
+ 'column.in-progress': '进行中',
1090
+ 'column.review': '评审',
1091
+ 'column.deploy': '发布',
1092
+ 'column.cleanup': '清理',
1093
+ 'column.done': '已完成',
1094
+ 'board.loading': '正在加载看板…',
1095
+ 'board.empty': '还没有任务。',
1096
+ 'board.waiting': '需要你',
1097
+ 'board.sync': '核对 Gitea',
1098
+ 'board.syncing': '核对中…',
1099
+ 'board.noMatch': '没有符合搜索的内容。',
1100
+ 'board.search': '搜索',
1101
+ 'board.limit': '上限 {n}',
1102
+ 'board.newTask': '任务',
1103
+ 'board.refresh': '刷新',
1104
+ 'board.allRepos': '全部仓库',
1105
+ 'board.repo': '仓库',
1106
+ 'dialog.title': '新任务',
1107
+ 'dialog.own': '自建任务',
1108
+ 'dialog.fromGitea': '来自 Gitea',
1109
+ 'dialog.repo': '仓库',
1110
+ 'dialog.pickRepo': '选择仓库',
1111
+ 'dialog.loadingRepos': '正在加载仓库…',
1112
+ 'dialog.noRepos': '没有可用的仓库',
1113
+ 'dialog.taskTitle': '标题',
1114
+ 'dialog.taskBody': '描述',
1115
+ 'dialog.taskLabels': '标签',
1116
+ 'dialog.taskColumn': '列',
1117
+ 'dialog.bodyHint': '任务启动时作为首条消息发给 Agent。',
1118
+ 'dialog.labelsHint': '用逗号分隔',
1119
+ 'dialog.archived': '已归档',
1120
+ 'dialog.create': '创建',
1121
+ 'dialog.cancel': '取消',
1122
+ 'move.title': '移动到「{column}」?',
1123
+ 'board.expand': '展开该列',
1124
+ 'card.move': '移动',
1125
+ 'card.toChat': '进入对话',
1126
+ 'card.pick': '选中以便批量启动',
1127
+ 'card.queue': '加入队列…',
1128
+ 'card.unqueue': '移出队列',
1129
+ 'queue.title': '加入正在运行的会话',
1130
+ 'queue.hint': '任务进入该会话的队列:Agent 做完手头的工作后就会接手。在那之前卡片留在原处。',
1131
+ 'queue.session': '会话',
1132
+ 'queue.option': '{title} - 任务数:{n}',
1133
+ 'queue.add': '加入队列',
1134
+ 'error.no-live-sessions': '没有正在运行的会话可供排队。',
1135
+ 'error.session-not-live': '该会话已不再运行。',
1136
+ 'error.task-not-queued': '该任务并不在队列中。',
1137
+ 'batch.title': '在一个对话里启动 {n} 个任务',
1138
+ 'batch.hint': '所有任务共用一个会话。任务逐个送达:Agent 做完当前的才会接下一个。只有第一个进入「进行中」,其余在原处等候。',
1139
+ 'batch.start': '启动',
1140
+ 'state.queued': '排队中 · {n}',
1141
+ 'board.pickedCount': '已选 {n}',
1142
+ 'board.pickNone': '取消选择',
1143
+ 'board.pickStart': '启动所选',
1144
+ 'dialog.kind': '任务类型',
1145
+ 'dialog.kindProject': '项目任务',
1146
+ 'dialog.kindPlain': '非项目',
1147
+ 'dialog.kindProjectHint': '会在 Gitea 中创建 issue 并与卡片绑定。卡片位于项目看板。',
1148
+ 'dialog.kindPlainHint': '不涉及任何仓库的便签。它位于简易看板,永远不会到达 Gitea。',
1149
+ 'dialog.repoNew': '— 新建仓库 —',
1150
+ 'dialog.repoNamePlaceholder': '新仓库的名称',
1151
+ 'dialog.repoNewHint': '将在该组织中创建:私有且为空,没有 README,也没有任何脚手架。创建仓库无法从这里撤销。',
1152
+ 'error.repo-required': '请选择仓库,或为新仓库起个名字。',
1153
+ 'error.bad-repo-name': '仓库名称只能包含拉丁字母、数字、点、连字符和下划线。',
1154
+ 'error.repo-not-created': '仓库没有创建,其他动作也未执行。',
1155
+ 'error.issue-not-created': 'issue 没有创建。如果仓库是新建的,它仍然保留——没有删除任何东西。',
1156
+ 'board.archive': '归档',
1157
+ 'panel.archive': '移入归档',
1158
+ 'archive.hint': '在「已完成」里待够时间的卡片。什么都没有删除:恢复的卡片会回到它离开的那一列。',
1159
+ 'archive.empty': '归档是空的。',
1160
+ 'archive.since': '已归档 {ago}',
1161
+ 'archive.restore': '放回看板',
1162
+ 'sync.running': '正在与 Gitea 核对…',
1163
+ 'sync.never': '还没有与 Gitea 核对过',
1164
+ 'sync.okAgo': '{ago}前核对过',
1165
+ 'sync.failed': '核对一直失败',
1166
+ 'sync.lastSeen': '最后一次成功核对在 {ago}前',
1167
+ 'sync.neverSeen': '自 harness 启动以来没有一次成功的核对',
1168
+ 'facet.repo': '仓库',
1169
+ 'facet.author': '创建者',
1170
+ 'board.pickArchive': '移入归档',
1171
+ 'board.pickUnqueue': '移出队列',
1172
+ 'board.pickMove': '移动到…',
1173
+ 'board.someFailed': '{total} 个中完成了 {done} 个,其余被拒绝。',
1174
+ 'card.stop': '停止',
1175
+ 'card.pack': '批次 {at}/{total}',
1176
+ 'card.packHint': '一起启动,同一个会话;Agent 按这个顺序接手。',
1177
+ 'stop.idle': 'Agent 本来就没有在跑,没有什么可停。',
1178
+ 'stop.no-session': '该任务没有会话。',
1179
+ 'board.waitCount': '等你回应:{n}',
1180
+ 'board.waitCountHint': 'Agent 提了问题或在请求许可的任务。',
1181
+ 'order.manual': '按摆放顺序',
1182
+ 'order.old': '旧的在前',
1183
+ 'order.new': '新的在前',
1184
+ 'facet.type': '类型',
1185
+ 'facet.priority': '优先级',
1186
+ 'facet.status': '状态',
1187
+ 'facet.scope': '范围',
1188
+ 'facet.risk': '风险',
1189
+ 'facet.signal': '信号',
1190
+ 'facet.release': '发布',
1191
+ 'boardName.main': '项目看板',
1192
+ 'boardName.simple': '简易看板',
1193
+ 'board.clearFilters': '清除筛选',
1194
+ 'board.shown': '显示 {shown} / {total}',
1195
+ 'board.noRepo': '无项目',
1196
+ 'board.back': '回到对话',
1197
+ 'board.backHint': '关闭看板并回到对话',
1198
+ 'panel.openChat': '打开对话',
1199
+ 'panel.bodyLoading': '正在读取描述…',
1200
+ 'panel.author': '由 {who} 创建',
1201
+ 'panel.created': '{ago}前创建',
1202
+ 'panel.updated': '最后改动在 {ago}前',
1203
+ 'time.now': '刚刚',
1204
+ 'time.min': '{n} 分钟',
1205
+ 'time.hour': '{n} 小时',
1206
+ 'time.day': '{n} 天',
1207
+ 'board.collapse': '收起该列',
1208
+ 'plan.title': 'Agent 的计划',
1209
+ 'plan.counter': '{done}/{total}',
1210
+ 'state.running': '工作中',
1211
+ 'state.stopped': '已停下',
1212
+ 'move.confirm': '移动',
1213
+ 'move.backlog': '任务上的工作会停止:Agent 正在进行的一轮被中断。没有会话时,卡片只是挪个位置。',
1214
+ 'move.in-progress': '通知 Agent 开始或继续实现。',
1215
+ 'move.review': 'Agent 把工作送去评审:取消 pull request 的草稿标记并请求检查。',
1216
+ 'move.deploy': 'Agent 合并 pull request 并发布。这一移动本身就是你对发布的明确许可——Agent 不会再问一次。',
1217
+ 'move.cleanup': 'Agent 删除 Gitea 上的分支、worktree 和本地分支,并把做过的事记录到 issue 里。',
1218
+ 'move.done': '任务算作完成。这是人的决定:Agent 从不自己把卡片移到这里。',
1219
+ 'dialog.imported': '已经在看板上',
1220
+ 'dialog.noIssues': '没有找到开放的 issue。',
1221
+ 'panel.close': '关闭',
1222
+ 'panel.issue': 'issue',
1223
+ 'panel.log': '流转记录',
1224
+ 'panel.noLog': '还没有流转记录。',
1225
+ 'panel.refresh': '从 Gitea 刷新',
1226
+ 'panel.delete': '删除',
1227
+ 'panel.deleteConfirm': '删除「{title}」?此操作无法撤销。',
1228
+ 'panel.start': '启动',
1229
+ 'panel.agentPreset': 'Agent 预设',
1230
+ 'panel.presetDefault': '按 harness 的默认',
1231
+ 'panel.permission': '权限级别',
1232
+ 'error.permission-unavailable': '这套部署没有提供权限级别——任务没有启动,以免它以超出所请求的权限运行。',
1233
+ 'error.permission-refused': '权限级别被拒绝,任务没有启动。',
1234
+ 'panel.provider': '供应商',
1235
+ 'panel.model': '模型',
1236
+ 'panel.pickProvider': '选择供应商',
1237
+ 'panel.pickProviderFirst': '请先选择供应商',
1238
+ 'panel.pickModel': '选择模型',
1239
+ 'panel.noProviders': '没有可用的供应商',
1240
+ 'panel.noModels': '该供应商没有可用模型',
1241
+ 'panel.starting': '正在启动…',
1242
+ 'panel.continue': '继续',
1243
+ 'panel.continueHint': '任务保有自己的对话:打开的是已有会话,而不是新的。',
1244
+ 'panel.message': '发给 Agent 的消息',
1245
+ 'panel.messageHint': '发送前可以修改。留空则使用内置文本。',
1246
+ 'panel.startHint': '会为该任务打开一个专属的 Agent 会话。分支和 worktree 由 Agent 在自检之后创建,不在这里。',
1247
+ 'panel.workDir': '工作目录:{path}',
1248
+ 'panel.workDirHint': '设置中未指定项目根目录,因此使用此目录。',
1249
+ 'error.startFailed': '会话没能启动。',
1250
+ 'error.sessionNotOpened': '会话已启动({id}),但此版本无法切换过去。请到会话列表中查找。',
1251
+ 'error.modelNotSelected': '没有选择模型——请在设置中选择,或在此处填写。',
1252
+ 'chip.moveTo': '移动到',
1253
+ 'chip.openCard': '打开卡片',
1254
+ 'chip.note': '留个便签',
1255
+ 'chip.noteHint': '便签会追加到任务正文。正文随首条消息发给 Agent,所以启动前留的便签能到达工作,启动后留的则不会。',
1256
+ 'chip.noteSave': '追加',
1257
+ 'error.giteaAbsent': '没有安装 Gitea 插件,因此无法导入。',
1258
+ 'error.giteaUnconfigured': '请先在 Gitea 插件的设置卡片中完成配置。',
1259
+ 'error.taskNotFound': '没有找到任务。',
1260
+ 'error.taskHasNoIssue': '该任务背后没有 Gitea issue。',
1261
+ 'error.issueNotFound': '没有找到 issue。',
1262
+ 'error.titleRequired': '标题不能为空。',
1263
+ 'error.crossSite': '请求被判定为跨站而拒绝。',
1264
+ 'error.unknown': '请求失败。',
1265
+ }
1266
+
1267
+ function fallbackT(key, vars) {
1268
+ // Запасной перевод — английский: русский приезжает языковым пакетом,
1269
+ // а до службы локализации доска обязана говорить хоть на чём-то.
1270
+ let text = en[key] !== undefined ? en[key] : key
1271
+ if (vars) {
1272
+ for (const name of Object.keys(vars)) {
1273
+ text = text.split('{' + name + '}').join(String(vars[name]))
1274
+ }
1275
+ }
1276
+ return text
1277
+ }
1278
+
1279
+ // ------------------------------------------------------- карточка настроек
1280
+
1281
+ const FIELDS = [
1282
+ { field: 'giteaUrl', kind: 'text', group: 'gitea' },
1283
+ { field: 'giteaTokenRef', kind: 'text', group: 'gitea' },
1284
+ { field: 'defaultProjectRoot', kind: 'text', group: 'general' },
1285
+ { field: 'startPrompt', kind: 'area', group: 'general' },
1286
+ { field: 'replyInstruction', kind: 'text', group: 'general' },
1287
+ { field: 'wipInProgress', kind: 'number', group: 'limits' },
1288
+ { field: 'wipReview', kind: 'number', group: 'limits' },
1289
+ { field: 'giteaOwner', kind: 'text', group: 'gitea' },
1290
+ { field: 'syncIntervalSec', kind: 'number', group: 'sync' },
1291
+ { field: 'staleAfterMin', kind: 'number', group: 'sync' },
1292
+ { field: 'columnNames', kind: 'text', group: 'general' },
1293
+ { field: 'watchRepos', kind: 'text', group: 'sync' },
1294
+ { field: 'archiveAfterDays', kind: 'number', group: 'sync' },
1295
+ { field: 'boardToolEnabled', kind: 'bool', group: 'sync' },
1296
+ { field: 'pushToGitea', kind: 'bool', group: 'sync' },
1297
+ { field: 'webhookSecretRef', kind: 'text', group: 'sync' },
1298
+ ]
1299
+
1300
+ function hintKey(spec) {
1301
+ if (spec.group === 'sync') return 'hint.' + spec.field
1302
+ if (spec.group === 'gitea') return 'hint.' + spec.field
1303
+ if (spec.group === 'labels') return 'hint.label'
1304
+ if (spec.group === 'limits') return 'hint.limit'
1305
+ return 'hint.' + spec.field
1306
+ }
1307
+
1308
+ function Chevron(props) {
1309
+ return React.createElement('svg', {
1310
+ className: 'dkb-chevron' + (props.open ? ' dkb-chevronOpen' : ''),
1311
+ width: 16, height: 16, viewBox: '0 0 16 16', 'aria-hidden': 'true',
1312
+ }, React.createElement('path', {
1313
+ d: 'M4 6l4 4 4-4', fill: 'none', stroke: 'currentColor', strokeWidth: '1.5',
1314
+ strokeLinecap: 'round', strokeLinejoin: 'round',
1315
+ }))
1316
+ }
1317
+
1318
+ function KanbanSettingsCard(props) {
1319
+ // Все хуки объявлены выше любого возврата: ранний возврат над хуком даёт
1320
+ // React error 310 при первом же изменении состояния.
1321
+ const ctx = props.ctx
1322
+ const t = props.t || fallbackT
1323
+ const [open, setOpen] = React.useState(false)
1324
+ const [draft, setDraft] = React.useState(null)
1325
+ const [saving, setSaving] = React.useState(false)
1326
+ const [failed, setFailed] = React.useState('')
1327
+ const [saved, setSaved] = React.useState(false)
1328
+
1329
+ const scope = React.useMemo(
1330
+ () => (ctx && ctx.settingsScope ? ctx.settingsScope.bind({ namespace: NS }) : undefined),
1331
+ [ctx],
1332
+ )
1333
+ const snapshot = React.useSyncExternalStore(
1334
+ React.useMemo(() => (cb) => (scope ? scope.subscribe(cb) : () => {}), [scope]),
1335
+ React.useCallback(() => (scope ? scope.getSnapshot() : { status: 'loading' }), [scope]),
1336
+ React.useCallback(() => ({ status: 'loading' }), []),
1337
+ )
1338
+
1339
+ const status = (snapshot && snapshot.status) || 'loading'
1340
+ const stored = (snapshot && snapshot.value) || {}
1341
+
1342
+ const valueOf = React.useCallback((field) => {
1343
+ if (draft && Object.prototype.hasOwnProperty.call(draft, field)) return draft[field]
1344
+ const current = stored[field]
1345
+ return current === undefined ? '' : String(current)
1346
+ }, [draft, stored])
1347
+
1348
+ const edit = React.useCallback((field, text) => {
1349
+ setSaved(false)
1350
+ setDraft((prev) => Object.assign({}, prev, { [field]: text }))
1351
+ }, [])
1352
+
1353
+ const discard = React.useCallback(() => { setDraft(null); setFailed(''); setSaved(false) }, [])
1354
+
1355
+ const save = React.useCallback(async () => {
1356
+ if (!scope || !draft) return
1357
+ setSaving(true); setFailed(''); setSaved(false)
1358
+ // Пишем ВСЕ поля, а не до первой ошибки: обрыв на первой неудаче
1359
+ // оставил бы остальные поля незаписанными, а снаружи это выглядит как
1360
+ // «кнопка не работает».
1361
+ const broken = []
1362
+ for (const spec of FIELDS) {
1363
+ if (!draft || !Object.prototype.hasOwnProperty.call(draft, spec.field)) continue
1364
+ let next = draft[spec.field]
1365
+ if (spec.kind === 'number') {
1366
+ const parsed = Number(String(next).trim())
1367
+ if (!Number.isFinite(parsed)) { broken.push(spec.field); continue }
1368
+ next = parsed
1369
+ } else if (spec.kind === 'bool') {
1370
+ next = String(next) === 'true'
1371
+ }
1372
+ try { await scope.set(spec.field, next) } catch { broken.push(spec.field) }
1373
+ }
1374
+ setSaving(false)
1375
+ if (broken.length) {
1376
+ setFailed(t('settings.saveFailed', { fields: broken.map((f) => t('field.' + f)).join(', ') }))
1377
+ return
1378
+ }
1379
+ setDraft(null); setSaved(true)
1380
+ }, [scope, draft, t])
1381
+
1382
+ const head = React.createElement('button', {
1383
+ type: 'button', className: 'dkb-head', onClick: () => setOpen((v) => !v),
1384
+ },
1385
+ React.createElement('span', { className: 'dkb-headText' },
1386
+ React.createElement('span', { className: 'dkb-title' }, t('title')),
1387
+ React.createElement('span', { className: 'dkb-sub' }, t('subtitle')),
1388
+ ),
1389
+ React.createElement(Chevron, { open }),
1390
+ )
1391
+ const style = React.createElement('style', null, css)
1392
+
1393
+ if (!open) return React.createElement('li', { className: 'dkb-card' }, style, head)
1394
+
1395
+ let body
1396
+ if (status === 'loading') {
1397
+ body = React.createElement('p', { className: 'dkb-note', role: 'status' }, t('settings.loading'))
1398
+ } else if (status !== 'ready') {
1399
+ // Проверяем СТАТУС, а не значение: при `unavailable` признак writable
1400
+ // остаётся истинным, и карточка нарисовала бы пустую, но с виду
1401
+ // рабочую форму.
1402
+ body = React.createElement('p', { className: 'dkb-failed', role: 'status' }, t('settings.unavailable'))
1403
+ } else {
1404
+ const writable = !snapshot || snapshot.writable !== false
1405
+ const rows = []
1406
+ let lastGroup
1407
+ for (const spec of FIELDS) {
1408
+ if (spec.group !== lastGroup) {
1409
+ lastGroup = spec.group
1410
+ rows.push(React.createElement('div', { className: 'dkb-group', key: 'g-' + spec.group }, t('group.' + spec.group)))
1411
+ }
1412
+ if (spec.kind === 'bool') {
1413
+ // Булево поле — флажок, а не строка «true»: набирать слово руками
1414
+ // человека заставлять незачем.
1415
+ rows.push(React.createElement('div', { className: 'dkb-field', key: spec.field },
1416
+ React.createElement('label', { className: 'dkb-row' },
1417
+ React.createElement('input', {
1418
+ type: 'checkbox',
1419
+ checked: String(valueOf(spec.field)) === 'true',
1420
+ disabled: !writable || saving,
1421
+ onChange: (e) => edit(spec.field, e.target.checked ? 'true' : 'false'),
1422
+ }),
1423
+ React.createElement('span', { className: 'dkb-label' }, t('field.' + spec.field)),
1424
+ ),
1425
+ React.createElement('p', { className: 'dkb-hint' }, t(hintKey(spec))),
1426
+ ))
1427
+ continue
1428
+ }
1429
+ const common = {
1430
+ className: spec.kind === 'area' ? 'dkb-area' : 'dkb-input',
1431
+ value: valueOf(spec.field),
1432
+ disabled: !writable || saving,
1433
+ onChange: (e) => edit(spec.field, e.target.value),
1434
+ }
1435
+ if (spec.kind === 'number') common.type = 'number'
1436
+ rows.push(React.createElement('div', { className: 'dkb-field', key: spec.field },
1437
+ React.createElement('span', { className: 'dkb-label' }, t('field.' + spec.field)),
1438
+ React.createElement(spec.kind === 'area' ? 'textarea' : 'input', common),
1439
+ React.createElement('p', { className: 'dkb-hint' }, t(hintKey(spec))),
1440
+ ))
1441
+ }
1442
+ const dirty = draft !== null && Object.keys(draft).length > 0
1443
+ let note
1444
+ if (failed) note = React.createElement('p', { className: 'dkb-failed', role: 'status' }, failed)
1445
+ else if (!writable) note = React.createElement('p', { className: 'dkb-note', role: 'status' }, t('settings.readOnly'))
1446
+ else if (saved) note = React.createElement('p', { className: 'dkb-note', role: 'status' }, t('settings.saved'))
1447
+ else note = React.createElement('p', { className: 'dkb-note' })
1448
+ rows.push(React.createElement('div', { className: 'dkb-foot', key: 'foot' },
1449
+ note,
1450
+ React.createElement('button', { type: 'button', className: 'dkb-discard', disabled: !dirty || saving, onClick: discard }, t('discard')),
1451
+ React.createElement('button', { type: 'button', className: 'dkb-save', disabled: !dirty || saving || !writable, onClick: save }, t('save')),
1452
+ ))
1453
+ body = rows
1454
+ }
1455
+
1456
+ return React.createElement('li', { className: 'dkb-card dkb-cardOpen' },
1457
+ style, head, React.createElement('div', { className: 'dkb-body' }, body))
1458
+ }
1459
+
1460
+ /** Стрелка влево для кнопки возврата: 14x14, обводка цветом текста. */
1461
+ const BACK_ICON = '<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor"'
1462
+ + ' stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">'
1463
+ + '<path d="M10 3.5 5.5 8l4.5 4.5"/></svg>'
1464
+
1465
+ // ------------------------------------------------------------ экран доски
1466
+
1467
+ function TaskCard(props) {
1468
+ const { task, t, now } = props
1469
+ const ref = cardRef(task)
1470
+ const status = cardStatus(task)
1471
+ const waiting = task.waiting === true
1472
+ const plan = planLine(task, t)
1473
+ const state = task.state === 'queued'
1474
+ ? t('state.queued', { n: task.queuePos || 1 })
1475
+ : stateLabel(task, t)
1476
+ const pack = props.pack
1477
+ // Дата заведения, а не время в колонке. «Здесь 34 минуты» у задачи,
1478
+ // приехавшей подхватом полчаса назад, не значит ничего: это возраст
1479
+ // карточки на доске, а спрашивают про возраст самой задачи.
1480
+ const born = shortDate(task.createdAt)
1481
+ const touched = task.sessionId ? agoText(now, task.updatedAt, t) : ''
1482
+ const menu = props.menuOpen
1483
+ const urgent = priorityColor(task)
1484
+ return React.createElement('div', {
1485
+ className: 'dkb-taskCard' + (urgent ? ' dkb-urgent' : '')
1486
+ + (props.sameSession ? ' dkb-sameSession' : ''),
1487
+ onMouseEnter: () => { if (props.onHover) props.onHover(task.sessionId || '') },
1488
+ onMouseLeave: () => { if (props.onHover) props.onHover('') },
1489
+ style: urgent ? { borderLeftColor: urgent } : undefined,
1490
+ // Полный заголовок в подсказке: на карточке он обрезан двумя строками,
1491
+ // и прочитать его иначе можно было только открыв её.
1492
+ title: task.title,
1493
+ // Карточка становится точкой табуляции: доска была мышиной целиком, а
1494
+ // перенос теперь ещё и команда агенту — действие с последствиями.
1495
+ tabIndex: 0,
1496
+ draggable: true,
1497
+ onDragStart: (e) => {
1498
+ try { e.dataTransfer.setData('text/plain', task.id) } catch { /* не всякий носитель умеет */ }
1499
+ props.onDragStart(task)
1500
+ },
1501
+ onClick: () => props.onOpen(task),
1502
+ },
1503
+ // Шапка карточки: чей это проект и что за работа. Оба ответа нужны
1504
+ // раньше заголовка — по ним карточку и находят глазами в колонке.
1505
+ React.createElement('div', { className: 'dkb-taskHead' },
1506
+ React.createElement('input', {
1507
+ type: 'checkbox',
1508
+ className: 'dkb-pick' + (props.picked ? ' dkb-pickOn' : ''),
1509
+ checked: props.picked === true,
1510
+ title: t('card.pick'),
1511
+ onClick: (e) => e.stopPropagation(),
1512
+ onChange: () => props.onPick(task),
1513
+ }),
1514
+ waiting ? React.createElement('span', { className: 'dkb-waitDot' }) : null,
1515
+ waiting ? React.createElement('span', { className: 'dkb-waiting' }, t('board.waiting')) : null,
1516
+ taskRef(task, t),
1517
+ (task.labels || []).map((l) => React.createElement('span', {
1518
+ className: 'dkb-tag', key: l, style: tagStyle(task, l),
1519
+ }, l))),
1520
+ React.createElement('div', { className: 'dkb-taskTitle' }, task.title),
1521
+ plan ? React.createElement('div', { className: 'dkb-planLine' }, plan) : null,
1522
+ // Второй путь к переносу, не замена перетаскиванию: мышью удобнее,
1523
+ // когда мышь под рукой. Подтверждение и последствия у обоих те же —
1524
+ // разойтись им нельзя.
1525
+ React.createElement('div', { className: 'dkb-cardMenu' },
1526
+ React.createElement('button', {
1527
+ type: 'button', className: 'dkb-moveBtn',
1528
+ // Скрыто до наведения — но появляется и при фокусе с клавиатуры:
1529
+ // прятать ради красоты то, что чинили ради доступности, нельзя.
1530
+ 'aria-expanded': menu === task.id,
1531
+ onClick: (e) => { e.stopPropagation(); props.onMenu(menu === task.id ? '' : task.id) },
1532
+ }, t('card.move')),
1533
+ menu === task.id
1534
+ ? React.createElement('div', { className: 'dkb-facetList' },
1535
+ (props.columns || []).filter((c) => c !== task.column).map((c) => React.createElement('button', {
1536
+ type: 'button', className: 'dkb-facetRow', key: c,
1537
+ onClick: (e) => { e.stopPropagation(); props.onMenu(''); props.onMove(task, c) },
1538
+ }, t('column.' + c))),
1539
+ // Очередь стоит рядом с переносом: и то и другое — «отдать
1540
+ // работу», разница лишь в том, кому и когда.
1541
+ // Останавливать нечего у того, кто не идёт: пункт, который
1542
+ // ничего не делает, хуже отсутствующего.
1543
+ task.state === 'running' || task.state === 'waiting'
1544
+ ? React.createElement('button', {
1545
+ type: 'button', className: 'dkb-facetRow', key: 'stop',
1546
+ onClick: (e) => { e.stopPropagation(); props.onMenu(''); props.onStop(task) },
1547
+ }, t('card.stop'))
1548
+ : null,
1549
+ task.state === 'queued'
1550
+ ? React.createElement('button', {
1551
+ type: 'button', className: 'dkb-facetRow', key: 'unqueue',
1552
+ onClick: (e) => { e.stopPropagation(); props.onMenu(''); props.onUnqueue(task) },
1553
+ }, t('card.unqueue'))
1554
+ : React.createElement('button', {
1555
+ type: 'button', className: 'dkb-facetRow', key: 'queue',
1556
+ onClick: (e) => { e.stopPropagation(); props.onMenu(''); props.onQueue(task) },
1557
+ }, t('card.queue')))
1558
+ : null,
1559
+ ),
1560
+ status || state || born || touched
1561
+ ? React.createElement('div', { className: 'dkb-taskRef' },
1562
+ state
1563
+ ? React.createElement('span', {
1564
+ className: 'dkb-state' + (task.state === 'running' ? ' dkb-stateLive' : ''),
1565
+ }, state)
1566
+ : null,
1567
+ // Ссылка в чат стоит рядом с отметкой работы: «идёт» без пути к
1568
+ // тому, что идёт, — сведение, которым нельзя воспользоваться.
1569
+ task.sessionId
1570
+ ? React.createElement('button', {
1571
+ type: 'button', className: 'dkb-toChat',
1572
+ onClick: (e) => { e.stopPropagation(); props.onOpenChat(task) },
1573
+ }, t('card.toChat'))
1574
+ : null,
1575
+ touched
1576
+ ? React.createElement('span', {
1577
+ className: task.stale ? 'dkb-countOver' : '',
1578
+ title: exactAt(task.updatedAt),
1579
+ }, touched)
1580
+ : null,
1581
+ born
1582
+ ? React.createElement('span', { title: exactAt(task.createdAt) }, born)
1583
+ : null,
1584
+ pack
1585
+ ? React.createElement('span', {
1586
+ className: 'dkb-pack', title: t('card.packHint'),
1587
+ }, t('card.pack', { at: pack.at, total: pack.total }))
1588
+ : null,
1589
+ status ? React.createElement('span', null, status) : null)
1590
+ : null,
1591
+ )
1592
+ }
1593
+
1594
+ /**
1595
+ * Чем запускать: профиль агента и уровень доступа.
1596
+ *
1597
+ * Одни и те же поля стоят и в окне задачи, и в окне пачки — пачка идёт
1598
+ * ОДНОЙ сессией, значит и профиль с доступом у неё один. Две копии полей
1599
+ * разошлись бы при первой правке.
1600
+ *
1601
+ * Пустого списка не бывает наполовину: службы у ядра необязательны, и если
1602
+ * выбирать не из чего, поле не рисуется вовсе.
1603
+ */
1604
+ function LaunchFields(props) {
1605
+ const t = props.t
1606
+ const rows = []
1607
+ if (!props.hidePreset && (props.presets.agentPresets || []).length > 0) {
1608
+ rows.push(React.createElement('div', { className: 'dkb-field', key: 'preset' },
1609
+ React.createElement('span', { className: 'dkb-label' }, t('panel.agentPreset')),
1610
+ React.createElement('select', {
1611
+ className: 'dkb-input', value: props.agentPreset, disabled: props.disabled,
1612
+ onChange: (e) => props.onPreset(e.target.value),
1613
+ },
1614
+ React.createElement('option', { value: '' }, t('panel.presetDefault')),
1615
+ props.presets.agentPresets.map((one) => React.createElement('option', {
1616
+ value: one.id, key: one.id, title: one.description || '',
1617
+ }, one.name || one.id))),
1618
+ ))
1619
+ }
1620
+ if ((props.presets.access || []).length > 0) {
1621
+ const picked = (props.presets.access || []).find((one) => one.value === props.permission)
1622
+ rows.push(React.createElement('div', { className: 'dkb-field', key: 'access' },
1623
+ React.createElement('span', { className: 'dkb-label' }, t('panel.permission')),
1624
+ React.createElement('select', {
1625
+ className: 'dkb-input', value: props.permission, disabled: props.disabled,
1626
+ onChange: (e) => props.onPermission(e.target.value),
1627
+ }, props.presets.access.map((one) => React.createElement('option', {
1628
+ value: one.value, key: one.value,
1629
+ }, one.name || one.value))),
1630
+ // Пояснение к уровню доступа пишет ядро: свои слова разошлись бы с
1631
+ // тем, что этот уровень значит на самом деле.
1632
+ picked && picked.description
1633
+ ? React.createElement('p', { className: 'dkb-hint' }, picked.description)
1634
+ : null,
1635
+ ))
1636
+ }
1637
+ return rows.length === 0 ? null : React.createElement('div', { className: 'dkb-start' }, rows)
1638
+ }
1639
+
1640
+ function BoardScreen(props) {
1641
+ const t = props.t || fallbackT
1642
+ const [state, setState] = React.useState(null)
1643
+ const [error, setError] = React.useState('')
1644
+ const [openTask, setOpenTask] = React.useState(null)
1645
+ const [log, setLog] = React.useState([])
1646
+ const [dialog, setDialog] = React.useState(null)
1647
+ const [overColumn, setOverColumn] = React.useState('')
1648
+ const [model, setModel] = React.useState('')
1649
+ const [provider, setProvider] = React.useState('')
1650
+ const [providers, setProviders] = React.useState([])
1651
+ const [models, setModels] = React.useState([])
1652
+ const [starting, setStarting] = React.useState(false)
1653
+ const [draftText, setDraftText] = React.useState('')
1654
+ const [query, setQuery] = React.useState('')
1655
+ const [dropAt, setDropAt] = React.useState(null)
1656
+ const [board, setBoard] = React.useState('main')
1657
+ // Решения о сворачивании держим в состоянии экрана, а не в хранилище:
1658
+ // это взгляд одного человека на одну доску прямо сейчас, а не свойство
1659
+ // задач. Обновление доски их переживает, перезагрузка страницы — нет.
1660
+ const [collapsed, setCollapsed] = React.useState({})
1661
+ // Отборы живут в состоянии экрана: он смонтирован всё время работы
1662
+ // харнесса, поэтому выбор переживает и закрытие доски, и обновление.
1663
+ const [filters, setFilters] = React.useState({})
1664
+ const [openFacet, setOpenFacet] = React.useState('')
1665
+ const [cardMenu, setCardMenu] = React.useState('')
1666
+ const [body, setBody] = React.useState(null)
1667
+ const [groups, setGroups] = React.useState({})
1668
+ const [syncing, setSyncing] = React.useState(false)
1669
+ // Отмеченные для группового запуска. Живут отдельно от открытой карточки:
1670
+ // выбор переживает и открытие окна, и обновление доски.
1671
+ const [picked, setPicked] = React.useState({})
1672
+ const [archive, setArchive] = React.useState(null)
1673
+ // «Только ждущие» держим отдельно от отборов: это не значение измерения,
1674
+ // а взгляд «покажи, где стоит работа», и складывается он с любым отбором.
1675
+ const [onlyWaiting, setOnlyWaiting] = React.useState(false)
1676
+ const [hoverSession, setHoverSession] = React.useState('')
1677
+ const [order, setOrder] = React.useState('manual')
1678
+ // Чем запускать: профиль агента и уровень доступа. Обе службы у ядра
1679
+ // необязательны, поэтому пустой список — не поломка, а «выбирать не из
1680
+ // чего»: поле тогда не рисуется вовсе.
1681
+ const [presets, setPresets] = React.useState({ agentPresets: [], access: [] })
1682
+ const [agentPreset, setAgentPreset] = React.useState('')
1683
+ const [permission, setPermission] = React.useState('')
1684
+ const dragged = React.useRef(null)
1685
+
1686
+ const load = React.useCallback(async (nextBoard) => {
1687
+ try {
1688
+ // Отбор по репозиторию делает браузер вместе с остальными отборами:
1689
+ // серверный отбор спорил бы с ним и прятал бы часть значений из
1690
+ // списка, потому что их не было бы в ответе.
1691
+ const query = '?board=' + encodeURIComponent(nextBoard || 'main')
1692
+ setState(normalizeBoard(await api('/board' + query)))
1693
+ setError('')
1694
+ } catch (e) {
1695
+ setError(t(e.key || 'error.unknown'))
1696
+ }
1697
+ }, [t])
1698
+
1699
+ React.useEffect(() => { load(board) }, [load, board])
1700
+
1701
+ // Просьба чипа «покажи вот эту». Забираем её один раз: повтор открывал
1702
+ // бы окно снова после каждого закрытия.
1703
+ const [wanted, setWanted] = React.useState('')
1704
+ React.useEffect(() => {
1705
+ const toggle = props.toggle
1706
+ if (!toggle || typeof toggle.takeWanted !== 'function') return undefined
1707
+ const pull = () => {
1708
+ const asked = toggle.takeWanted()
1709
+ if (!asked || !asked.id) return
1710
+ setBoard(asked.board)
1711
+ setWanted(asked.id)
1712
+ }
1713
+ pull()
1714
+ return toggle.subscribe(pull)
1715
+ }, [props.toggle])
1716
+
1717
+ // Открываем, когда задача приехала: доска могла переключаться, и в
1718
+ // момент просьбы её ещё не было в списке.
1719
+ React.useEffect(() => {
1720
+ if (wanted === '' || state === null) return
1721
+ const task = state.tasks.find((x) => x.id === wanted)
1722
+ if (task === undefined) return
1723
+ setWanted('')
1724
+ openPanelRef.current(task)
1725
+ }, [wanted, state])
1726
+
1727
+ // Escape снимает по одному слою за нажатие: диалог, затем панель, затем
1728
+ // саму доску. Закрывать всё разом — терять контекст, которого человек не
1729
+ // просил лишаться.
1730
+ React.useEffect(() => {
1731
+ const onKey = (e) => {
1732
+ if (e.key !== 'Escape') return
1733
+ if (dialog !== null) { setDialog(null); return }
1734
+ if (openTask !== null) { setOpenTask(null); return }
1735
+ if (props.onClose) props.onClose()
1736
+ }
1737
+ document.addEventListener('keydown', onKey)
1738
+ return () => document.removeEventListener('keydown', onKey)
1739
+ }, [dialog, openTask, props])
1740
+
1741
+ // Сначала провайдеры и текущий выбор, затем модели выбранного провайдера.
1742
+ React.useEffect(() => {
1743
+ let alive = true
1744
+ api('/models')
1745
+ .then((out) => {
1746
+ if (!alive || !out) return
1747
+ const list = out.providers || []
1748
+ setProviders(list)
1749
+ const saved = recallLaunch(board)
1750
+ const provider = stillOffered(saved.provider, list.map((p) => p.id), '')
1751
+ if (provider !== '') {
1752
+ setProvider(provider)
1753
+ // Модель проверяется своим списком, когда он приедет: здесь её
1754
+ // ещё не с чем сверить, а подставить чужую нельзя.
1755
+ setModel(typeof saved.model === 'string' ? saved.model : '')
1756
+ } else if (out.current) {
1757
+ setProvider(out.current.provider || '')
1758
+ setModel(out.current.model || '')
1759
+ }
1760
+ })
1761
+ .catch(() => {})
1762
+ return () => { alive = false }
1763
+ }, [])
1764
+
1765
+ React.useEffect(() => {
1766
+ let alive = true
1767
+ api('/presets')
1768
+ .then((out) => {
1769
+ if (!alive || !out) return
1770
+ const rows = out.agentPresets || []
1771
+ const access = out.access || []
1772
+ setPresets({ agentPresets: rows, access })
1773
+ // Пустое значение — «как у харнесса по умолчанию»; подставляем его
1774
+ // же явно, чтобы человек видел, с чем задача пойдёт.
1775
+ const saved = recallLaunch(board)
1776
+ setAgentPreset(stillOffered(saved.agentPreset, rows.map((x) => x.id), out.agentPresetDefault || ''))
1777
+ setPermission(stillOffered(saved.permission, access.map((x) => x.value), out.accessDefault || ''))
1778
+ })
1779
+ .catch(() => {})
1780
+ return () => { alive = false }
1781
+ }, [])
1782
+
1783
+ React.useEffect(() => {
1784
+ let alive = true
1785
+ if (provider === '') { setModels([]); return () => { alive = false } }
1786
+ api('/models?provider=' + encodeURIComponent(provider))
1787
+ .then((out) => {
1788
+ if (!alive || !out) return
1789
+ const list = out.models || []
1790
+ setModels(list)
1791
+ // Модель могла исчезнуть у провайдера: подставленная из памяти, она
1792
+ // осталась бы в поле, а запуск отказал бы уже после нажатия.
1793
+ setModel((cur) => stillOffered(cur, list.map((m) => m.id), ''))
1794
+ })
1795
+ .catch(() => { if (alive) setModels([]) })
1796
+ return () => { alive = false }
1797
+ }, [provider])
1798
+
1799
+ const commitMove = React.useCallback(async (task, column, where) => {
1800
+ // Карточку не перерисовываем заранее: до подтверждения она обязана
1801
+ // оставаться там, где лежала, иначе отмена выглядит откатом.
1802
+ try {
1803
+ await api('/task/' + encodeURIComponent(task.id) + '/move', {
1804
+ method: 'POST',
1805
+ body: JSON.stringify({ column, beforeId: where.beforeId, afterId: where.afterId }),
1806
+ })
1807
+ await load(board)
1808
+ } catch (e) {
1809
+ setError(t(e.key || 'error.unknown'))
1810
+ await load(board)
1811
+ }
1812
+ }, [load, t])
1813
+
1814
+ /**
1815
+ * Спросить о переносе и, получив согласие, выполнить его.
1816
+ *
1817
+ * Общая для мыши и для меню на карточке: путь другой, последствия те же,
1818
+ * и разойтись им нельзя.
1819
+ */
1820
+ const askMove = React.useCallback((task, column, where) => {
1821
+ if (!task) return
1822
+ const place = where ?? neighboursFor(tasksOf((state && state.tasks) || [], column), task.id, Infinity)
1823
+ // Перенос внутри колонки — это порядок, а не команда: спрашивать не о чем.
1824
+ if (task.column === column) { commitMove(task, column, place); return }
1825
+ setDialog({ mode: 'move', task, column, where: place })
1826
+ }, [state, commitMove])
1827
+
1828
+ const drop = React.useCallback((column, index) => {
1829
+ const task = dragged.current
1830
+ dragged.current = null
1831
+ setOverColumn('')
1832
+ setDropAt(null)
1833
+ if (!task || !state) return
1834
+ // Мышь слишком легко задевает карточку, чтобы перенос уходил без спроса.
1835
+ askMove(task, column, neighboursFor(tasksOf(state.tasks, column), task.id, index))
1836
+ }, [state, askMove])
1837
+
1838
+ const openPanelRef = React.useRef(() => {})
1839
+ const openPanel = React.useCallback(async (task) => {
1840
+ setOpenTask(task)
1841
+ setDraftText('')
1842
+ setBody(null)
1843
+ api('/task/' + encodeURIComponent(task.id) + '/body')
1844
+ .then((out) => setBody((out && out.blocks) || []))
1845
+ .catch(() => setBody([]))
1846
+ api('/task/' + encodeURIComponent(task.id) + '/message')
1847
+ .then((out) => setDraftText((out && out.text) || ''))
1848
+ .catch(() => setDraftText(''))
1849
+ try {
1850
+ const out = await api('/task/' + encodeURIComponent(task.id) + '/log')
1851
+ setLog(out.transitions || [])
1852
+ } catch { setLog([]) }
1853
+ }, [])
1854
+ openPanelRef.current = openPanel
1855
+
1856
+ const style = React.createElement('style', null, css)
1857
+
1858
+ if (state === null) {
1859
+ return React.createElement('div', { className: 'dkb-screen' }, style,
1860
+ React.createElement('p', { className: 'dkb-note' }, error || t('board.loading')))
1861
+ }
1862
+
1863
+ const boards = (state && state.boards) || []
1864
+ const bar = React.createElement('div', { className: 'dkb-bar' },
1865
+ // Первой в шапке и слева от заголовка — там она читается как «назад».
1866
+ // Своего механизма закрытия не заводит: их уже три (Escape, повторный
1867
+ // щелчок по боковой строке, переход в чужую сессию), и четвёртый
1868
+ // разошёлся бы с ними при первой же правке.
1869
+ React.createElement('button', {
1870
+ type: 'button', className: 'dkb-back', title: t('board.backHint'),
1871
+ onClick: () => { if (props.onClose) props.onClose() },
1872
+ },
1873
+ React.createElement('span', {
1874
+ className: 'dkb-backIcon',
1875
+ dangerouslySetInnerHTML: { __html: BACK_ICON },
1876
+ }),
1877
+ React.createElement('span', null, t('board.back')),
1878
+ ),
1879
+ React.createElement('span', { className: 'dkb-barTitle' }, t('section.label')),
1880
+ boards.length > 1
1881
+ ? React.createElement('select', {
1882
+ className: 'dkb-input', style: { width: 'auto' }, value: board,
1883
+ onChange: (e) => {
1884
+ // Отборы собраны из меток ЭТОЙ доски: на другой те же значения
1885
+ // могут не встречаться вовсе, и выбор превратился бы в пустоту.
1886
+ setOpenTask(null); setCollapsed({}); setFilters({}); setBoard(e.target.value)
1887
+ },
1888
+ }, boards.map((b) => React.createElement('option', { value: b.id, key: b.id }, boardTitle(b, t))))
1889
+ : null,
1890
+ (state.facets || []).map((facet) => {
1891
+ const picked = filters[facet.ns] || []
1892
+ // Имена пространств приходят из Gitea сырыми. Известные переводим,
1893
+ // незнакомое оставляем как есть: выдумывать перевод метке, которую
1894
+ // завели вчера, доска не вправе.
1895
+ const label = facetLabel(facet.ns, t)
1896
+ return React.createElement('div', { className: 'dkb-facet', key: facet.ns },
1897
+ React.createElement('button', {
1898
+ type: 'button',
1899
+ className: 'dkb-facetHead' + (picked.length ? ' dkb-facetOn' : ''),
1900
+ 'aria-expanded': openFacet === facet.ns,
1901
+ onClick: () => setOpenFacet((cur) => (cur === facet.ns ? '' : facet.ns)),
1902
+ }, picked.length ? label + ' · ' + picked.length : label),
1903
+ openFacet === facet.ns
1904
+ ? React.createElement('div', { className: 'dkb-facetList' },
1905
+ facet.values.map((v) => React.createElement('label', {
1906
+ className: 'dkb-facetRow', key: v.value,
1907
+ },
1908
+ React.createElement('input', {
1909
+ type: 'checkbox',
1910
+ checked: picked.indexOf(v.value) >= 0,
1911
+ onChange: () => setFilters((cur) => toggleValue(cur, facet.ns, v.value)),
1912
+ }),
1913
+ React.createElement('span', { className: 'dkb-facetName' }, v.value),
1914
+ React.createElement('span', { className: 'dkb-count' }, String(v.count)),
1915
+ )))
1916
+ : null,
1917
+ )
1918
+ }),
1919
+ (() => {
1920
+ // Ноль в шапке — мебель, за которой перестают следить, поэтому
1921
+ // счётчик появляется только когда кого-то действительно ждут.
1922
+ const waiting = state.tasks.filter((x) => x.state === 'waiting').length
1923
+ if (waiting === 0) return null
1924
+ return React.createElement('button', {
1925
+ type: 'button',
1926
+ className: 'dkb-waitCount' + (onlyWaiting ? ' dkb-save' : ''),
1927
+ title: t('board.waitCountHint'),
1928
+ onClick: () => setOnlyWaiting((cur) => !cur),
1929
+ }, t('board.waitCount', { n: waiting }))
1930
+ })(),
1931
+ React.createElement('select', {
1932
+ className: 'dkb-input', style: { width: 'auto' }, value: order,
1933
+ onChange: (e) => setOrder(e.target.value),
1934
+ },
1935
+ React.createElement('option', { value: 'manual' }, t('order.manual')),
1936
+ React.createElement('option', { value: 'old' }, t('order.old')),
1937
+ React.createElement('option', { value: 'new' }, t('order.new')),
1938
+ ),
1939
+ React.createElement('button', {
1940
+ type: 'button', className: archive ? 'dkb-save' : 'dkb-discard',
1941
+ onClick: async () => {
1942
+ if (archive) { setArchive(null); return }
1943
+ try {
1944
+ const out = await api('/archive')
1945
+ setArchive(Array.isArray(out.tasks) ? out.tasks : [])
1946
+ } catch (e) { setError(t(e.key || 'error.unknown')) }
1947
+ },
1948
+ }, t('board.archive')),
1949
+ // «Показано 12 из 81» появляется, только когда что-то отобрано или
1950
+ // введён поиск: на нетронутой доске эта строка была бы шумом.
1951
+ anySelected(filters) || query.trim() !== ''
1952
+ ? React.createElement('span', { className: 'dkb-shown' }, t('board.shown', {
1953
+ shown: state.tasks.filter((x) => matchesFilters(x, filters) && matchesQuery(x, query)).length,
1954
+ total: state.tasks.length,
1955
+ }))
1956
+ : null,
1957
+ anySelected(filters)
1958
+ ? React.createElement('button', {
1959
+ type: 'button', className: 'dkb-discard',
1960
+ onClick: () => { setFilters({}); setOpenFacet('') },
1961
+ }, t('board.clearFilters'))
1962
+ : null,
1963
+ React.createElement('input', {
1964
+ className: 'dkb-search', value: query, placeholder: t('board.search'),
1965
+ onChange: (e) => setQuery(e.target.value),
1966
+ }),
1967
+ (() => {
1968
+ // Пока запрос в пути, состояние на доске ещё вчерашнее: оно приедет
1969
+ // только со следующим ответом. Поэтому ход показываем сами, а не
1970
+ // ждём, когда сервер расскажет о нём задним числом.
1971
+ const line = syncing
1972
+ ? { tone: 'idle', text: t('sync.running'), title: '' }
1973
+ : syncLine(state.sync, state.now, t)
1974
+ return React.createElement('span', {
1975
+ className: 'dkb-sync' + (line.tone === 'bad' ? ' dkb-syncBad' : ''),
1976
+ title: line.title,
1977
+ }, line.text)
1978
+ })(),
1979
+ React.createElement('button', {
1980
+ type: 'button', className: 'dkb-discard', disabled: syncing,
1981
+ onClick: async () => {
1982
+ // Кнопка гаснет на время прохода: нажать её второй раз значило бы
1983
+ // попросить сверку, которая и так идёт, а сервер ответил бы
1984
+ // «пропущено» — и человек решил бы, что ничего не случилось.
1985
+ setSyncing(true)
1986
+ try {
1987
+ await api('/sync', { method: 'POST', body: '{}' })
1988
+ await load(board)
1989
+ } catch (e) {
1990
+ setError(t(e.key || 'error.unknown'))
1991
+ } finally {
1992
+ setSyncing(false)
1993
+ }
1994
+ },
1995
+ }, syncing ? t('board.syncing') : t('board.sync')),
1996
+ React.createElement('button', { type: 'button', className: 'dkb-discard', onClick: () => load(board) }, t('board.refresh')),
1997
+ // Полоса выбора появляется, только когда что-то отмечено: на пустом
1998
+ // выборе она была бы мебелью.
1999
+ Object.keys(picked).length > 0
2000
+ ? React.createElement('span', { className: 'dkb-shown' },
2001
+ t('board.pickedCount', { n: Object.keys(picked).length }))
2002
+ : null,
2003
+ Object.keys(picked).length > 0
2004
+ ? React.createElement('button', {
2005
+ type: 'button', className: 'dkb-discard',
2006
+ onClick: () => setPicked({}),
2007
+ }, t('board.pickNone'))
2008
+ : null,
2009
+ Object.keys(picked).length > 0
2010
+ ? React.createElement('button', {
2011
+ type: 'button', className: 'dkb-save',
2012
+ onClick: () => setDialog({
2013
+ mode: 'batch',
2014
+ ids: Object.keys(picked),
2015
+ provider: '', model: '', text: '', error: '',
2016
+ }),
2017
+ }, t('board.pickStart'))
2018
+ : null,
2019
+ Object.keys(picked).length > 0
2020
+ ? React.createElement('button', {
2021
+ type: 'button', className: 'dkb-discard',
2022
+ onClick: async () => {
2023
+ const ids = Object.keys(picked)
2024
+ const out = await applyToEach(ids, (id) => api(
2025
+ '/task/' + encodeURIComponent(id) + '/archive', { method: 'POST', body: '{}' },
2026
+ ))
2027
+ // Часть задач архивировать нельзя (архив только из «Выполнено»),
2028
+ // и число отказов называется вслух.
2029
+ if (out.failed > 0) setError(t('board.someFailed', { done: out.done, total: ids.length }))
2030
+ else setPicked({})
2031
+ await load(board)
2032
+ },
2033
+ }, t('board.pickArchive'))
2034
+ : null,
2035
+ Object.keys(picked).length > 0
2036
+ ? React.createElement('button', {
2037
+ type: 'button', className: 'dkb-discard',
2038
+ onClick: async () => {
2039
+ const ids = Object.keys(picked)
2040
+ const out = await applyToEach(ids, (id) => api(
2041
+ '/task/' + encodeURIComponent(id) + '/unqueue', { method: 'POST', body: '{}' },
2042
+ ))
2043
+ if (out.failed > 0) setError(t('board.someFailed', { done: out.done, total: ids.length }))
2044
+ else setPicked({})
2045
+ await load(board)
2046
+ },
2047
+ }, t('board.pickUnqueue'))
2048
+ : null,
2049
+ Object.keys(picked).length > 0
2050
+ ? React.createElement('select', {
2051
+ className: 'dkb-input', style: { width: 'auto' }, value: '',
2052
+ onChange: async (e) => {
2053
+ const column = e.target.value
2054
+ if (column === '') return
2055
+ const ids = Object.keys(picked)
2056
+ // Групповой перенос — та же команда агенту, что и одиночный:
2057
+ // маршрут один, и последствия у него те же.
2058
+ const out = await applyToEach(ids, (id) => api(
2059
+ '/task/' + encodeURIComponent(id) + '/move',
2060
+ { method: 'POST', body: JSON.stringify({ column }) },
2061
+ ))
2062
+ if (out.failed > 0) setError(t('board.someFailed', { done: out.done, total: ids.length }))
2063
+ else setPicked({})
2064
+ await load(board)
2065
+ },
2066
+ },
2067
+ React.createElement('option', { value: '' }, t('board.pickMove')),
2068
+ (state.columns || []).map((c) => React.createElement('option', {
2069
+ value: c.id, key: c.id,
2070
+ }, columnTitle(c, t))),
2071
+ )
2072
+ : null,
2073
+ React.createElement('button', { type: 'button', className: 'dkb-save', onClick: () => setDialog({ mode: 'own', project: false, repo: '', newRepo: '', title: '', full: '', repos: null, issues: null, error: '' }) }, t('board.newTask')),
2074
+ )
2075
+
2076
+ // Счётчик колонки считает ВСЕ карточки, а не найденные: иначе поиск
2077
+ // сделал бы вид, что предел колонки соблюдён.
2078
+ const packs = packInfo(state.tasks)
2079
+ const columns = React.createElement('div', { className: 'dkb-cols' },
2080
+ state.columns.map((col) => {
2081
+ // Счётчик колонки считает ОТОБРАННОЕ, а поиск — нет.
2082
+ //
2083
+ // Разница осмысленная, а не недосмотр: поиск временный, и предел
2084
+ // «3 из 3» при найденной одной карточке врал бы. Отбор постоянный, и
2085
+ // на отборе по одному проекту «в работе 3 из 3» должно значить «по
2086
+ // этому проекту» — иначе предел ни о чём.
2087
+ const all = tasksOf(state.tasks, col.id).filter((task) => matchesFilters(task, filters))
2088
+ const items = sortTasks(
2089
+ all.filter((task) => matchesQuery(task, query))
2090
+ .filter((task) => !onlyWaiting || task.state === 'waiting'),
2091
+ order,
2092
+ )
2093
+ const limit = col.limit
2094
+ const overLimit = limit !== undefined && limit !== null && all.length > limit
2095
+ const slot = (index) => React.createElement('div', {
2096
+ className: 'dkb-slot' + (dropAt && dropAt.column === col.id && dropAt.index === index ? ' dkb-slotOn' : ''),
2097
+ key: 's' + index,
2098
+ onDragOver: (e) => {
2099
+ e.preventDefault(); e.stopPropagation()
2100
+ setOverColumn(col.id)
2101
+ setDropAt({ column: col.id, index })
2102
+ },
2103
+ onDrop: (e) => { e.preventDefault(); e.stopPropagation(); drop(col.id, index) },
2104
+ })
2105
+
2106
+ const rows = []
2107
+ rows.push(slot(0))
2108
+ // Заголовок рисуем при смене проекта. Задачи приходят уже
2109
+ // разложенными, поэтому группы идут подряд и разрываться не могут.
2110
+ let group = null
2111
+ const sizes = new Map()
2112
+ for (const x of items) sizes.set(x.repo || '', (sizes.get(x.repo || '') || 0) + 1)
2113
+ const manyGroups = sizes.size > 1
2114
+
2115
+ items.forEach((task, i) => {
2116
+ const repo = task.repo || ''
2117
+ if (manyGroups && repo !== group) {
2118
+ group = repo
2119
+ const shut = groups[col.id + '/' + repo] === true
2120
+ rows.push(React.createElement('button', {
2121
+ type: 'button', className: 'dkb-groupHead', key: 'g' + repo,
2122
+ 'aria-expanded': !shut,
2123
+ onClick: () => setGroups((cur) => Object.assign({}, cur, { [col.id + '/' + repo]: !shut })),
2124
+ },
2125
+ React.createElement('span', null, repo || t('board.noRepo')),
2126
+ // Счётчик виден и у свёрнутой: группа с работой, выглядящая
2127
+ // пустой, — то же зло, что и свёрнутая колонка без числа.
2128
+ React.createElement('span', { className: 'dkb-groupCount' }, String(sizes.get(repo))),
2129
+ ))
2130
+ }
2131
+ if (manyGroups && groups[col.id + '/' + (task.repo || '')] === true) return
2132
+ rows.push(React.createElement(TaskCard, {
2133
+ key: task.id, task, t, now: state.now,
2134
+ columns: (state.columns || []).map((c) => c.id),
2135
+ menuOpen: cardMenu,
2136
+ onMenu: setCardMenu,
2137
+ picked: picked[task.id] === true,
2138
+ onPick: (x) => setPicked((cur) => {
2139
+ const next = Object.assign({}, cur)
2140
+ if (next[x.id]) delete next[x.id]
2141
+ else next[x.id] = true
2142
+ return next
2143
+ }),
2144
+ pack: packs[task.id],
2145
+ sameSession: hoverSession !== '' && task.sessionId === hoverSession,
2146
+ onHover: setHoverSession,
2147
+ onStop: async (x) => {
2148
+ try {
2149
+ const out = await api('/task/' + encodeURIComponent(x.id) + '/stop', { method: 'POST', body: '{}' })
2150
+ // «Остановлено» и «он и так стоял» — разные ответы, и второй
2151
+ // нельзя выдавать за первый.
2152
+ if (out && out.acted !== 'stopped') setError(t('stop.' + out.acted))
2153
+ await load(board)
2154
+ } catch (e) { setError(t(e.key || 'error.unknown')) }
2155
+ },
2156
+ onMove: (x, column) => askMove(x, column),
2157
+ onQueue: async (x) => {
2158
+ try {
2159
+ const out = await api('/sessions')
2160
+ const live = (out && out.sessions) || []
2161
+ // Живых сессий нет — ставить некуда, и сказать об этом надо
2162
+ // сразу, а не показывать пустой список.
2163
+ if (live.length === 0) { setError(t('error.no-live-sessions')); return }
2164
+ setDialog({ mode: 'queue', task: x, sessions: live, sessionId: live[0].sessionId, error: '' })
2165
+ } catch (e) { setError(t(e.key || 'error.unknown')) }
2166
+ },
2167
+ onUnqueue: async (x) => {
2168
+ try {
2169
+ await api('/task/' + encodeURIComponent(x.id) + '/unqueue', { method: 'POST', body: '{}' })
2170
+ await load(board)
2171
+ } catch (e) { setError(t(e.key || 'error.unknown')) }
2172
+ },
2173
+ onOpenChat: (x) => {
2174
+ if (!openSession(props.ctx, x.sessionId)) {
2175
+ setError(t('error.sessionNotOpened', { id: x.sessionId }))
2176
+ return
2177
+ }
2178
+ if (props.onClose) props.onClose()
2179
+ },
2180
+ onDragStart: (x) => { dragged.current = x },
2181
+ onOpen: openPanel,
2182
+ }))
2183
+ rows.push(slot(i + 1))
2184
+ })
2185
+
2186
+ const shut = isCollapsed({ id: col.id, count: all.length }, collapsed)
2187
+ const toggle = () => setCollapsed((prev) => Object.assign({}, prev, { [col.id]: !shut }))
2188
+ const count = limit !== undefined && limit !== null
2189
+ ? all.length + ' / ' + t('board.limit', { n: limit })
2190
+ : String(all.length)
2191
+
2192
+ return React.createElement('div', {
2193
+ className: 'dkb-col'
2194
+ + (overColumn === col.id ? ' dkb-colOver' : '')
2195
+ + (shut ? ' dkb-colShut' : ''),
2196
+ key: col.id,
2197
+ // Свёрнутая колонка остаётся целью переноса: иначе карточку было бы
2198
+ // некуда положить, не развернув её сперва.
2199
+ onDragOver: (e) => { e.preventDefault(); setOverColumn(col.id) },
2200
+ onDragLeave: () => { setOverColumn(''); setDropAt(null) },
2201
+ onDrop: (e) => { e.preventDefault(); drop(col.id, items.length) },
2202
+ },
2203
+ React.createElement('button', {
2204
+ type: 'button', className: 'dkb-colHead',
2205
+ 'aria-expanded': !shut,
2206
+ title: t(shut ? 'board.expand' : 'board.collapse'),
2207
+ onClick: toggle,
2208
+ },
2209
+ React.createElement('span', { className: 'dkb-dot', 'data-col': col.id }),
2210
+ React.createElement('span', { className: 'dkb-colName' }, columnTitle(col, t)),
2211
+ // Число карточек видно и в свёрнутом виде: колонка с работой,
2212
+ // выглядящая пустой, — худшее, что может сделать сворачивание.
2213
+ React.createElement('span', {
2214
+ className: 'dkb-count' + (overLimit ? ' dkb-countOver' : ''),
2215
+ 'data-col': col.id,
2216
+ }, count),
2217
+ ),
2218
+ shut ? null : React.createElement('div', { className: 'dkb-list' },
2219
+ all.length === 0
2220
+ ? React.createElement('div', { className: 'dkb-empty' }, t('board.empty'))
2221
+ : items.length === 0
2222
+ ? React.createElement('div', { className: 'dkb-empty' }, t('board.noMatch'))
2223
+ : null,
2224
+ rows,
2225
+ ),
2226
+ )
2227
+ }),
2228
+ )
2229
+
2230
+ // Окно, а не панель справа: в 420 пикселей помещались описание и журнал,
2231
+ // но не план с отметками. Живёт внутри экрана доски — сама доска лежит в
2232
+ // колонке разговора, и выход за её пределы наложился бы на чужие части
2233
+ // оболочки.
2234
+ const panel = openTask
2235
+ ? React.createElement('div', {
2236
+ className: 'dkb-modal',
2237
+ onClick: () => setOpenTask(null),
2238
+ }, React.createElement('div', {
2239
+ className: 'dkb-panel',
2240
+ role: 'dialog', 'aria-modal': 'true',
2241
+ onClick: (e) => e.stopPropagation(),
2242
+ },
2243
+ // Шапка: чьё это, что за работа, и лишь потом название. Тот же
2244
+ // порядок, что на карточке, — глаз не должен переучиваться.
2245
+ React.createElement('div', { className: 'dkb-row' },
2246
+ React.createElement('div', { className: 'dkb-taskHead', style: { flex: 1 } },
2247
+ taskRef(openTask, t),
2248
+ (openTask.labels || []).map((l) => React.createElement('span', {
2249
+ className: 'dkb-tag', key: l, style: tagStyle(openTask, l),
2250
+ }, l))),
2251
+ React.createElement('button', {
2252
+ type: 'button', className: 'dkb-discard', onClick: () => setOpenTask(null),
2253
+ }, t('panel.close')),
2254
+ ),
2255
+ React.createElement('div', { className: 'dkb-title' }, openTask.title),
2256
+ React.createElement('div', { className: 'dkb-note' },
2257
+ t('panel.created', { ago: agoText(state.now, openTask.createdAt, t) })
2258
+ // Автор стоит рядом с датой заведения: это одно событие — кто и
2259
+ // когда завёл, — и разносить его по разным углам незачем.
2260
+ + (openTask.author ? ' · ' + t('panel.author', { who: openTask.author }) : ''),
2261
+ ' · ',
2262
+ t('panel.updated', { ago: agoText(state.now, openTask.updatedAt, t) }),
2263
+ openTask.issueUrl
2264
+ ? React.createElement('a', {
2265
+ className: 'dkb-issueLink', href: openTask.issueUrl,
2266
+ target: '_blank', rel: 'noreferrer noopener',
2267
+ }, ' · ' + t('panel.issue') + ' ↗')
2268
+ : null),
2269
+ // Пустое тело — отсутствие раздела, а не пустой раздел с заголовком.
2270
+ body === null
2271
+ ? React.createElement('p', { className: 'dkb-note' }, t('panel.bodyLoading'))
2272
+ : body.length === 0
2273
+ ? null
2274
+ : React.createElement('div', { className: 'dkb-mdBody' }, renderBlocks(body)),
2275
+ openTask.sessionId
2276
+ ? React.createElement('button', {
2277
+ type: 'button', className: 'dkb-discard', style: { alignSelf: 'flex-start' },
2278
+ onClick: () => {
2279
+ // Тот же путь, что и сразу после запуска: задача, идущая
2280
+ // вторые сутки, иначе ищется в общем списке по имени вида
2281
+ // kanban-9e84cae6-…
2282
+ if (!openSession(props.ctx, openTask.sessionId)) {
2283
+ setError(t('error.sessionNotOpened', { id: openTask.sessionId }))
2284
+ return
2285
+ }
2286
+ setOpenTask(null)
2287
+ if (props.onClose) props.onClose()
2288
+ },
2289
+ }, t('panel.openChat'))
2290
+ : null,
2291
+ log.length
2292
+ ? React.createElement('div', null,
2293
+ React.createElement('div', { className: 'dkb-group' }, t('panel.log')),
2294
+ React.createElement('ul', { className: 'dkb-log' }, log.map((row) => React.createElement('li', {
2295
+ className: 'dkb-logRow', key: row.id,
2296
+ }, `${t('column.' + row.toCol)} ← ${row.source}${row.detail ? ' · ' + row.detail : ''}`))))
2297
+ : null,
2298
+ openTask.plan && openTask.plan.items && openTask.plan.items.length
2299
+ ? React.createElement('div', null,
2300
+ React.createElement('div', { className: 'dkb-group' }, t('plan.title')),
2301
+ React.createElement('p', { className: 'dkb-hint' }, planLine(openTask, t)),
2302
+ React.createElement('ul', { className: 'dkb-planList' },
2303
+ openTask.plan.items.map((item, i) => React.createElement('li', {
2304
+ key: i,
2305
+ className: 'dkb-planItem'
2306
+ + (item.done ? ' dkb-planDone' : '')
2307
+ + (item.active ? ' dkb-planActive' : ''),
2308
+ },
2309
+ React.createElement('span', { className: 'dkb-planMark' },
2310
+ item.done ? '✓' : item.active ? '▸' : '·'),
2311
+ React.createElement('span', null, item.text),
2312
+ ))),
2313
+ )
2314
+ : null,
2315
+ openTask.archivedAt
2316
+ ? React.createElement('button', {
2317
+ type: 'button', className: 'dkb-save', style: { alignSelf: 'flex-start' },
2318
+ // Запускать работу по архивной задаче нечего: сперва она
2319
+ // возвращается на доску, и это одно нажатие.
2320
+ onClick: async () => {
2321
+ try {
2322
+ await api('/task/' + encodeURIComponent(openTask.id) + '/restore', { method: 'POST', body: '{}' })
2323
+ setArchive((cur) => (cur || []).filter((x) => x.id !== openTask.id))
2324
+ setOpenTask(null)
2325
+ await load(board)
2326
+ } catch (e) { setError(t(e.key || 'error.unknown')) }
2327
+ },
2328
+ }, t('archive.restore'))
2329
+ : React.createElement('div', { className: 'dkb-start' },
2330
+ React.createElement('div', { className: 'dkb-group' },
2331
+ openTask.sessionId ? t('panel.continue') : t('panel.start')),
2332
+ React.createElement('p', { className: 'dkb-hint' },
2333
+ openTask.sessionId ? t('panel.continueHint') : t('panel.startHint')),
2334
+ // Корень не задан настройкой — сессия пойдёт в рабочей папке
2335
+ // харнесса. Молчать об этом нельзя: человек иначе узнает, где
2336
+ // оказался агент, только из его первых слов. Показываем фактический
2337
+ // корень, который приехал с доской.
2338
+ !openTask.sessionId && state && !state.projectRoot.set && state.projectRoot.path !== ''
2339
+ ? React.createElement('p', { className: 'dkb-hint dkb-workdir' },
2340
+ t('panel.workDir', { path: state.projectRoot.path }),
2341
+ React.createElement('br'),
2342
+ t('panel.workDirHint'))
2343
+ : null,
2344
+ React.createElement(LaunchFields, {
2345
+ t, presets, agentPreset, permission, disabled: starting,
2346
+ onPreset: setAgentPreset, onPermission: setPermission,
2347
+ // У идущей сессии профиль уже выбран: сменить его нельзя, а
2348
+ // показывать поле, которое ничего не меняет, — врать.
2349
+ hidePreset: Boolean(openTask.sessionId),
2350
+ }),
2351
+ React.createElement('div', { className: 'dkb-field' },
2352
+ React.createElement('span', { className: 'dkb-label' }, t('panel.provider')),
2353
+ React.createElement('select', {
2354
+ className: 'dkb-input', value: provider, disabled: starting || providers.length === 0,
2355
+ onChange: (e) => { setProvider(e.target.value); setModel('') },
2356
+ },
2357
+ React.createElement('option', { value: '' },
2358
+ providers.length === 0 ? t('panel.noProviders') : t('panel.pickProvider')),
2359
+ providers.map((p) => React.createElement('option', { value: p.id, key: p.id }, p.name)),
2360
+ ),
2361
+ ),
2362
+ React.createElement('div', { className: 'dkb-field' },
2363
+ React.createElement('span', { className: 'dkb-label' }, t('panel.model')),
2364
+ React.createElement('select', {
2365
+ className: 'dkb-input', value: model,
2366
+ disabled: starting || provider === '' || models.length === 0,
2367
+ onChange: (e) => setModel(e.target.value),
2368
+ },
2369
+ React.createElement('option', { value: '' },
2370
+ provider === '' ? t('panel.pickProviderFirst')
2371
+ : models.length === 0 ? t('panel.noModels') : t('panel.pickModel')),
2372
+ models.map((m) => React.createElement('option', { value: m.id, key: m.id }, m.name)),
2373
+ ),
2374
+ ),
2375
+ React.createElement('div', { className: 'dkb-field' },
2376
+ React.createElement('span', { className: 'dkb-label' }, t('panel.message')),
2377
+ React.createElement('textarea', {
2378
+ className: 'dkb-area', value: draftText, disabled: starting,
2379
+ onChange: (e) => setDraftText(e.target.value),
2380
+ }),
2381
+ React.createElement('p', { className: 'dkb-hint' }, t('panel.messageHint')),
2382
+ ),
2383
+ // Опасное слева, обычное справа, всё в одной полосе. Отдельная строка
2384
+ // под «Удалить» съедала высоту окна ради одной кнопки, которую
2385
+ // нажимают раз в жизни.
2386
+ React.createElement('div', { className: 'dkb-foot dkb-footSplit' },
2387
+ React.createElement('div', { className: 'dkb-footLeft' },
2388
+ React.createElement('button', {
2389
+ type: 'button', className: 'dkb-danger',
2390
+ onClick: async () => {
2391
+ // Подтверждение обязательно: удаление карточки необратимо, а
2392
+ // сама кнопка стоит рядом с безобидными.
2393
+ if (!window.confirm(t('panel.deleteConfirm', { title: openTask.title }))) return
2394
+ try {
2395
+ await api('/task/' + encodeURIComponent(openTask.id), { method: 'DELETE' })
2396
+ setOpenTask(null)
2397
+ await load(board)
2398
+ } catch (e) {
2399
+ setError(t(e.key || 'error.unknown'))
2400
+ }
2401
+ },
2402
+ }, t('panel.delete')),
2403
+ openTask.column === 'done'
2404
+ ? React.createElement('button', {
2405
+ type: 'button', className: 'dkb-discard',
2406
+ onClick: async () => {
2407
+ try {
2408
+ await api('/task/' + encodeURIComponent(openTask.id) + '/archive', { method: 'POST', body: '{}' })
2409
+ setOpenTask(null)
2410
+ await load(board)
2411
+ } catch (e) { setError(t(e.key || 'error.unknown')) }
2412
+ },
2413
+ }, t('panel.archive'))
2414
+ : null,
2415
+
2416
+ ),
2417
+ React.createElement('button', {
2418
+ type: 'button', className: 'dkb-save', disabled: starting || model === '',
2419
+ onClick: async () => {
2420
+ // Кнопка блокируется на время запроса: двойное нажатие подняло
2421
+ // бы две сессии по одной задаче.
2422
+ setStarting(true)
2423
+ try {
2424
+ rememberLaunch(board, { provider, model, agentPreset, permission })
2425
+ const out = await api('/task/' + encodeURIComponent(openTask.id) + '/start', {
2426
+ method: 'POST',
2427
+ body: JSON.stringify({ provider, model, text: draftText, agentPreset, permission }),
2428
+ })
2429
+ setOpenTask(null)
2430
+ await load(board)
2431
+ // Сессия поднята — но пока её не открыли, для человека
2432
+ // «ничего не произошло»: он остаётся на доске и не видит
2433
+ // чата, ради которого нажимал.
2434
+ if (!openSession(props.ctx, out && out.sessionId)) {
2435
+ setError(t('error.sessionNotOpened', { id: (out && out.sessionId) || '' }))
2436
+ return
2437
+ }
2438
+ if (props.onClose) props.onClose()
2439
+ } catch (e) {
2440
+ setError(t(e.key || 'error.unknown'))
2441
+ } finally {
2442
+ setStarting(false)
2443
+ }
2444
+ },
2445
+ }, starting ? t('panel.starting')
2446
+ : openTask.sessionId ? t('panel.continue') : t('panel.start')),
2447
+ ),
2448
+ ),
2449
+ ))
2450
+ : null
2451
+
2452
+ const moveDialogEl = dialog && dialog.mode === 'move'
2453
+ ? React.createElement('div', { className: 'dkb-dialog', onClick: () => setDialog(null) },
2454
+ React.createElement('div', { className: 'dkb-dialogBox', onClick: (e) => e.stopPropagation() },
2455
+ React.createElement('span', { className: 'dkb-title' },
2456
+ t('move.title', { column: t('column.' + dialog.column) })),
2457
+ React.createElement('p', { className: 'dkb-note' },
2458
+ cardRef(dialog.task) ? cardRef(dialog.task) + ' · ' + dialog.task.title : dialog.task.title),
2459
+ React.createElement('p', { className: 'dkb-hint' }, t('move.' + dialog.column)),
2460
+ React.createElement('div', { className: 'dkb-row' },
2461
+ React.createElement('button', {
2462
+ type: 'button', className: 'dkb-discard', onClick: () => setDialog(null),
2463
+ }, t('dialog.cancel')),
2464
+ React.createElement('button', {
2465
+ type: 'button', className: 'dkb-save',
2466
+ onClick: () => {
2467
+ const d = dialog
2468
+ setDialog(null)
2469
+ commitMove(d.task, d.column, d.where)
2470
+ },
2471
+ }, t('move.confirm')),
2472
+ ),
2473
+ ))
2474
+ : null
2475
+
2476
+ // Групповой запуск. Провайдер и модель спрашиваем один раз на всю пачку:
2477
+ // сессия одна, значит и модель одна.
2478
+ const batchEl = dialog && dialog.mode === 'batch'
2479
+ ? React.createElement('div', { className: 'dkb-dialog', onClick: () => setDialog(null) },
2480
+ React.createElement('div', { className: 'dkb-dialogBox', onClick: (e) => e.stopPropagation() },
2481
+ React.createElement('span', { className: 'dkb-title' },
2482
+ t('batch.title', { n: dialog.ids.length })),
2483
+ React.createElement('p', { className: 'dkb-hint' }, t('batch.hint')),
2484
+ dialog.error ? React.createElement('p', { className: 'dkb-failed' }, dialog.error) : null,
2485
+ React.createElement('ul', { className: 'dkb-batchList' },
2486
+ dialog.ids.map((id, at) => {
2487
+ const task = state.tasks.find((x) => x.id === id)
2488
+ return React.createElement('li', { className: 'dkb-batchRow', key: id },
2489
+ React.createElement('span', { className: 'dkb-refNum' }, String(at + 1)),
2490
+ React.createElement('span', null, task ? task.title : id))
2491
+ })),
2492
+ React.createElement('div', { className: 'dkb-field' },
2493
+ React.createElement('span', { className: 'dkb-label' }, t('panel.provider')),
2494
+ React.createElement('select', {
2495
+ className: 'dkb-input', value: provider,
2496
+ onChange: (e) => { setProvider(e.target.value); setModel('') },
2497
+ },
2498
+ React.createElement('option', { value: '' }, t('panel.pickProvider')),
2499
+ providers.map((p) => React.createElement('option', { value: p.id, key: p.id }, p.name)),
2500
+ ),
2501
+ ),
2502
+ React.createElement('div', { className: 'dkb-field' },
2503
+ React.createElement('span', { className: 'dkb-label' }, t('panel.model')),
2504
+ React.createElement('select', {
2505
+ className: 'dkb-input', value: model, disabled: provider === '',
2506
+ onChange: (e) => setModel(e.target.value),
2507
+ },
2508
+ React.createElement('option', { value: '' }, t('panel.pickModel')),
2509
+ models.map((m) => React.createElement('option', { value: m.id, key: m.id }, m.name)),
2510
+ ),
2511
+ ),
2512
+ React.createElement(LaunchFields, {
2513
+ t, presets, agentPreset, permission,
2514
+ onPreset: setAgentPreset, onPermission: setPermission,
2515
+ }),
2516
+ React.createElement('div', { className: 'dkb-row' },
2517
+ React.createElement('button', {
2518
+ type: 'button', className: 'dkb-discard', onClick: () => setDialog(null),
2519
+ }, t('dialog.cancel')),
2520
+ React.createElement('button', {
2521
+ type: 'button', className: 'dkb-save', disabled: model === '',
2522
+ onClick: async () => {
2523
+ try {
2524
+ rememberLaunch(board, { provider, model, agentPreset, permission })
2525
+ const out = await api('/batch', {
2526
+ method: 'POST',
2527
+ body: JSON.stringify({ ids: dialog.ids, provider, model, agentPreset, permission }),
2528
+ })
2529
+ setDialog(null)
2530
+ setPicked({})
2531
+ await load(board)
2532
+ // Сессия поднята — но пока её не открыли, для человека
2533
+ // «ничего не произошло».
2534
+ if (!openSession(props.ctx, out && out.sessionId)) {
2535
+ setError(t('error.sessionNotOpened', { id: (out && out.sessionId) || '' }))
2536
+ return
2537
+ }
2538
+ if (props.onClose) props.onClose()
2539
+ } catch (e) {
2540
+ setDialog(Object.assign({}, dialog, { error: t(e.key || 'error.unknown') }))
2541
+ }
2542
+ },
2543
+ }, t('batch.start')),
2544
+ ),
2545
+ ))
2546
+ : null
2547
+
2548
+ const queueEl = dialog && dialog.mode === 'queue'
2549
+ ? React.createElement('div', { className: 'dkb-dialog', onClick: () => setDialog(null) },
2550
+ React.createElement('div', { className: 'dkb-dialogBox', onClick: (e) => e.stopPropagation() },
2551
+ React.createElement('span', { className: 'dkb-title' }, t('queue.title')),
2552
+ React.createElement('p', { className: 'dkb-hint' }, t('queue.hint')),
2553
+ dialog.error ? React.createElement('p', { className: 'dkb-failed' }, dialog.error) : null,
2554
+ React.createElement('div', { className: 'dkb-field' },
2555
+ React.createElement('span', { className: 'dkb-label' }, t('queue.session')),
2556
+ React.createElement('select', {
2557
+ className: 'dkb-input', value: dialog.sessionId,
2558
+ onChange: (e) => setDialog(Object.assign({}, dialog, { sessionId: e.target.value })),
2559
+ }, dialog.sessions.map((one) => React.createElement('option', {
2560
+ value: one.sessionId, key: one.sessionId,
2561
+ }, t('queue.option', {
2562
+ n: one.tasks.length,
2563
+ title: (one.tasks[0] && one.tasks[0].title) || one.sessionId,
2564
+ })))),
2565
+ ),
2566
+ React.createElement('div', { className: 'dkb-row' },
2567
+ React.createElement('button', {
2568
+ type: 'button', className: 'dkb-discard', onClick: () => setDialog(null),
2569
+ }, t('dialog.cancel')),
2570
+ React.createElement('button', {
2571
+ type: 'button', className: 'dkb-save',
2572
+ onClick: async () => {
2573
+ try {
2574
+ await api('/task/' + encodeURIComponent(dialog.task.id) + '/queue', {
2575
+ method: 'POST',
2576
+ body: JSON.stringify({ sessionId: dialog.sessionId }),
2577
+ })
2578
+ setDialog(null)
2579
+ await load(board)
2580
+ } catch (e) {
2581
+ setDialog(Object.assign({}, dialog, { error: t(e.key || 'error.unknown') }))
2582
+ }
2583
+ },
2584
+ }, t('queue.add')),
2585
+ ),
2586
+ ))
2587
+ : null
2588
+
2589
+ const dialogEl = dialog && dialog.mode !== 'move' && dialog.mode !== 'batch' && dialog.mode !== 'queue'
2590
+ ? React.createElement('div', { className: 'dkb-dialog', onClick: () => setDialog(null) },
2591
+ React.createElement('div', { className: 'dkb-dialogBox', onClick: (e) => e.stopPropagation() },
2592
+ React.createElement('span', { className: 'dkb-title' }, t('dialog.title')),
2593
+ React.createElement('div', { className: 'dkb-row' },
2594
+ React.createElement('button', {
2595
+ type: 'button', className: dialog.mode === 'own' ? 'dkb-save' : 'dkb-discard',
2596
+ onClick: () => setDialog(Object.assign({}, dialog, { mode: 'own', error: '' })),
2597
+ }, t('dialog.own')),
2598
+ // Простая доска — для задач без issue, и предлагать на ней импорт
2599
+ // значит звать туда, откуда сервер вернёт отказ.
2600
+ state.kind === 'simple' ? null : React.createElement('button', {
2601
+ type: 'button', className: dialog.mode === 'gitea' ? 'dkb-save' : 'dkb-discard',
2602
+ onClick: async () => {
2603
+ setDialog(Object.assign({}, dialog, { mode: 'gitea', error: '' }))
2604
+ if (dialog.repos !== null) return
2605
+ try {
2606
+ const out = await api('/gitea/repos?q=')
2607
+ setDialog((d) => Object.assign({}, d, { mode: 'gitea', repos: out.repos || [], error: '' }))
2608
+ } catch (e) {
2609
+ setDialog((d) => Object.assign({}, d, { mode: 'gitea', repos: [], error: t(e.key || 'error.unknown') }))
2610
+ }
2611
+ },
2612
+ }, t('dialog.fromGitea')),
2613
+ ),
2614
+ dialog.error ? React.createElement('p', { className: 'dkb-failed' }, dialog.error) : null,
2615
+ dialog.mode === 'own'
2616
+ ? React.createElement('div', null,
2617
+ // Проект или нет — это выбор доски, и он же выбор судьбы
2618
+ // задачи: у проектной появляется issue, у остальной нет.
2619
+ // Правило без исключений: есть issue — проектная доска.
2620
+ React.createElement('div', { className: 'dkb-field' },
2621
+ React.createElement('span', { className: 'dkb-label' }, t('dialog.kind')),
2622
+ React.createElement('div', { className: 'dkb-row' },
2623
+ React.createElement('button', {
2624
+ type: 'button', className: dialog.project ? 'dkb-save' : 'dkb-discard',
2625
+ onClick: () => setDialog(Object.assign({}, dialog, { project: true, error: '' })),
2626
+ }, t('dialog.kindProject')),
2627
+ React.createElement('button', {
2628
+ type: 'button', className: dialog.project ? 'dkb-discard' : 'dkb-save',
2629
+ onClick: () => setDialog(Object.assign({}, dialog, { project: false, error: '' })),
2630
+ }, t('dialog.kindPlain')),
2631
+ ),
2632
+ React.createElement('p', { className: 'dkb-hint' },
2633
+ t(dialog.project ? 'dialog.kindProjectHint' : 'dialog.kindPlainHint')),
2634
+ ),
2635
+ dialog.project
2636
+ ? React.createElement('div', { className: 'dkb-field' },
2637
+ React.createElement('span', { className: 'dkb-label' }, t('dialog.repo')),
2638
+ React.createElement('select', {
2639
+ className: 'dkb-input', value: dialog.repo || '',
2640
+ onChange: (e) => setDialog(Object.assign({}, dialog, { repo: e.target.value })),
2641
+ },
2642
+ React.createElement('option', { value: '' }, t('dialog.repoNew')),
2643
+ (state.facets || [])
2644
+ .filter((f) => f.ns === REPO_FACET)
2645
+ .flatMap((f) => f.values)
2646
+ .map((v) => React.createElement('option', { value: v.value, key: v.value }, v.value)),
2647
+ ),
2648
+ (dialog.repo || '') === ''
2649
+ ? React.createElement('div', null,
2650
+ React.createElement('input', {
2651
+ className: 'dkb-input', value: dialog.newRepo || '',
2652
+ placeholder: t('dialog.repoNamePlaceholder'),
2653
+ onChange: (e) => setDialog(Object.assign({}, dialog, { newRepo: e.target.value })),
2654
+ }),
2655
+ // Создание репозитория необратимо в один клик, и
2656
+ // человек должен видеть это ДО нажатия, а не после.
2657
+ React.createElement('p', { className: 'dkb-hint' }, t('dialog.repoNewHint')),
2658
+ )
2659
+ : null,
2660
+ )
2661
+ : null,
2662
+ React.createElement('div', { className: 'dkb-field' },
2663
+ React.createElement('span', { className: 'dkb-label' }, t('dialog.taskTitle')),
2664
+ React.createElement('input', {
2665
+ className: 'dkb-input', value: dialog.title, autoFocus: true,
2666
+ onChange: (e) => setDialog(Object.assign({}, dialog, { title: e.target.value })),
2667
+ }),
2668
+ ),
2669
+ React.createElement('div', { className: 'dkb-field' },
2670
+ React.createElement('span', { className: 'dkb-label' }, t('dialog.taskBody')),
2671
+ React.createElement('textarea', {
2672
+ className: 'dkb-area', value: dialog.body || '',
2673
+ onChange: (e) => setDialog(Object.assign({}, dialog, { body: e.target.value })),
2674
+ }),
2675
+ React.createElement('p', { className: 'dkb-hint' }, t('dialog.bodyHint')),
2676
+ ),
2677
+ React.createElement('div', { className: 'dkb-field' },
2678
+ React.createElement('span', { className: 'dkb-label' }, t('dialog.taskLabels')),
2679
+ React.createElement('input', {
2680
+ className: 'dkb-input', value: dialog.labels || '',
2681
+ placeholder: t('dialog.labelsHint'),
2682
+ onChange: (e) => setDialog(Object.assign({}, dialog, { labels: e.target.value })),
2683
+ }),
2684
+ ),
2685
+ React.createElement('div', { className: 'dkb-field' },
2686
+ React.createElement('span', { className: 'dkb-label' }, t('dialog.taskColumn')),
2687
+ React.createElement('select', {
2688
+ className: 'dkb-input', value: dialog.column || 'backlog',
2689
+ onChange: (e) => setDialog(Object.assign({}, dialog, { column: e.target.value })),
2690
+ }, (state.columns || []).map((c) => React.createElement('option', { value: c.id, key: c.id }, t('column.' + c.id)))),
2691
+ ),
2692
+ React.createElement('div', { className: 'dkb-foot' },
2693
+ React.createElement('button', { type: 'button', className: 'dkb-discard', onClick: () => setDialog(null) }, t('dialog.cancel')),
2694
+ React.createElement('button', {
2695
+ type: 'button', className: 'dkb-save', disabled: String(dialog.title || '').trim() === '',
2696
+ onClick: async () => {
2697
+ try {
2698
+ if (dialog.project) {
2699
+ // Проектная задача заводит issue и привязывается к
2700
+ // нему; метки ей ставит Gitea, а не мы.
2701
+ await api('/project-task', {
2702
+ method: 'POST',
2703
+ body: JSON.stringify({
2704
+ repo: dialog.repo || '',
2705
+ newRepo: dialog.newRepo || '',
2706
+ title: dialog.title,
2707
+ body: dialog.body || '',
2708
+ board: 'main',
2709
+ }),
2710
+ })
2711
+ } else {
2712
+ await api('/task', {
2713
+ method: 'POST',
2714
+ body: JSON.stringify({
2715
+ title: dialog.title,
2716
+ body: dialog.body || '',
2717
+ // Метки вводятся через запятую: отдельный редактор
2718
+ // меток ради своей задачи — лишняя мебель.
2719
+ labels: String(dialog.labels || '').split(',').map((x) => x.trim()).filter(Boolean),
2720
+ column: dialog.column || 'backlog',
2721
+ // Задача без issue живёт на простой доске: правило
2722
+ // без исключений, и помнить, где её завели, не надо.
2723
+ board: 'simple',
2724
+ }),
2725
+ })
2726
+ }
2727
+ setDialog(null)
2728
+ await load(board)
2729
+ } catch (e) {
2730
+ setDialog(Object.assign({}, dialog, { error: t(e.key || 'error.unknown') }))
2731
+ }
2732
+ },
2733
+ }, t('dialog.create')),
2734
+ ),
2735
+ )
2736
+ : React.createElement('div', null,
2737
+ React.createElement('div', { className: 'dkb-row' },
2738
+ React.createElement('select', {
2739
+ className: 'dkb-input', value: dialog.full,
2740
+ disabled: dialog.repos === null || dialog.repos.length === 0,
2741
+ onChange: async (e) => {
2742
+ const full = e.target.value
2743
+ setDialog((d) => Object.assign({}, d, { full, issues: null, error: '' }))
2744
+ const pair = splitFullName(full)
2745
+ if (pair === undefined) return
2746
+ try {
2747
+ const out = await api('/gitea/issues?owner=' + encodeURIComponent(pair.owner) +
2748
+ '&repo=' + encodeURIComponent(pair.repo))
2749
+ setDialog((d) => Object.assign({}, d, { issues: out.issues || [], error: '' }))
2750
+ } catch (err) {
2751
+ setDialog((d) => Object.assign({}, d, { issues: [], error: t(err.key || 'error.unknown') }))
2752
+ }
2753
+ },
2754
+ },
2755
+ React.createElement('option', { value: '' },
2756
+ dialog.repos === null ? t('dialog.loadingRepos')
2757
+ : dialog.repos.length === 0 ? t('dialog.noRepos') : t('dialog.pickRepo')),
2758
+ (dialog.repos || []).map((r) => {
2759
+ const full = r.fullName || (r.owner + '/' + r.repo)
2760
+ return React.createElement('option', { value: full, key: full }, repoOption(r, t))
2761
+ }),
2762
+ ),
2763
+ ),
2764
+ dialog.issues === null ? null
2765
+ : dialog.issues.length === 0
2766
+ ? React.createElement('p', { className: 'dkb-note' }, t('dialog.noIssues'))
2767
+ : React.createElement('div', { className: 'dkb-list' }, dialog.issues.map((issue) =>
2768
+ React.createElement('button', {
2769
+ type: 'button',
2770
+ className: 'dkb-issue' + (issue.imported ? ' dkb-issueUsed' : ''),
2771
+ key: issue.number,
2772
+ onClick: async () => {
2773
+ const pair = splitFullName(dialog.full)
2774
+ if (pair === undefined) return
2775
+ try {
2776
+ await api('/import', {
2777
+ method: 'POST',
2778
+ body: JSON.stringify({ owner: pair.owner, repo: pair.repo, issueNumber: issue.number, board }),
2779
+ })
2780
+ setDialog(null)
2781
+ await load(board)
2782
+ } catch (e) {
2783
+ setDialog(Object.assign({}, dialog, { error: t(e.key || 'error.unknown') }))
2784
+ }
2785
+ },
2786
+ }, `#${issue.number} ${issue.title}${issue.imported ? ' · ' + t('dialog.imported') : ''}`))),
2787
+ ),
2788
+ ))
2789
+ : null
2790
+
2791
+ // Архив — свой экран, а не колонка: колонка это стадия работы, а архив
2792
+ // стадией не является. Возврат ставит карточку ровно туда, где она была.
2793
+ const archiveEl = archive
2794
+ ? React.createElement('div', { className: 'dkb-archive' },
2795
+ React.createElement('p', { className: 'dkb-hint' }, t('archive.hint')),
2796
+ archive.length === 0
2797
+ ? React.createElement('p', { className: 'dkb-note' }, t('archive.empty'))
2798
+ : React.createElement('ul', { className: 'dkb-archiveList' },
2799
+ archive.map((task) => React.createElement('li', { className: 'dkb-archiveRow', key: task.id },
2800
+ React.createElement('button', {
2801
+ type: 'button', className: 'dkb-archiveTitle dkb-archiveOpen',
2802
+ // Архив — не братская могила: задачу туда убрали, а не
2803
+ // стёрли, и прочитать её должно быть можно, не возвращая.
2804
+ onClick: () => setOpenTask(task),
2805
+ },
2806
+ [cardRef(task), task.title].filter(Boolean).join(' · ')),
2807
+ React.createElement('span', { className: 'dkb-taskRef', title: exactAt(task.archivedAt) },
2808
+ t('archive.since', { ago: agoText(state.now, task.archivedAt, t) })),
2809
+ React.createElement('button', {
2810
+ type: 'button', className: 'dkb-discard',
2811
+ onClick: async () => {
2812
+ try {
2813
+ await api('/task/' + encodeURIComponent(task.id) + '/restore', { method: 'POST', body: '{}' })
2814
+ setArchive((cur) => (cur || []).filter((x) => x.id !== task.id))
2815
+ await load(board)
2816
+ } catch (e) { setError(t(e.key || 'error.unknown')) }
2817
+ },
2818
+ }, t('archive.restore')),
2819
+ ))),
2820
+ )
2821
+ : null
2822
+
2823
+ return React.createElement('div', { className: 'dkb-screen' },
2824
+ style, bar,
2825
+ error ? React.createElement('p', { className: 'dkb-failed' }, error) : null,
2826
+ archive ? archiveEl : columns,
2827
+ panel, dialogEl, moveDialogEl, batchEl, queueEl,
2828
+ )
2829
+ }
2830
+
2831
+ /**
2832
+ * Чип задачи в шапке чата. Большинство сессий подняты не с доски, и это
2833
+ * штатное положение: тогда чип не рисуется вовсе. Пока ответ не пришёл,
2834
+ * тоже ничего — мигающая заглушка в шапке хуже её отсутствия.
2835
+ */
2836
+ function TaskChip(props) {
2837
+ const t = props.t || fallbackT
2838
+ const [task, setTask] = React.useState(null)
2839
+ const [columns, setColumns] = React.useState(COLUMN_ORDER)
2840
+ const [note, setNote] = React.useState('')
2841
+ const [saving, setSaving] = React.useState(false)
2842
+ const [open, setOpen] = React.useState(false)
2843
+ const session = props.useSession ? props.useSession((s) => s) : null
2844
+ const sessionId = chipSessionId(props, session)
2845
+
2846
+ React.useEffect(() => {
2847
+ let alive = true
2848
+ if (!sessionId) { setTask(null); return () => {} }
2849
+ api('/session/' + encodeURIComponent(sessionId) + '/task')
2850
+ .then((out) => {
2851
+ if (!alive) return
2852
+ setTask((out && out.task) || null)
2853
+ setColumns((out && out.columns) || COLUMN_ORDER)
2854
+ })
2855
+ .catch(() => { if (alive) setTask(null) })
2856
+ return () => { alive = false }
2857
+ }, [sessionId])
2858
+
2859
+ const move = React.useCallback(async (column) => {
2860
+ if (!task) return
2861
+ try {
2862
+ const out = await api('/task/' + encodeURIComponent(task.id) + '/move', {
2863
+ method: 'POST', body: JSON.stringify({ column }),
2864
+ })
2865
+ setTask(out.task)
2866
+ setOpen(false)
2867
+ } catch { /* причину показывает доска; шапку не ломаем */ }
2868
+ }, [task])
2869
+
2870
+ if (!task) return null
2871
+
2872
+ const label = [cardRef(task) || task.title, t('column.' + task.column)].filter(Boolean).join(' · ')
2873
+ return React.createElement('span', { className: 'dkb-chipWrap' },
2874
+ React.createElement('style', null, css),
2875
+ React.createElement('button', {
2876
+ type: 'button', className: 'dkb-chip', title: task.title,
2877
+ onClick: () => setOpen((v) => !v),
2878
+ }, '▦ ' + label),
2879
+ open ? React.createElement('div', { className: 'dkb-chipPanel' },
2880
+ React.createElement('span', { className: 'dkb-hint' }, t('chip.moveTo')),
2881
+ columns.map((id) => React.createElement('button', {
2882
+ type: 'button', key: id,
2883
+ className: id === task.column ? 'dkb-save' : 'dkb-discard',
2884
+ onClick: () => move(id),
2885
+ }, t('column.' + id))),
2886
+ props.toggle
2887
+ ? React.createElement('button', {
2888
+ type: 'button', className: 'dkb-discard',
2889
+ onClick: () => { setOpen(false); props.toggle.show(task.id, task.board) },
2890
+ }, t('chip.openCard'))
2891
+ : null,
2892
+ React.createElement('span', { className: 'dkb-hint' }, t('chip.note')),
2893
+ React.createElement('textarea', {
2894
+ className: 'dkb-area', value: note, disabled: saving,
2895
+ onChange: (e) => setNote(e.target.value),
2896
+ }),
2897
+ // Тело уходит агенту первым сообщением при запуске: заметка до
2898
+ // запуска попадёт в работу, после — нет. Молчать об этом значит
2899
+ // сделать поведение случайным на вид.
2900
+ React.createElement('span', { className: 'dkb-hint' }, t('chip.noteHint')),
2901
+ React.createElement('button', {
2902
+ type: 'button', className: 'dkb-save',
2903
+ disabled: saving || note.trim() === '',
2904
+ onClick: async () => {
2905
+ setSaving(true)
2906
+ try {
2907
+ const out = await api('/task/' + encodeURIComponent(task.id) + '/note', {
2908
+ method: 'POST', body: JSON.stringify({ text: note }),
2909
+ })
2910
+ setTask(out.task)
2911
+ setNote('')
2912
+ setOpen(false)
2913
+ } catch { /* причину показывает доска; шапку не ломаем */ } finally {
2914
+ setSaving(false)
2915
+ }
2916
+ },
2917
+ }, t('chip.noteSave')),
2918
+ ) : null,
2919
+ )
2920
+ }
2921
+
2922
+
2923
+ // ------------------------------------------------- встраивание в оболочку
2924
+ //
2925
+ // Раздела верхнего уровня в этой сборке нет: слоты `app.section` и
2926
+ // `sidebar.section` отсутствуют, а `settings.section` уводит доску в
2927
+ // настройки, где ей не место. Поэтому строка и экран вставляются прямо в
2928
+ // вёрстку оболочки — тем же приёмом, каким это делает штатный task board.
2929
+ //
2930
+ // Приём хрупкий по своей природе: он опирается на чужую вёрстку. Смягчение
2931
+ // — селекторы по подстроке класса, наблюдатели за перерисовкой React и
2932
+ // мягкий отказ: не нашли корень — просто ничего не показали, ничего не
2933
+ // сломали.
2934
+
2935
+ const PANEL = 'kanban'
2936
+ const ACTIVATE_EVENT = 'dsh-panel-activate'
2937
+ const ACTIVE_ATTR = 'data-dsh-kanban-active'
2938
+ const ENTRY_ATTR = 'data-dsh-kanban-entry'
2939
+ const VIEW_ATTR = 'data-dsh-kanban-view'
2940
+ const COLUMN_SELECTOR = '[data-pane=conversation], [class*=centerCol]'
2941
+ const SESSION_ROW_SELECTOR = '[class*="sessionRow"], [class*="projectRow"], [class*="searchResultRow"], [class*="newSession"]'
2942
+
2943
+ const ICON = '<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor"'
2944
+ + ' stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">'
2945
+ + '<rect x="2" y="2.5" width="12" height="11" rx="1.5"/><path d="M2 6.5h12M6.5 6.5v7"/></svg>'
2946
+
2947
+ /** Открыта ли доска. Крошечное хранилище: подписки нужны и строке, и экрану. */
2948
+ /** @see helpers.createToggle */
2949
+ function createToggle() {
2950
+ let open = false
2951
+ // Задача, которую просили показать при открытии. Состояние окна живёт
2952
+ // внутри экрана доски, и снаружи до него иначе не дотянуться.
2953
+ let wanted = undefined
2954
+ const listeners = new Set()
2955
+ return {
2956
+ isOpen: () => open,
2957
+ set(next) {
2958
+ if (open === next) return
2959
+ open = next
2960
+ for (const fn of listeners) fn()
2961
+ },
2962
+ toggle() { this.set(!open) },
2963
+ /** Открыть доску на конкретной задаче. */
2964
+ show(taskId, boardId) {
2965
+ // Доска едет вместе с задачей: карточка может лежать на простой, а
2966
+ // открытой быть проектная, и тогда искать её было бы негде.
2967
+ wanted = { id: String(taskId || ''), board: String(boardId || 'main') }
2968
+ if (open) { for (const fn of listeners) fn() } else this.set(true)
2969
+ },
2970
+ /** Забрать просьбу. Читается один раз: повтор открывал бы окно снова. */
2971
+ takeWanted() {
2972
+ const asked = wanted
2973
+ wanted = undefined
2974
+ return asked
2975
+ },
2976
+ subscribe(fn) { listeners.add(fn); return () => listeners.delete(fn) },
2977
+ }
2978
+ }
2979
+
2980
+ /**
2981
+ * Родная кнопка «Новая сессия».
2982
+ *
2983
+ * Отсеиваем узлы, у которых в классах есть `newSessionLabel`: это подпись
2984
+ * внутри кнопки, а не сама кнопка.
2985
+ */
2986
+ function nativeButton() {
2987
+ const nodes = document.querySelectorAll('button[class*="newSession"]')
2988
+ for (const node of nodes) {
2989
+ if (String(node.className).indexOf('newSessionLabel') === -1) return node
2990
+ }
2991
+ return undefined
2992
+ }
2993
+
2994
+ /**
2995
+ * Кнопка доски: КЛОН родной кнопки со своими подписью и значком.
2996
+ *
2997
+ * Классы оболочки берутся у самой оболочки, а не переписываются у себя:
2998
+ * повторять чужие стили руками — значит расходиться с ними при первом же
2999
+ * обновлении интерфейса. Соседний плагин «Мастерская» поступает так же.
3000
+ */
3001
+ function createEntry(target, toggle, label) {
3002
+ const entry = target.cloneNode(true)
3003
+ entry.className = String(target.className) + ' dkb-entry-clone'
3004
+ entry.setAttribute(ENTRY_ATTR, '')
3005
+ entry.setAttribute('data-dsh-plugin', 'kanban')
3006
+ entry.setAttribute('data-dsh-part', 'sidebar-entry')
3007
+ entry.setAttribute('aria-label', label)
3008
+ entry.removeAttribute('id')
3009
+ entry.innerHTML = ''
3010
+
3011
+ const icon = document.createElement('span')
3012
+ icon.className = 'dkb-entryIcon'
3013
+ icon.innerHTML = ICON
3014
+ entry.appendChild(icon)
3015
+
3016
+ const text = document.createElement('span')
3017
+ text.className = 'dkb-entryLabel'
3018
+ text.textContent = label
3019
+ entry.appendChild(text)
3020
+
3021
+ entry.addEventListener('click', (event) => {
3022
+ event.preventDefault()
3023
+ event.stopPropagation()
3024
+ toggle.toggle()
3025
+ })
3026
+ return entry
3027
+ }
3028
+
3029
+ /** Встать последней среди уже вставленных чужих кнопок, а не влезать между. */
3030
+ function placeEntry(target, entry) {
3031
+ let anchor = target
3032
+ let next = target.nextElementSibling
3033
+ while (next !== null && next.tagName === 'BUTTON'
3034
+ && String(next.className).indexOf('sidebar-entry') >= 0) {
3035
+ anchor = next
3036
+ next = next.nextElementSibling
3037
+ }
3038
+ anchor.insertAdjacentElement('afterend', entry)
3039
+ return true
3040
+ }
3041
+
3042
+ /** Кнопка в боковой панели, переживающая перерисовку оболочки. */
3043
+ function mountSidebarEntry(toggle, label) {
3044
+ if (typeof document === 'undefined') return () => {}
3045
+
3046
+ let entry
3047
+ const tryPlace = () => {
3048
+ if (entry !== undefined && entry.isConnected) return
3049
+ const target = nativeButton()
3050
+ if (target === undefined || target.parentElement === null) return
3051
+ if (target.parentElement.querySelector('[' + ENTRY_ATTR + ']') !== null) return
3052
+ entry = createEntry(target, toggle, label)
3053
+ placeEntry(target, entry)
3054
+ syncActive()
3055
+ }
3056
+
3057
+ const syncActive = () => {
3058
+ if (entry === undefined) return
3059
+ if (toggle.isOpen()) entry.dataset.active = 'true'
3060
+ else delete entry.dataset.active
3061
+ }
3062
+
3063
+ // Оболочка перерисовывается сама по себе: без наблюдателя кнопка исчезнет
3064
+ // при первом же обновлении списка сессий.
3065
+ const observer = new MutationObserver(tryPlace)
3066
+ observer.observe(document.body, { childList: true, subtree: true })
3067
+ const unsubscribe = toggle.subscribe(syncActive)
3068
+ tryPlace()
3069
+
3070
+ return () => {
3071
+ observer.disconnect()
3072
+ unsubscribe()
3073
+ if (entry !== undefined) entry.remove()
3074
+ entry = undefined
3075
+ }
3076
+ }
3077
+
3078
+ /** Экран доски поверх колонки разговора. */
3079
+ function mountBoard(ctx, toggle) {
3080
+ if (typeof document === 'undefined') return () => {}
3081
+ let root
3082
+ let container
3083
+
3084
+ const ensure = () => {
3085
+ if (container !== undefined) return
3086
+ const column = document.querySelector(COLUMN_SELECTOR)
3087
+ if (column === null) return
3088
+ container = document.createElement('div')
3089
+ container.setAttribute(VIEW_ATTR, '')
3090
+ container.setAttribute('data-dsh-plugin', 'kanban')
3091
+ column.appendChild(container)
3092
+ root = ReactDOM.createRoot(container)
3093
+ root.render(React.createElement(BoardScreen, { ctx, toggle, onClose: () => toggle.set(false) }))
3094
+ }
3095
+
3096
+ const applyActive = () => {
3097
+ if (toggle.isOpen()) {
3098
+ document.documentElement.setAttribute(ACTIVE_ATTR, '')
3099
+ document.dispatchEvent(new CustomEvent(ACTIVATE_EVENT, { detail: PANEL }))
3100
+ } else {
3101
+ document.documentElement.removeAttribute(ACTIVE_ATTR)
3102
+ }
3103
+ }
3104
+
3105
+ // Соседние панели объявляют себя тем же событием: две панели поверх одной
3106
+ // колонки одновременно — это каша, поэтому уступаем.
3107
+ const onOtherActivate = (event) => {
3108
+ if (event.detail !== PANEL && toggle.isOpen()) toggle.set(false)
3109
+ }
3110
+ const onSidebarClick = (event) => {
3111
+ if (!toggle.isOpen()) return
3112
+ const target = event.target
3113
+ if (target !== null && target.closest && target.closest(SESSION_ROW_SELECTOR) !== null) toggle.set(false)
3114
+ }
3115
+
3116
+ const waitObserver = new MutationObserver(ensure)
3117
+ waitObserver.observe(document.body, { childList: true, subtree: true })
3118
+ document.addEventListener(ACTIVATE_EVENT, onOtherActivate)
3119
+ document.addEventListener('click', onSidebarClick, true)
3120
+ const unsubscribe = toggle.subscribe(applyActive)
3121
+ applyActive()
3122
+ ensure()
3123
+
3124
+ return () => {
3125
+ waitObserver.disconnect()
3126
+ document.removeEventListener(ACTIVATE_EVENT, onOtherActivate)
3127
+ document.removeEventListener('click', onSidebarClick, true)
3128
+ unsubscribe()
3129
+ document.documentElement.removeAttribute(ACTIVE_ATTR)
3130
+ if (root !== undefined) root.unmount()
3131
+ root = undefined
3132
+ if (container !== undefined) container.remove()
3133
+ container = undefined
3134
+ }
3135
+ }
3136
+
3137
+ // ------------------------------------------------------------ регистрация
3138
+
3139
+ /**
3140
+ * Зарегистрировать компонент в первый слот, который его примет.
3141
+ *
3142
+ * Имя слота в сборке подтвердить нельзя: строки слотов в собранном
3143
+ * интерфейсе не литеральны. Регистрация в несуществующий слот проходит
3144
+ * молча и без ошибки в журнале, поэтому единственный надёжный способ —
3145
+ * перебрать кандидатов и запомнить, какой сработал. Соседний плагин
3146
+ * dsh-gitea поступает так же со своей карточкой настроек.
3147
+ *
3148
+ * @returns {string|undefined} имя сработавшего слота
3149
+ */
3150
+ function registerFirst(ctx, candidates, component) {
3151
+ for (const entry of candidates) {
3152
+ try {
3153
+ let ok = false
3154
+ ctx.slots.inject(entry.name, () => {
3155
+ ctx.slots.register(entry, component)
3156
+ ok = true
3157
+ })
3158
+ if (ok) return entry.name
3159
+ } catch { /* слота нет в этой сборке — пробуем следующий */ }
3160
+ }
3161
+ return undefined
3162
+ }
3163
+
3164
+ helpers.createToggle = createToggle
3165
+
3166
+ function apply(ctx) {
3167
+ ctx.effect(() => ctx.locale.register(NS, { en, zh }), 'dsh-kanban: словари')
3168
+
3169
+ // Карточка настроек. Ключ обязан совпадать с пространством настроек:
3170
+ // вкладка перебирает объявленные пространства и рисует слот с entryKey,
3171
+ // равным имени пространства.
3172
+ const cardSlot = registerFirst(ctx, [
3173
+ { name: 'settings.plugin.item', key: NS, locale: NS, inject: () => ({ ctx }) },
3174
+ { name: 'settings.section', id: '@goodandready/dsh-kanban', order: 32, locale: NS, label: () => fallbackT('title'), inject: () => ({ ctx }) },
3175
+ ], KanbanSettingsCard)
3176
+
3177
+ // Экран доски встраивается в оболочку, а не в слот: раздела верхнего
3178
+ // уровня в сборке нет, а настройки — не место для доски.
3179
+ const toggle = createToggle()
3180
+ ctx.effect(() => {
3181
+ const off = [mountSidebarEntry(toggle, fallbackT('section.label')), mountBoard(ctx, toggle)]
3182
+ return () => { for (const dispose of off) dispose() }
3183
+ }, 'dsh-kanban: доска в оболочке')
3184
+
3185
+ // Чип задачи в шапке чата. Слот подтверждён живым плагином dsh-gitea.
3186
+ const chipSlot = registerFirst(ctx, [
3187
+ { name: 'conversation.session.header.utilities', id: '@goodandready/dsh-kanban.chip', order: 26, locale: NS, inject: () => ({ ctx, toggle }) },
3188
+ ], TaskChip)
3189
+
3190
+ // Куда встали — видно снаружи: живая проверка сверяет это с интерфейсом.
3191
+ exports.slots = { card: cardSlot, chip: chipSlot }
3192
+ }
3193
+
3194
+ module.exports = { apply, inject: ['slots', 'locale', 'settingsScope'], helpers }
3195
+ return module.exports
3196
+ },
3197
+ })