Andrey Epifancev 6327c7cf57 Спека: рулинг R-19 (#124)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 01:44:44 +04:00

214 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

## Context
25.10.2026 пять сабагентов сверили открытые follow-up issue хаба и агентов с main (hub 1405ccd). Каждый маркер получил
статус с доказательством `file:line`. Итоги записаны комментариями в issue. Владелец выбрал первым пакет A — видимые
пользователю ошибки веба (sdlc-hub#124). Brainstorming-вопросы владелец пропустил («делай сам… не хочу с ними
сталкиваться в будущем»), поэтому все решения ниже приняты агентом. Они перечислены в «Decisions made without
discussion».
Состав пакета: #42 M-1, M-2, M-4, M-5; #116; #103 С6 и метки `kind-*`; #56 M-4, M-5, M-6; #35 T4.1/M-15; #60 M-3;
#64 M-6, T9.1; #86 T6.2, T6.3, M-4; #68 T3.3. Ссылки на строки ниже — по main 1405ccd.
## Goals / Non-Goals
Цель — убрать перечисленные ошибки. Каждую закрепить тестом, который падает до правки: юнит или поведение компонента в
vitest, для окна карточки дополнительно e2e. Сервер, API, MCP и агенты не меняются.
Не цели:
- прочие маркеры исходных issue: тесты, стиль, доступность UI-кита (#68), квадратичный разбор (#56 M-2), фокус окна
(#42 M-3), `100vh` окна (#42 M-6);
- новые возможности.
## Decisions
### Окно карточки
1. **Поиск списка не закрывает окно (#42 M-1).** Сейчас `IssuesPage.tsx:31-38` через 300 мс после ввода делает
`setParams(..., {replace: true})`. Если за это время открыто окно карточки, запись заменяет адрес окна адресом списка,
и окно закрывается.
Решение — перед открытием карточки со страницы «Задачи» (строка, ключ) несохранённый поиск записывается в адрес сразу,
таймер снимается. Окно открывается поверх списка уже с этим поиском. Отложенная запись MUST NOT срабатывать, пока
открыто окно.
Реализация на усмотрение исполнителя, например:
- `IssuesPage` даёт таблице свою обёртку `open`, которая сначала делает flush;
- или эффект проверяет, что текущий адрес браузера — сам список.
2. **Повторное закрытие не уводит назад дальше (#42 M-2).** `overlay.tsx:37-41`: `close()` при каждом вызове делает
`navigate(-depth)`. Двойной щелчок по ✕ или фону, Esc сразу после щелчка — два шага назад.
Решение — закрытие выполняется один раз для одной записи истории окна. Повторные вызовы игнорируются, пока адрес не
сменился. Признак — `location.key` записи, с которой начато закрытие. Отметка снимается также при «Остаться» (`abortClose`): навигация заблокирована, ключ записи не меняется, и без сброса следующий ✕ молча игнорировался бы (R-15).
3. **Подтверждение ухода с несохранённой заметкой (#42 M-4).** `NoteForm.tsx:82`: блок `role="alertdialog"` без имени,
фокус в него не переводится. Решение:
- блоку — `aria-label` «Уйти без сохранения заметки?»;
- при появлении фокус переходит на «Остаться» (безопасное действие по умолчанию);
- прокрутка к блоку остаётся.
4. **Связанная задача «нет в зеркале» и окно 404 (#42 M-5).**
- В «Связях» (`IssueCard.tsx:123-125`) ключ задачи вне зеркала открывает задачу в Jira в новой вкладке (как «Открыть
в Jira» в шапке) со значком внешней ссылки, а не окно хаба. Пометка «нет в зеркале» остаётся.
- Окно 404 (прямой адрес, ключ из текста, гонка) показывает «Закрыть» (закрывает окно целиком, как ✕). Если окно
открыто переходом из другой карточки (`depth ≥ 1`), добавляется «Назад» — `navigate(-1)` к прежней карточке.
- То же «Назад» — у ошибки загрузки с «Повторить».
### Строка состояния
5. **Ответ опроса прежнего проекта отбрасывается (#116).** `ActivityProvider.tsx`:
- эффект смены проекта (`:75-90`) сбрасывает `activeRef`/`active` и событие;
- эффект опроса (`:126-154`) в том же коммите видит `active` из замыкания старого рендера и снова ставит событие
`batch` (`:139`);
- после этого `active = null`, событие больше никто не снимает.
Решение — эффект опроса обрабатывает ответ, только если отслеживаемый пакет на момент обработки тот же:
`activeRef.current` совпадает с `active` из замыкания (по `id` и `project`). Иначе ответ игнорируется. `finish` по
устаревшему ответу тоже не вызывается.
Тест — детерминированный, без нагрузки: ответ `/batches/{id}` приходит в том же такте, что и смена проекта; после
этого событие модуля `null`. Существующий тест `activity.test.tsx:331` при этом стабилен (прогон в цикле ≥ 20 раз
зелёный).
### Лента и карточка
6. **Текст комментария в ленте без разметки (#103 С6).** Новая чистая функция `wikiPlain(text, opts)` рядом с
разборщиком (`issues/wiki/`) переводит вики-разметку в обычный текст тем же разбором `parseWiki`:
- выделение и `{{моно}}` — содержимое;
- ссылка — её текст (у `[адрес]` — адрес);
- ключ задачи — ключ, `[~логин]` — «@логин», вложение — имя;
- блоки — их текст через пробел, заголовок — его текст, `----` — пусто.
`eventText` для `comment` берёт `wikiPlain`, затем прежние «одна строка» и обрезку до 300 символов с «…». Модуль ленты
импортирует функцию из модуля задач того же модуля jira — граница модулей веба не нарушается. Та же функция
применяется к «Истории» карточки: она рисуется тем же `EventList`.
7. **Метки видов работ отдельно (#103).** В «Прочих полях» (`IssueCard.tsx:227`):
- строка «Вид работы в Jira» — названия видов из `kind_labels`, чьи метки есть у задачи, через запятую, в порядке
меток задачи;
- строка «Метки» — остальные метки.
Строка с пустым значением, как и сейчас, не показывается. Сравнение меток — точное, как у доски (`dicts.ts`).
Служебное поле заметки «Вид работы» (`work_kind`) не трогается. Это заметка хаба, а не метка Jira, и они могут
расходиться — отсюда подпись «в Jira».
8. **Описание из пробелов (#56 M-4).** `IssueCard.tsx:79`: раздел «Описание» показывается, только если
`description.trim()` не пуст.
### Вики-разметка (#56 M-5, M-6)
9. **`\#`.** `#` добавляется в `ESCAPABLE` (`inline.ts:21`). Строка `\# текст` не список (уже так — `LIST` требует `#`
первым символом), а `\#` показывается как `#`.
10. **Макросы с параметрами.** `MACRO` (`parse.ts:17`) принимает параметры у всех трёх тегов:
`\{(code|noformat|quote)(?::[^}]*)?\}`. Закрывающий тег — по имени без параметров, как сейчас.
11. **Пробелы после открывающего тега.** В `closeMacro` (`parse.ts:111`) остаток строки открывающего тега из одних
пробелов в тело блока не попадает. Ведущие пробелы строк внутри блока сохраняются.
12. **`[~логин|текст]`.** В `bracket` (`inline.ts:149-152`) логин — часть до `|`, обрезанная. Получается «@логин»,
подпись не показывается. Пустой логин или логин с пробелом — текст как есть, как сейчас.
13. **Непарная `[` в ячейке таблицы.** В `row` (`parse.ts:127-132`) `[` увеличивает глубину, только если дальше в
строке есть парная `]`. Иначе `[` — обычный символ, и `|` делит ячейки.
### Список задач, доска, проекты
14. **Список ждёт настройки (#60 M-3).** Запрос `GET /issues` (`useIssues`) включается, когда запрос `/settings`
завершён — успехом или ошибкой. Пока нет — «Загрузка…», без «По этим фильтрам задач нет». При ошибке настроек
закрытых статусов нет, запрос уходит с `hide_closed=1`, как сейчас.
15. **Два быстрых переключения фильтра доски (#35 T4.1/M-15).** `useBoardPrefs.ts:39-54`: `toggle` и `toggleHidden`
считают новое значение из `prefs` замыкания, и второй клик в том же батче теряет первый. Решение — последнее значение
держится в `ref`, обновляемом синхронно вместе с `setPrefs`. Новое значение считается из `ref` и тем же значением
пишется в `localStorage`. Чистота updater-функции сохраняется: StrictMode не пишет дважды.
16. **Фильтр спринтов не из-за эпика (#64 T9.1).** `fromHub.ts:30`: `hasSprint` — только по своим задачам, которые
не эпики (`!isEpic(it) && !it.neighbor && !!it.sprint`), то есть по узлам доски.
17. **«Повторить» при ошибке загрузки проектов (#64 M-6).** `ProjectGate.tsx:11`: у сообщения «Проекты не загрузились»
есть кнопка «Повторить», которая перезапрашивает `GET /projects` (`refetch` запроса проектов). Через контекст
проекта или прямо — на усмотрение исполнителя.
### Вложения
18. **Прежняя ошибка не мигает после успешного повтора (#86 T6.2).** После успешного скачивания хук помнит «успех в
этой карточке» для вложения. Строка (`AttachmentList.tsx:36`) не показывает серверный `state = failed`, пока
данные карточки не перечитаны. Отметку снимает приход данных, где `state` вложения уже не `failed`, или новый
`start`.
19. **401 при опросе скачивания — экран входа (#86 T6.3).** В `useAttachmentDownload` (`:74`, `:84`) `AuthError` идёт
в `dropTokenOn(e)`, как в остальном приложении, а не текстом под вложением. `HubDownError` — текст «хаб не отвечает»
под вложением с «Повторить» (требование «Ошибки хаба»).
20. **Порядок вложений — как у хаба (#86 M-4).** Хаб уже отдаёт вложения новыми сверху (`mirror.py:300-301`: по
`created_at` UTC, затем `attachment_id`). Веб не пересортирует (`newestFirst` убирается из `AttachmentList`).
`findAttachment` (`inline.ts:36-45`) при одинаковом имени берёт первое вложение в порядке хаба. Так выбор совпадает
с `attachment_ids` хаба и MCP и не зависит от `Date.parse` строк Jira.
### UI-кит
21. **Фокус после «Очистить» (#68 T3.3).** `SearchInput` (`ui/inputs.tsx:22`): после нажатия «Очистить» фокус
переводится в поле ввода (ref). Иначе кнопка исчезает вместе с фокусом.
## Risks / Trade-offs
- **#116 — гонка в порядке эффектов.** Решение опирается на то, что эффект смены проекта стоит раньше эффекта опроса и
синхронно меняет `activeRef`. Тест должен воспроизводить именно один коммит со сменой проекта и ответом опроса, иначе
он проходит и без правки. Исполнитель проверяет RED.
- **`wikiPlain` и разбор.** Плоский текст строится разбором карточки. Ошибка разбора в ленте даёт исходник — так же
безопасно, как в карточке (рендер блока с ошибкой — исходник).
- **Ссылка «нет в зеркале» в Jira.** Адрес Jira — тот же `JIRA_URL`, что у «Открыть в Jira». Задача может быть и вне
Jira (опечатка в связи) — тогда Jira покажет свою 404, это приемлемо.
- **Окно 404 и «Назад».** `depth` берётся из state окна. При прямом адресе `depth = 0` — только «Закрыть».
## Decisions made without discussion
Все — 25.10.2026: владелец пропустил вопросы brainstorming («делай сам»). Рулинги R-n — с R-1 в пределах фичи.
- R-1 (design #1): при открытии карточки отложенный поиск записывается сразу, окно открывается поверх списка с ним.
Обоснование: пользователь ввёл поиск и видит отфильтрованную строку, на которую щёлкнул, — потерять поиск хуже.
Цена ошибки — вместо этого отбрасывать поиск: правка одной функции.
- R-2 (design #2): повторное закрытие окна игнорируется до смены адреса. Цена ошибки — нет.
- R-3 (design #3): фокус подтверждения ухода — на «Остаться». Обоснование: безопасное действие по умолчанию, данные не
теряются от лишнего Enter. Цена ошибки — перенести фокус на другую кнопку.
- R-4 (design #4): ключ связи «нет в зеркале» открывает Jira в новой вкладке, а не окно хаба с 404. Обоснование: в хабе
про задачу ничего нет, полезное действие — посмотреть её в Jira. Цена ошибки — вернуть окно: одна ветка в `Links`.
- R-5 (design #4): у окна 404 после перехода по связи — «Назад» (на шаг) и «Закрыть» (всё окно). Цена ошибки — убрать
кнопку.
- R-6 (design #5): #116 чинится в провайдере (ответ сверяется с текущим `activeRef`), тест не ослабляется. Цена ошибки
— нет: гипотеза подтверждена чтением кода при сверке.
- R-7 (design #6): лента показывает текст комментария без разметки по разбору карточки; «История» карточки — тоже.
Альтернатива — показывать начало комментария отрендеренным — отвергнута: строка ленты однострочная. Цена ошибки —
правка `eventText`.
- R-8 (design #7): метки видов работ — отдельной строкой «Вид работы в Jira» с названиями видов, из «Меток» убраны.
Альтернатива — просто скрыть. Отвергнута: метка вида — единственный след вида работы в Jira, и он может расходиться
с заметкой. Цена ошибки — скрыть строку.
- R-9 (design #12): `[~логин|текст]` показывается «@логин» без подписи. Jira такой формы не документирует, логин важнее
подписи. Цена ошибки — показывать подпись.
- R-10 (design #13): непарная `[` в строке таблицы — обычный символ. Цена ошибки — нет.
- R-11 (design #14): при ошибке `/settings` список всё равно запрашивается (с `hide_closed=1`, закрытых статусов нет),
а не блокируется. Обоснование: список полезнее пустого экрана, ошибку настроек покажет страница. Цена ошибки — нет.
- R-12 (design #19): `HubDownError` при опросе скачивания остаётся текстом под вложением с «Повторить», отдельного
экрана нет — данные карточки остаются на экране (требование «Ошибки хаба»). Цена ошибки — нет.
- R-13 (design #20): веб берёт порядок вложений у хаба, а при одинаковых именах — первое в этом порядке. Обоснование:
совпадение с `attachment_ids` хаба и MCP. Цена ошибки — вернуть сортировку на клиенте.
- R-14 (proposal): в пакет не взяты #42 M-3 (возврат фокуса после закрытия окна), M-6 (`100vh`), доступность
UI-кита #68 и производительность разбора #56 M-2. Это не ошибки поведения, они остаются в своих issue. Цена ошибки —
следующий пакет.
- R-15 (design #2, 26.10.2026): `closingKey` в `overlay.tsx` сбрасывается и в `abortClose` («Остаться»), а не только при смене
`location.key`. Обоснование: при заблокированной навигации ключ записи не меняется, и без сброса второй Esc/✕ после
«Остаться» молча игнорировался бы (дефект плана, пойман e2e Task 4). Цена ошибки — повторный ✕ при блокере станет
повторно вызывать navigate; безвредно.
- R-16 (design #6): в ленте вложение читается именем без списка вложений карточки — флаг `WikiOptions.anyAttachment`,
выставляется только `wikiPlain`; карточка его не передаёт. Обоснование: финальное ревью I-1, `!скрин.png|thumbnail!` и
`[^отчёт.pdf]` иначе остаются сырой разметкой. Цена ошибки — правка в одном месте.
- R-17: #18 выполнен частично (отметка «успех» не снимается приходом данных) и разбор всего комментария ради 300 символов
в ленте — отложены (M-2, M-6 финального ревью). Обоснование: редкий случай и общая тема #56 M-2. Цена ошибки — редкая
невидимая ошибка вложения в открытой карточке; задержка ленты на очень длинных комментариях.
- R-18 (design #2): ссылка R-15 указывает на решение 2, а не на несуществующее #22; к решению 2 дописан сброс в
`abortClose`. Цена ошибки — нет (документация).
- R-19: N-1 (пере-ревью финальной волны) — в режиме `anyAttachment` ветка `!имя!` принимает любую пару «!», поэтому в ленте
«Срочно! Проверьте! Ок» теряет восклицательные знаки — отложено в follow-up (требовать расширение файла в имени);
второй волны правок нет. Цена ошибки — искажённые «!» в тексте строки ленты, данные не страдают.