# Task 03: Критичный UX — жизненный цикл, камера, ошибки, модальные запросы (hermes-android) **Repo:** `ochenstarik-ui/hermes-android` **Assigned to:** Antigravity (режим оркестратора, два кодера) **Priority:** CRITICAL (потеря состояния, утечки, тупиковый диалог) **Date:** 2026-08-24 **Base SHA:** результат задания 02 — указать фактический SHA при выдаче **Зависимость:** задания 01 и 02 приняты. ## Роли и протокол | Роль | Модель | Что делает | |---|---|---| | Оркестратор | Antigravity | Разбивает работу, маршрутизирует, принимает результат | | Кодер 1 | Gemini Flash 3.7 high | Реализация §Scope целиком | | Кодер 2 | Gemini Pro high | Независимая проверка работы кодера 1 и доработка | **Раунд 1 (кодер 1).** Сначала тесты из §Required tests с фиксацией падения на base SHA дословным выводом, затем код по пунктам §Scope. Нерешённое — в `OPEN QUESTIONS`. **Раунд 2 (кодер 2).** Независимое воспроизведение падения своим запуском, проход §Anti-checklist с явной отметкой каждого пункта, доводка минимальным дифом, findings по шкале. Тесты кодера 1 не удалять и не ослаблять. **Раунд 3 (оркестратор).** Приёмка только при фактическом выводе команд и подтверждении обоими кодерами. ## Проблема **1. ViewModel не переживают смену конфигурации (`UI-01`).** `MainActivity.kt:87-92,116-118,145-147` создают `UnifiedSessionsViewModel`, `HostsViewModel`, `ChatViewModel`, `NativeSessionsViewModel` через `remember`. Поворот экрана пересоздаёт Activity и вместе с ней все VM: состояние чата, введённый текст и подписки теряются. `onCleared()` не вызывается никогда, `viewModelScope` не отменяется — корутины утекают при каждом пересоздании. Смежно: `HostsViewModel.kt:101` — `startSignIn(context, …)` удерживает `Context` Activity на протяжении всего входа (до трёх минут). **2. Камера работает после закрытия сканера (`UI-02`).** `QrScannerSheet.kt:148-166` — `bindToLifecycle` привязан к жизненному циклу Activity, а `onDispose` закрывает только executor. Ни `unbindAll()`, ни `scanner.close()` не вызываются: камера и ML Kit остаются активными до ухода приложения в фон, индикатор использования камеры горит. Плюс `cameraProviderFuture.addListener` навешивается заново при каждом перезапуске эффекта и никогда не снимается, а `unbindAll()` отвязывает все use-case-ы процесса. **3. Ошибки чата вычисляются и не показываются (`UI-03`).** `ChatViewModel.kt:82-85` кладёт текст ошибки в `uiState.error`, `ChatScreen` не обращается к этому полю ни разу — 0 вхождений по файлу. Неудачная отправка промпта выглядит как «ничего не произошло». То же с `UnifiedSessionsViewModel.uiState.error`. **4. Диалог sudo невозможно закрыть (`UI-04`).** `ChatScreen.kt:280-288` передаёт `onDismiss = { /* dismiss */ }` — пустая лямбда. Кнопка Cancel (`ClarifyDialog.kt:130-134`) и тап вне диалога не делают ничего. Пользователь заперт, пока не введёт что-нибудь и не отправит — для запроса пароля это ещё и подталкивает ввести пароль ради выхода. **5. Запросы подтверждений глобальны (`DATA-05`).** `UnifiedSessionRepository.kt:41-44` — `activeApprovals` один общий список, `activeClarify` одно поле. `ChatViewModel.kt:28-29` отдаёт их без фильтрации: подтверждение из сессии A всплывает в чате сессии B, а второй sudo-запрос затирает первый, на который уже никто не ответит. **6. Незащищённый `valueOf` роняет процесс (`UI-06`).** `HostsViewModel.kt:166` — `HostStatus.valueOf(existingHost.lastKnownStatus)` без `try/catch`, хотя во всех остальных местах проекта конвертация обёрнута. Неизвестное значение в БД даёт `IllegalArgumentException` в `viewModelScope` и падение. ## Scope **1. Настоящие ViewModel (`UI-01`).** Перевести все четыре VM на `viewModel(factory = …)` с фабрикой, получающей зависимости из `AppContainer`. Проверить, что `onCleared` вызывается и `viewModelScope` отменяется. `ChatViewModel` привязать к маршруту чата, а не к Activity, чтобы уход с экрана освобождал VM. `Context` для Custom Tabs передавать в момент вызова, не хранить в поле VM и не захватывать в suspend-функции. **2. Освобождение камеры (`UI-02`).** В `DisposableEffect` вызывать `cameraProvider.unbindAll()` и `scanner.close()`. Перейти с `addListener` на `await()`/`suspendCancellableCoroutine`, чтобы слушатели не накапливались. Колбэк `onQrScanned` обернуть в `rememberUpdatedState`. Чтение `isScanning` из фонового анализатора убрать — Compose-состояние не читается вне главного потока. **3. Показ ошибок (`UI-03`).** `SnackbarHost` в `Scaffold` чата и списка сессий; ошибка показывается с действием «Повторить» и сбрасывается после показа, чтобы не всплывать повторно при рекомпозиции. Текст ошибки не должен содержать секретов и сырых стектрейсов. **4. Отменяемый модальный запрос (`UI-04`).** Cancel и тап вне диалога закрывают его и отправляют хосту отрицательный ответ через соответствующий метод (`clarify.respond` / `sudo.respond` / `secret.respond` — уточнить у контракта, какой канал принимает отказ; если контракт этого не поддерживает, зафиксировать в `OPEN QUESTIONS` и не выдумывать метод). Введённое значение очищать при закрытии, не оставлять в Compose-состоянии. **5. Скоупинг подтверждений (`DATA-05`).** `activeApprovals` и `activeClarify` ключевать по `(sessionId, hostId)`; вместо одного слота clarify — очередь, чтобы второй запрос не затирал первый. `ChatViewModel` отдаёт только записи своей сессии. Ответ по-прежнему маршрутизируется на тот хост и рантайм-сессию, которые запрос породили — это в коде уже верно, не сломать. **6. Безопасная конвертация статуса (`UI-06`).** Один общий маппер `HostStatus.fromStringOrOffline()`, применённый во всех местах конвертации, включая `HostsViewModel.kt:166` и `HermesConnectionManager.toDomain`. ## Do not change - Транспорт, парсер событий — задание 01. - Схему БД, батчинг записи — задание 02. - Кэши `sessionMessagesState` и автопрокрутку (`DATA-09`, `UI-07`) — задание 09. - Иконку, строки, тему (`UI-05`, `UI-08`, `UI-09`) — задание 10. - PKCE-флоу и `FLAG_SECURE` — задание 06. - Дизайн экранов и композицию UI без необходимости: правки минимальные, по существу пунктов. ## Anti-checklist 1. VM переведены на `viewModel()`, но фабрика создаёт новый `AppContainer` вместо использования существующего — появились вторые экземпляры БД и менеджера соединений. Проверить, что контейнер один. 2. `ChatViewModel` привязан к Activity, а не к маршруту: уход из чата не освобождает VM, утечка сохранилась в другой форме. 3. `unbindAll()` добавлен, но `scanner.close()` забыт — ML Kit продолжает держать ресурсы. 4. Snackbar показывается, но `error` не сбрасывается — сообщение всплывает при каждой рекомпозиции. 5. Cancel закрывает диалог локально, но хосту ничего не отправляется — хост висит до таймаута. Это половина фикса, а не фикс. 6. Подтверждения отфильтрованы во ViewModel, но репозиторий по-прежнему хранит один слот clarify — второй запрос всё так же затирает первый. Проверить именно хранилище, а не отображение. 7. Тесты написаны только на VM, без проверки репозитория; дефект `DATA-05` живёт в репозитории. 8. Тест на `UI-01` не падает на base SHA, потому что проверяет фабрику, а не сохранение состояния при пересоздании. Требование: тест воспроизводит пересоздание. 9. В отчёте `green` для незапущенной команды (`AGENTS.md §3`). ## Definition of Done - Поворот экрана в чате сохраняет введённый текст, историю и состояние выполнения. - `onCleared` вызывается при уходе с экрана чата; `viewModelScope` отменён. - После закрытия шторки сканера камера освобождена — подтверждено на устройстве. - Ошибка отправки промпта видна пользователю и не повторяется после показа. - Cancel в диалоге sudo закрывает его и хост получает отказ. - Подтверждение, поднятое в сессии A, не отображается в сессии B; два подряд clarify от разных хостов оба доступны для ответа. - Неизвестное значение статуса в БД не роняет приложение. - Все существующие тесты зелёные, ни один не удалён и не ослаблен. ## Required tests `feature/chat/ChatViewModelScopeTest.kt` — состояние переживает пересоздание; `onCleared` отменяет scope. `core/repository/ApprovalScopingTest.kt` — подтверждение сессии A не попадает в выборку сессии B; два clarify от разных хостов сосуществуют в очереди. Обязан падать на base SHA. `feature/chat/ClarifyCancelTest.kt` — отмена отправляет хосту отрицательный ответ. `feature/hosts/HostStatusMappingTest.kt` — неизвестная строка статуса даёт `OFFLINE`, а не исключение. Обязан падать на base SHA. Compose-тест (androidTest) `ChatErrorSnackbarTest` — ошибка отображается и не дублируется. Освобождение камеры юнит-тестом не проверяется: подтверждение — на устройстве, по индикатору камеры и `adb shell dumpsys media.camera`. Без устройства пункт `UNVERIFIED`. ## Required verification ```text ./gradlew --no-daemon testDebugUnitTest ./gradlew --no-daemon connectedDebugAndroidTest ./gradlew --no-daemon lint ./gradlew --no-daemon assembleDebug ``` Приложить `git diff --stat`. Незапущенные команды указывать явно с текстом ошибки. ## Result `agents/antigravity/done/TASK-2026-08-24-03-critical-ux.md` — разделы `## Кодер 1`, `## Кодер 2 (review + доработка)`, `## Вердикт оркестратора`. Содержимое по `AGENTS.md §4`.