Перейти к основному содержимому

ADR 0176 — SessionPage.tsx меньше 1 000 строк, за десять механических PR, а замок — только после

Статус​

Accepted. 2026-09-28 (AT-138, BRB-015). Решение сопровождающего (2026-09-27): да, и запереть после — декомпозировать apps/web/src/routes/SessionPage.tsx до менее чем 1 000 строк механическими PR и только затем превратить потолок в замок CI.

Ссылается, без правки, на ADR 0122 (пять механических PR, которые довели файл с 3 807 до 2 661 строки и решили, что извлечённые соседние файлы импортируют SessionPage.module.css напрямую), ADR 0124 (кластер канала хода в useTurnoDoAgente, который эта программа НЕ трогает) и ADR 0125 (та же мерка «импортёры ничего не меняют», применённая к ProjectSettingsTab.tsx).

Контекст​

Строка долга SessionPage.tsx закрылась 2026-08-30, ADR 0122 и 0124, на 2 479 строках. Она закрылась по ЧИСЛУ, и ничто потом это число не измеряло: 2026-09-13 в файле было 2 559 строк (architecture.md стала это объявлять, AT-031), а в сегодняшней origin/dev (018b8cd24c, измерено wc -l) — 2 637 строк и 122 379 байт. Никто не сделал ничего неправильного — каждое добавление было законной функциональностью (фильтр пригодных, карточка handoff Infra, отказ закрытой сессии, Infra Lead в composer), — и именно поэтому регрессия и произошла: без замка долг возвращается как сумма правильных решений.

Каким был файл, измеренный до этой программы:

  • Компонент на ~2 400 строк. Тело SessionPage шло со строки 237 по 2 637. Вне его были только импорты, реэкспорт функций timeline и agruparNarracoesDoTurno.
  • Самым большим блоком был useMemo на 762 строки, сборка timeline: по одному JSX-узлу на тип события, партия историй, ожидающих продвижения (RN-126/RN-148), и карточки одобрения (RN-155).
  • Возвращаемый JSX занимал ~625 строк в трёх независимых полосах: верхняя панель, прокручиваемое содержимое ленты и нижняя полоса (карточка Infra, ручной handoff, composer).
  • Обработчики занимали ~500 строк, в трёх семействах: обработчики ХОДА (handleSend, handleCancel, handleReadiness, handleArchitectureReadiness — вход в кластер ADR 0124), обработчики ПРОДВИЖЕНИЯ историй и обработчики HANDOFF/ИСПОЛНЕНИЯ, не относящиеся к ходу.
  • 28 файлов SessionPage.*.test.tsx, 173 теста, все с vi.mock МОДУЛЯ (../lib/api-client, ../lib/hooks, ../lib/session-channel…). Mock модуля действует для любого файла, импортирующего модуль, поэтому код, перенесённый в соседний файл, остаётся замоканным — именно это делает перенос без правки тестов осуществимым.

Решение​

Десять PR, уложенных стопкой по порядку (каждый поверх предыдущего, --base — ветка предыдущего), каждый МЕХАНИЧЕСКИЙ: перенос кода без изменения поведения — никакого рефакторинга логики, переименования публичного API компонента или «улучшения мимоходом». Мерка приёмки — из ADR 0122, слово в слово: 28 существующих тестовых файлов проходят БЕЗ ПРАВКИ. PR, которому нужно править один из них, не механический и останавливается.

SessionPage.tsx уходит с 2 637 и приходит к 834 строкам (−1 803):

PRЧто уходитКудаSessionPage.tsx после
—ADR 0176 (этот документ)docs/adr/2 637
1agruparNarracoesDoTurno, FIO_RECENTES_ABERTAS, тело useMemo timelineAgrupada (сворачивание по агенту, RN-138) и fio (срез по источнику, RN-177)routes/session-fio.tsx (agruparTimelinePorAgente, dividirFio)2 445 (−192)
2тело useMemo timeline (762 строки)routes/session-timeline-montagem.tsx (montarTimeline)1 718 (−727)
3верхняя панельroutes/SessionTopbar.tsx1 561 (−157)
4содержимое ленты (приглашение, история, недавние, оптимистичное, пузырь streaming)routes/SessionFio.tsx1 390 (−171)
5нижняя полоса (карточка Infra, ручной handoff, composer)routes/SessionComposer.tsx1 225 (−165)
6activeFor, offeredHandoff, handoffDaInfraOferecido и три «уже объявленных»lib/session-handoffs.ts (derivarHandoffsDaSessao)1 163 (−62)
7четыре ref и четыре эффекта прокрутки, и rolarParaOFimlib/session-rolagem.ts (useRolagemDoFio)1 094 (−69)
8пять состояний и три обработчика продвижения историй, и модальное окно возвратаlib/session-promocao.ts (usePromocaoDeHistorias) + routes/DevolverHistoriaModal.tsx982 (−112)
9четыре состояния и пять обработчиков handoff/исполнения, не относящихся к ходу (RN-406, RN-440, RN-161, RN-137, RN-153)lib/session-acoes-de-handoff.ts (useAcoesDeHandoff)834 (−148)
10замокroutes/SessionPage.teto.test.ts834

Порядок идёт от наименее рискованного к наиболее, как в ADR 0122: сначала то, что является чистой функцией или почти (PR 1–2), затем JSX без состояния (PR 3–5), затем чистое вычисление (PR 6), и в последнюю очередь ХУКИ (PR 7–9), которые единственные трогают порядок хуков компонента.

Что значит «механический» для каждого вида среза, решено здесь один раз, чтобы не пересуживать в десяти ревью:

  1. Тело useMemo становится функцией, вызываемой ИЗНУТРИ того же useMemo, с ТЕМ ЖЕ списком зависимостей, байт в байт — включая то, чего он не перечисляет. Сборка timeline замыкается на обработчики (handlePromoteStory, handleAcceptHandoff…), t и semRepositorio, которых никогда не было в зависимостях; дополнение списка изменило бы, КОГДА timeline пересобирается, а это поведение. oxlint продолжает предупреждать (exhaustive-deps — это warn) о том, о чём предупреждал раньше.
  2. JSX-блок становится компонентом, у props которого ТЕ ЖЕ имена, что блок читал из области видимости SessionPage. Компонент вызывает собственный useTranslation('sessionPage'). Блок с несколькими соседями становится фрагментом. {recusandoStory && …} модального окна ушёл вместе с ним, внутрь компонента.
  3. Чистое вычисление становится чистой функцией, возвращающей те же имена и вычисляемой на каждом рендере, как и было.
  4. Состояние + обработчики становятся хуком, вызываемым в ТОЙ ЖЕ точке, где были эффекты (PR 7), или сразу после useTurnoDoAgente, когда обработчики от него зависят (PR 8–9). Позиция useState в порядке хуков меняется, и это не наблюдаемо: React требует лишь одинакового порядка между одним рендером и следующим. Обработчики остаются функциями, пересоздаваемыми на каждом рендере, как и были async function компонента.
  5. Без JSX — в lib/*.ts; с JSX — остаётся в routes/*.tsx и импортирует SessionPage.module.css напрямую — ответ ADR 0122 на вопрос CSS, переиспользованный без повторного открытия. В lib/ по-прежнему нет .tsx с кодом.
  6. Комментарии уходят вместе с кодом, включая говорящие «здесь» или «чуть ниже», — новый файл в начале говорит, откуда они пришли. Переписать комментарий мимоходом — это то «улучшение мимоходом», которое программа запрещает.
  7. SessionPage.tsx реэкспортирует то, что тесты из него импортируют: agruparNarracoesDoTurno ушла в session-fio.tsx и по-прежнему реэкспортируется, по той же причине, что и четыре функции timeline из ADR 0122.

Замок рождается в ПОСЛЕДНЕМ PR, а не раньше. Это тест web (SessionPage.teto.test.ts), проваливающий SessionPage.tsx при 1 000 строк или больше, считая как wc -l, и доказывающий собственный подсчёт на обеих границах (999 проходит, 1 000 проваливается) — измерено также мутацией, добавлением 166 строк в реальный файл. Он живёт в web, а не в scripts/ci/, потому что выполняется в наборе, который уже запускает каждый PR, трогающий файл.

Вне рамок, намеренно​

  • Обработчики ХОДА (handleSend, handleCancel, handleReadiness, handleArchitectureReadiness, avisarSessaoEncerrada) и обработчики жизненного цикла сессии (handleActivate, handleClose, handleRename, handleStartIdeation) остаются в SessionPage. Обработчики хода — входная дверь кластера, который ADR 0124 перенёс в useTurnoDoAgente и оставил вызываемым ОТСЮДА; их вынос означал бы заново решать, где живёт ход, а это не механика. Потолок достигнут без них.
  • useTurnoDoAgente, useSessionReadiness, ContextAside, StructuredQuestionCard, StorySlide — уже извлечены, не меняются.
  • Ни один список зависимостей не «исправляется», ни один обработчик не становится useCallback, ни одно имя prop или функции не меняется. Каждое из этого было бы изменением поведения или API, и каждое, если оно того стоит, — отдельный PR, который не выдаёт себя за механический.

Последствия​

  • Файл уходит с 2 637 до 834 строк, с запасом в 166 до замка. Запас намеренный: потолок не может стать следующей функциональностью, которая в него упадёт. Кому нужно перейти 999, делает срез — кандидаты объявлены выше, — и никогда не поднимает число. Поднять потолок — это новый ADR, ссылающийся на этот.
  • Сумма строк РАСТЁТ: в десяти новых файлах 2 574 строки против 1 803 ушедших, потому что каждый получил собственные импорты, блок props или контекста и заголовок, говорящий, откуда он пришёл. Это цена явных границ, и ту же цену принял ADR 0125.
  • Самый большой новый файл — 861 строка (session-timeline-montagem.tsx, почти весь исходный useMemo). Здесь он замка НЕ получает: объявленный долг — это долг SessionPage.tsx, и запереть только что родившийся файл было бы той ошибкой, которую назвала AT-031. Если он вырастет, следующий естественный срез — по файлу на семейство событий.
  • Ссылки arquivo:linha на SessionPage.tsx в docs/business-rules.md и docs/business-rules/autenticacao.md сместились — многие были устаревшими ещё до этой программы. Они обновляются в PR 10, один раз, когда строки перестают двигаться; в PR 1–9 предупреждает verificarRefsComSimbolo (в warn).
  • BRB-015 перестаёт воспроизводиться в PR 10, и строка architecture.md, объявлявшая регрессию, закрывается вместе с ним, указывая на замок.
  • Уложенные стопкой PR сливаются по порядку. PR функциональности, затронувший SessionPage.tsx между двумя из них, делает rebase поверх среза — цена, которую ADR 0122 уже принял за то, чтобы не замораживать файл.

Отвергнутые альтернативы​

  • Запереть до декомпозиции, с потолком на сегодняшнем числе, снижаемым с каждым PR. Отвергнуто сопровождающим: потолок, следующий за снижением, — это число, которое нужно править в каждом PR программы, а фиксированный потолок выше файла ничему не мешает. Замок имеет ценность только как НЕТ на «снова начал расти», а это существует лишь после того, как цель достигнута.
  • Один PR. Отвергнуто по той же причине, что в ADR 0122: 1 800 строк, перенесённых в одном diff, не ревьюятся, а файл продолжает получать функциональность.
  • Унести вместе обработчики хода, чтобы зайти дальше. Отвергнуто: это кластер, о котором решил ADR 0124, и потолку он не понадобился.
  • Файл на тип события уже в PR 2. Отвергнуто для этой программы: разбиение useMemo на функции по событиям требует решить сигнатуру каждой (партия продвижения и continue цикла пересекают типы), и это перестаёт быть переносом. Сначала перенести тело целиком — то, что позволяет этому срезу, если он будет, ревьюиться отдельно.