Аудит мобильной адаптации и поиска
Работа 19–21 сентября 2026. Проверка шла в двух средах: эмуляция Chromium на 375×812 и реальный мобильный Safari (iOS 18.6, iPhone 16, 393×852) через iOS Simulator. Все цифры ниже — измерения, а не оценки.
Отдельный раздел в конце перечисляет выводы, которые по ходу оказались неверными и были сняты.
1. Агент не отвечал — цепочка из пяти звеньев
Симптом: поле ввода агента вечно висело в «Connecting...», на мобиле и на десктопе одинаково. Причина оказалась не в агенте и не в мобильной вёрстке.
flowchart TD
A["NATS перезапущен 17.09 08:52"] --> B["chat исчерпал реконнекты<br/>и не переподключился"]
B --> C["perm.grant не публикуется<br/>последнее сообщение в PERM: 11:17"]
C --> D["в user_permission нет гранта<br/>последний chat_room: 16.09 14:00"]
D --> E["auth не кладёт комнату<br/>в claim shared_chat_rooms"]
E --> F["браузеру запрещена подписка<br/>на live.chat.room-XXXX"]
F --> G["PERMISSIONS_VIOLATION<br/>вечное Connecting..."]
style A fill:#3b2f2f,stroke:#a35
style G fill:#3b2f2f,stroke:#a35
Отказ при этом нигде не был виден. Сервис два дня выглядел живым:
hub.go:322—if h.nc == nil || userEmail == "" { return }возвращается молча, без записи в логNewHubлогирует неудачный коннект один раз и больше не ретраит:h.ncостаётсяnilнавсегдафронт трактует
PERMISSIONS_VIOLATIONкак «will retry on reconnect», хотя ретрай тут не поможет никогда
Что подтверждало диагноз: created_by у комнат проставлялся (значит обработчик создания отрабатывал), перевыпуск токена не помогал (и в старом, и в новом ровно 47 комнат), а в списке подключений к :4222 были api, comments, auth, crdt, rag — и не было chat.
Починено перезапуском chat. Три места, где отказ глушится, остались как есть — это отдельная задача.
2. Поиск: ответ «ничего не найдено» обеспечивал сам себя
Самый неприятный баг сессии. В глобальной палитре ветка «No results» проверялась раньше, чем рендерилась группа результатов. Но счётчики задач и кода заполняют сами эти группы через onCount — а они жили внутри else.
flowchart LR
A["tasksFound = 0"] --> B["ветка No results<br/>выигрывает"]
B --> C["TaskSearchResults<br/>не монтируется"]
C --> D["запрос не уходит"]
D --> A
style A fill:#2f3b2f,stroke:#5a5
style D fill:#3b2f2f,stroke:#a35
Компонент, который делает запрос, монтировался только если запрос уже что-то вернул. При включённом одном чипе «Tasks» не уходило ни одного запроса.
Проявлялось не всегда: пока поиск по документации что-то находил, cards.length > 0 ломал цикл и группы монтировались. Поэтому баг был невидим на организациях с проиндексированной документацией и постоянен там, где её нет.
Починено: группы остаются смонтированными и прячутся классом, а строка «No results» показывается поверх них.
было | стало | |
|---|---|---|
запросов при одном чипе «Tasks» |
|
|
найдено задач | 0 | 5 |
3. Поиск выбирает движок по контексту страницы
Раньше вкладка «Search» всегда вела в поиск по документации, который требует projectIds. На страницах организации, тасок и репозитория их нет — вкладка принимала ввод и молчала.
flowchart TD
A["Вкладка Search"] --> B{"Что в контексте?"}
B -->|"база знаний"| C["Поиск по документации<br/>projects/search"]
B -->|"организация<br/>таски<br/>карточка задачи"| D["Поиск по задачам<br/>tasks/search"]
B -->|"репозиторий"| E["Вкладки нет<br/>код ищется на своей странице"]
C --> F["SearchResultCard"]
D --> F
F --> G["контент, диаграммы,<br/>подсветка, автозум"]
style E fill:#33332a,stroke:#aa5
style G fill:#2f3b2f,stroke:#5a5
Рендер общий: и задачи, и страницы идут через processResult → SearchResultCard. Второго рендера не писалось. Поэтому задача, телом которой является диаграмма, отвечает диаграммой — подтверждено aria-roledescription="flowchart-v2" и переключателем Code | Diagram. Доски tldraw и excalidraw рисуются так же.
Для этого понадобилась правка в rag-v2: индекс задач хранил только плоский content, без original_fragments и content_types. После добавления полей и переиндексации ND-8 отдаёт фрагмент ```mermaid\ngraph TD ... ```, ND-9 — <ExcalidrawBoard>, ND-7 — <TldrawBoard>.
Ещё сделано по поиску: фильтры задач (unresolved, категории статусов, типы), листинг по фильтрам без запроса, сворачиваемые группы со счётчиками, один владелец ⌘K вместо трёх независимых слушателей на window.
4. Мобильная адаптация
Главной проблемой оказался автозум iOS. У полей ввода font-size: 14px при пороге 16px — iOS увеличивает страницу при фокусе и никогда не возвращает масштаб.
flowchart TD
A["Поле 14px"] --> B["iOS зумит страницу"]
B --> C["Зум не сбрасывается"]
C --> D["Кнопка отправки<br/>уезжает за правый край"]
C --> E["Интерфейс с горизонтальным срезом:<br/>Pages в ages"]
D --> F["Отправить можно только Enter"]
style A fill:#3b2f2f,stroke:#a35
Лечится одной строкой — 16px на touch-устройствах. После правки кнопка отправки на месте, вёрстка не съезжает.
Остальное:
что было | что стало |
|---|---|
разделы организации недоступны с телефона ( | меню в шапке, доступно и со страницы документа |
шапка в 2–4 ряда, свитчер «n...», поиск полоской в 27px | один ряд, скрывается при скролле вниз и отдаёт свою высоту контенту |
заголовок Service map зажат в 83px ширины при 335px высоты | переносится, легенда читается |
тулбар мермейда в полный экран лежал поверх поля ввода | перенесён в свободный верхний угол |
Отдельно подтверждено на реальном устройстве: drag нижнего листа, пинч-зум и поиск в сайдбаре работают корректно.
5. Производительность
Прод-сборка против dev, мобильный вьюпорт:
dev | прод | |
|---|---|---|
запросов | 250 | 19 |
FCP / LCP | 684 мс | 640 мс |
CLS | 0.042 | 0 |
68% веса экрана логина — декор, а не приложение:
ресурс | передано |
|---|---|
| 509 KB (несжатый PNG) |
| 335 KB (сырой TTF, не WOFF2) |
| 284 KB gzip / 888 KB распакованного |
В репозитории 3.0 MB шрифтов (ни одного WOFF2) и 6.8 MB картинок.
Мерцание измерено. Одна смена организации — 505 мс long tasks, 56 всплесков мутаций DOM, высота хедера меняется 7 раз за 1.1 с, сайдбар трижды размонтируется и монтируется заново. На телефонном CPU это 2–3 с джанка.
Редакторы склеены в один чанк. MdToJson — 746 KB gzip, и в нём mermaid, tldraw, excalidraw и tiptap сразу. Открытие страницы с одной mermaid-диаграммой тянет TLDrawContainer, ExcalidrawContainer и их экспортёры.
6. Что осталось
документация организации
banankне индексируется: ноль чанков вJson_chunksпри 1602 проиндексированных задачах той же организациипри скоупе «Everywhere» поиск по задачам не выполняется —
org_idу эндпоинта обязателенфильтры по типам контента для задач — нужна правка в
rag-v2по образцуproject_search509 KB PNG → CSS-градиент, шрифты TTF → WOFF2 с сабсетом: минус ~800 KB с критического пути
AbortControllerи состояние ошибки в поиске, ремаунтBadgeв хедере,60vh→dvh, тап-таргеты ниже 44pxразнести mermaid / tldraw / excalidraw по независимым чанкам
прод-сборка падает: 188 ошибок TS (
tsc -b), самvite buildпроходит
7. Снятые выводы
Четыре вывода по ходу работы оказались неверными. Оставляю их здесь, потому что каждый показывает, где рассуждение обгоняло проверку.
«Поповер выбора организации прозрачный» — артефакт среды: при скрытой панели браузера framer-motion застывала на середине анимации.
«Enter в агенте не отправляет» — не воспроизвелось в Chromium, но на реальном Safari «Ввод» отправляет.
«Поле поиска перекроется клавиатурой, потому что
position: fixedнельзя проскроллить» — это был расчёт, а не наблюдение. Safari поднимает содержимое, поле остаётся видимым.«Пустая выдача по banank объясняется правами доступа» — объясняло сессию проверяющего (гость в этой организации), а не владельца. Настоящей причиной был дедлок из раздела 2.
Решило дело наблюдение «не уходит ни одного запроса»: именно отсутствие запроса, а не пустой ответ, указало на то, что компонент не монтируется.
Методика
Мобильные проверки стоит гонять на симуляторе, а не только в эмуляции ширины: автозум, перекрытие клавиатурой и выталкивание кнопки отправки в Chromium не воспроизводятся вообще.
Два нюанса окружения:
софт-клавиатура включается через
defaults write com.apple.iphonesimulator ConnectHardwareKeyboard -bool falseи полный перезапуск приложения Simulatorlocalhostиз симулятора резолвится в::1, а Vite слушает IPv4 — нужен--host ::либо прокси[::1]:5173 → 127.0.0.1:5173. Менять адрес нельзя: auth доверяет только originhttp://localhost:5173