hermes-android/agy-work/TASK-2026-08-24-03-critical-ux.md

116 lines
14 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.

# 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`.