fix(ui): уведомления шли без остановки и закрывали интерфейс

Владелец: «справа постоянно выходят статусы, прям без остановки. я вообще
ничего не вижу за ними».

Это был не таймер, а замкнутый круг. Отрисовка настроек запускала опрос
состояния сжатия; executeAction на успехе вызывал fetchSnapshot; тот снова
перерисовывал настройки — и так без конца. Интерфейс сам себя кормил запросами
к серверу, показывая на каждом обороте два тоста: на запрос и на ответ. Тем же
путём заливал «poll_native_auth» во время входа.

Опросы, которые запускает сам интерфейс, а не владелец, теперь молчат и не
дёргают снапшот — второе и разрывает круг. Отказ опроса показывается там, где
его запросили: у мастера входа для этого своя область сообщений.

Состояние сжатия запрашивается при открытии экрана настроек и по кнопке, а не
при каждой отрисовке.

700 passed, 2 skipped; ruff чисто; релизный гейт пройден.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Hermes Team 2026-09-01 22:47:55 +07:00
parent fa7bbef8af
commit 90aeceb9fd
5 changed files with 114 additions and 7 deletions

View file

@ -18,7 +18,7 @@ namespace HermesHubSetup
// Подставляется сборщиком из фактического git-коммита. Раньше здесь // Подставляется сборщиком из фактического git-коммита. Раньше здесь
// жил зашитый "8cddc9f", то есть манифест сообщал неправду о том, из // жил зашитый "8cddc9f", то есть манифест сообщал неправду о том, из
// какого кода собран установщик. // какого кода собран установщик.
public const string BuildCommit = "28f35f8"; public const string BuildCommit = "fa7bbef";
public const string MIN_HERMES_VERSION = "0.20.0"; public const string MIN_HERMES_VERSION = "0.20.0";
public const string MAX_TESTED_HERMES = "0.20.4"; public const string MAX_TESTED_HERMES = "0.20.4";

Binary file not shown.

Binary file not shown.

View file

@ -117,6 +117,10 @@ function switchView(viewName) {
if (viewName === 'settings') { if (viewName === 'settings') {
fetchSettings(); fetchSettings();
// Состояние сжатия спрашиваем при открытии экрана, а не при каждой
// отрисовке настроек: раньше опрос запускался на каждом обновлении
// снапшота и замыкал круг сам на себя.
checkCompressionStatus();
} }
if (currentSnapshot) { if (currentSnapshot) {
@ -535,8 +539,23 @@ async function saveAuthTokenFromPrompt() {
} }
// ── ACTIONS EXECUTION (POST /api/action) ── // ── ACTIONS EXECUTION (POST /api/action) ──
// Действия-опросы: их запускает сам интерфейс, а не владелец.
//
// Показывать их тостами нельзя: опрос состояния сжатия шёл при каждом
// обновлении снапшота, и владелец не видел за уведомлениями самой программы.
// Хуже того, успешное действие вызывало fetchSnapshot, тот перерисовывал
// настройки, а перерисовка снова запускала опрос — круг замыкался и кормил
// сам себя. Поэтому опросы не только молчат, но и не дёргают снапшот.
const SILENT_ACTIONS = new Set([
'get_compression_status',
'poll_native_auth', 'poll_native_agy_login', 'poll_terminal_auth',
'poll_redirect_auth', 'poll_device_auth',
]);
async function executeAction(actionName, actionData = {}) { async function executeAction(actionName, actionData = {}) {
showToast(`Выполняется «${actionName}»...`, 'info'); const silent = SILENT_ACTIONS.has(actionName);
if (!silent) showToast(`Выполняется «${actionName}»...`, 'info');
try { try {
const headers = { 'Content-Type': 'application/json' }; const headers = { 'Content-Type': 'application/json' };
if (authToken) headers['X-Hub-Token'] = authToken; if (authToken) headers['X-Hub-Token'] = authToken;
@ -555,16 +574,20 @@ async function executeAction(actionName, actionData = {}) {
const result = await res.json().catch(() => ({ ok: false, message: `Ошибка парсинга ответа (${res.status})` })); const result = await res.json().catch(() => ({ ok: false, message: `Ошибка парсинга ответа (${res.status})` }));
if (result.ok) { if (result.ok) {
showToast(result.message || 'Действие выполнено успешно', 'success'); if (!silent) {
fetchSnapshot(); showToast(result.message || 'Действие выполнено успешно', 'success');
fetchSnapshot();
}
return result; return result;
} else { } else {
showToast(result.message || 'Отказ выполнения действия', 'warning'); // Отказ опроса показываем в том месте, которое его запросило: у мастера
// входа для этого своя область сообщений. Тостом заливать нельзя.
if (!silent) showToast(result.message || 'Отказ выполнения действия', 'warning');
return result; return result;
} }
} catch (err) { } catch (err) {
console.error(`Action ${actionName} failed:`, err); console.error(`Action ${actionName} failed:`, err);
showToast(`Ошибка сети: ${err.message}`, 'error'); if (!silent) showToast(`Ошибка сети: ${err.message}`, 'error');
return { ok: false, message: `Ошибка сети: ${err.message}` }; return { ok: false, message: `Ошибка сети: ${err.message}` };
} }
} }
@ -1801,7 +1824,9 @@ function renderSettingsView() {
if (compKeepRecentSel && s.compression_keep_recent_messages !== undefined) { if (compKeepRecentSel && s.compression_keep_recent_messages !== undefined) {
compKeepRecentSel.value = String(s.compression_keep_recent_messages); compKeepRecentSel.value = String(s.compression_keep_recent_messages);
} }
checkCompressionStatus(); // checkCompressionStatus() отсюда убран: отрисовка настроек происходит на
// каждом обновлении снапшота, и опрос замыкал круг сам на себя. Состояние
// запрашивается при открытии экрана настроек и по кнопке.
} }
function populateCompressorProfiles(s) { function populateCompressorProfiles(s) {

View file

@ -0,0 +1,82 @@
"""Опросы интерфейса не должны заливать владельца уведомлениями и кормить себя.
Владелец: «справа постоянно выходят статусы, прям без остановки. я вообще
ничего не вижу за ними».
Это был не таймер, а замкнутый круг: отрисовка настроек запускала опрос
состояния сжатия, executeAction на успехе вызывал fetchSnapshot, тот снова
перерисовывал настройки и так без конца. Заодно каждый оборот показывал два
тоста: на запрос и на ответ.
Тем же путём заливали «poll_native_auth» во время входа.
"""
from __future__ import annotations
import re
from pathlib import Path
import pytest
APP_JS = (
Path(__file__).resolve().parent.parent
/ "src" / "antigravity_provider" / "router" / "web" / "static" / "app.js"
)
@pytest.fixture(scope="module")
def source() -> str:
return APP_JS.read_text(encoding="utf-8")
def test_polling_actions_are_listed_as_silent(source):
assert "SILENT_ACTIONS" in source
for action in (
"get_compression_status",
"poll_native_auth",
"poll_redirect_auth",
"poll_device_auth",
):
assert f"'{action}'" in source, f"{action} обязан быть среди молчаливых"
def test_silent_actions_show_no_toasts(source):
"""Все четыре места, где executeAction показывает тост, под охраной silent."""
body = source.split("async function executeAction")[1].split("// ── GLOBAL HEADER")[0]
start, success, failure, network = (
body.split("try {")[0],
body.split("if (result.ok)")[1].split("} else {")[0],
body.split("} else {")[1].split("} catch")[0],
body.split("} catch")[1],
)
for part, where in (
(start, "начало действия"),
(success, "успех"),
(failure, "отказ"),
(network, "сбой сети"),
):
assert "showToast(" in part, f"место не найдено: {where}"
assert part.index("if (!silent)") < part.index("showToast("), (
f"тост без проверки молчаливости: {where}"
)
def test_silent_actions_do_not_refetch_the_snapshot(source):
"""Круг замыкался именно здесь: успех опроса тянул за собой снапшот."""
body = source.split("async function executeAction")[1].split("// ── GLOBAL HEADER")[0]
success_block = body.split("if (result.ok)")[1].split("} else {")[0]
assert "if (!silent)" in success_block
assert success_block.index("if (!silent)") < success_block.index("fetchSnapshot(")
def test_settings_render_does_not_start_a_poll(source):
"""Опрос из отрисовки убран — она идёт на каждом обновлении снапшота."""
render = source.split("function populateCompressorProfiles")[0]
render_tail = render[-2000:]
calls = re.findall(r"^\s*checkCompressionStatus\(\);", render_tail, re.MULTILINE)
assert not calls, "опрос из отрисовки настроек запускать нельзя"
def test_status_is_requested_when_the_screen_opens(source):
opening = source.split("if (viewName === 'settings')")[1][:400]
assert "checkCompressionStatus()" in opening