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

14 KiB
Raw Blame History

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:101startSignIn(context, …) удерживает Context Activity на протяжении всего входа (до трёх минут).

2. Камера работает после закрытия сканера (UI-02). QrScannerSheet.kt:148-166bindToLifecycle привязан к жизненному циклу 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-44activeApprovals один общий список, activeClarify одно поле. ChatViewModel.kt:28-29 отдаёт их без фильтрации: подтверждение из сессии A всплывает в чате сессии B, а второй sudo-запрос затирает первый, на который уже никто не ответит.

6. Незащищённый valueOf роняет процесс (UI-06). HostsViewModel.kt:166HostStatus.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

./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.