Добавлен экран информации о диалоге: шапка с кнопкой ⋯, окно с редактированием названия, отображением агента, даты создания/обновления, признака временности и кнопкой удаления.
This commit is contained in:
@@ -81,9 +81,11 @@ pw.binom.agentik.desktop
|
|||||||
- **Проверка связи** — «ответ сервера» + создание/удаление временного диалога,
|
- **Проверка связи** — «ответ сервера» + создание/удаление временного диалога,
|
||||||
с таймингами.
|
с таймингами.
|
||||||
- **UI (Compose)** — рейка агентов, список диалогов (поиск, папки, бейджи),
|
- **UI (Compose)** — рейка агентов, список диалогов (поиск, папки, бейджи),
|
||||||
чат с живым стримингом, композер, окна «Агенты», «Группы» и «Новый диалог»
|
чат с живым стримингом, композер, окна «Агенты», «Группы», «Новый диалог»
|
||||||
(при нескольких агентах — выбор, при одном — сразу), пустые состояния,
|
(при нескольких агентах — выбор, при одном — сразу) и «Параметры диалога»
|
||||||
адаптивная раскладка (узкое окно — список или чат).
|
(кнопка `⋯` в шапке чата: имя, обслуживающий агент, признак «Временный»,
|
||||||
|
даты создания/обновления, удаление), пустые состояния, адаптивная раскладка
|
||||||
|
(узкое окно — список или чат).
|
||||||
- **Голос** — микрофон → VAD → распознавание (`voice/`): кнопка микрофона
|
- **Голос** — микрофон → VAD → распознавание (`voice/`): кнопка микрофона
|
||||||
или `Ctrl+M` из чата запускает запись (`mic-api`), Silero VAD (`vad-kmp`) отмечает наличие речи,
|
или `Ctrl+M` из чата запускает запись (`mic-api`), Silero VAD (`vad-kmp`) отмечает наличие речи,
|
||||||
Vosk (`asr-vosk`, модель внутри артефакта) отдаёт промежуточный текст над
|
Vosk (`asr-vosk`, модель внутри артефакта) отдаёт промежуточный текст над
|
||||||
|
|||||||
@@ -82,6 +82,14 @@
|
|||||||
## 4. Открытый диалог
|
## 4. Открытый диалог
|
||||||
|
|
||||||
- **R10.** Показ сообщений: мои и ассистента, с временем.
|
- **R10.** Показ сообщений: мои и ассистента, с временем.
|
||||||
|
- **R10.1.** В шапке открытого диалога — **кнопка `⋯` (три вертикальные точки)**,
|
||||||
|
без визуальных границ, как в Telegram. Открывает окно «Параметры диалога».
|
||||||
|
- **R10.2.** Окно «Параметры диалога»: название (с редактированием по ✎),
|
||||||
|
карточка обслуживающего агента (имя + URL + статус «на связи»/«не отвечает»),
|
||||||
|
признак «Временный диалог» (только просмотр — задаётся только при создании),
|
||||||
|
«Создан» и «Обновлён» (`dd.MM.yyyy HH:mm`, системная тайм-зона),
|
||||||
|
«Опасное» с кнопкой «Удалить диалог». Создание идёт через модалку
|
||||||
|
выбора агента (R14.x), а не через это окно.
|
||||||
- **R11.** Ответ ассистента можно прервать — на видном месте кнопка «Стоп».
|
- **R11.** Ответ ассистента можно прервать — на видном месте кнопка «Стоп».
|
||||||
- **R12.** Сообщения приходят вживую, по мере генерации: текст дотекает куском
|
- **R12.** Сообщения приходят вживую, по мере генерации: текст дотекает куском
|
||||||
за куском, а не появляется целиком в конце. Отображать именно так.
|
за куском, а не появляется целиком в конце. Отображать именно так.
|
||||||
|
|||||||
+252
-89
@@ -169,7 +169,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="composer">
|
<div class="composer">
|
||||||
<div class="composer-row">
|
<div class="composer-row">
|
||||||
<div class="field">Сообщение агенту…</div>
|
<div class="composer-input">Сообщение агенту…</div>
|
||||||
<button class="mic" title="Голос">🎤</button>
|
<button class="mic" title="Голос">🎤</button>
|
||||||
<button class="send off" title="Отправить">➤</button>
|
<button class="send off" title="Отправить">➤</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -253,92 +253,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- ============ 5 · модалка нового диалога ============ -->
|
|
||||||
<section class="screen">
|
|
||||||
<div class="lbl"><b>5 · модалка нового диалога</b> выбор агента для нового диалога</div>
|
|
||||||
<div class="win" style="position:relative">
|
|
||||||
<section class="list">
|
|
||||||
<div class="l-head"><h1>Диалоги</h1></div>
|
|
||||||
<div class="chips">
|
|
||||||
<div class="chip on">Все чаты<b>3</b></div>
|
|
||||||
<div class="chip">Работа</div>
|
|
||||||
<div class="chip">Дом</div>
|
|
||||||
<button class="chip-act" title="Группы"><svg viewBox="0 0 24 24"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/></svg></button>
|
|
||||||
</div>
|
|
||||||
<div class="q">🔍 Поиск…</div>
|
|
||||||
<div class="rows">
|
|
||||||
<div class="row">
|
|
||||||
<span class="strip" style="background:var(--a1)"></span>
|
|
||||||
<div class="ava" style="background:var(--a1)">С</div>
|
|
||||||
<div class="mid">
|
|
||||||
<div class="l1"><span class="n">agentik · рабочий</span><span class="t">14:22</span></div>
|
|
||||||
<div class="l2"><span class="p">Собрал отчёт, жду правок</span></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<main class="main">
|
|
||||||
<div class="empty">
|
|
||||||
<div class="big">💬</div>
|
|
||||||
<h3>Выберите диалог слева</h3>
|
|
||||||
<p>Или нажмите <b>+</b> в шапке списка, чтобы создать новый.</p>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
<div class="scrim">
|
|
||||||
<div class="modal">
|
|
||||||
<h2>Новый диалог</h2>
|
|
||||||
<div class="sub">Выберите ассистента — он отвечает на «/agentik» на своём адресе</div>
|
|
||||||
<div class="body">
|
|
||||||
<div class="agent on">
|
|
||||||
<div class="sign" style="background:var(--a1)">С</div>
|
|
||||||
<div class="mid">
|
|
||||||
<div class="nm">Строитель</div>
|
|
||||||
<div class="ad">http://192.168.88.135:8080/agentik</div>
|
|
||||||
</div>
|
|
||||||
<div class="radio"></div>
|
|
||||||
</div>
|
|
||||||
<div class="agent">
|
|
||||||
<div class="sign" style="background:var(--a2)">К</div>
|
|
||||||
<div class="mid">
|
|
||||||
<div class="nm">Книги</div>
|
|
||||||
<div class="ad">http://192.168.76.199:8080/agentik</div>
|
|
||||||
</div>
|
|
||||||
<div class="radio"></div>
|
|
||||||
</div>
|
|
||||||
<div class="agent">
|
|
||||||
<div class="sign" style="background:var(--a3)">Р</div>
|
|
||||||
<div class="mid">
|
|
||||||
<div class="nm">Разбор логов <span class="bad">· не отвечает</span></div>
|
|
||||||
<div class="ad">http://192.168.10.42:8080/agentik</div>
|
|
||||||
</div>
|
|
||||||
<div class="radio"></div>
|
|
||||||
</div>
|
|
||||||
<div class="agent">
|
|
||||||
<div class="sign" style="background:var(--a4)">S</div>
|
|
||||||
<div class="mid">
|
|
||||||
<div class="nm">Solana-бэкенд</div>
|
|
||||||
<div class="ad">http://192.168.88.24:8080/agentik</div>
|
|
||||||
</div>
|
|
||||||
<div class="radio"></div>
|
|
||||||
</div>
|
|
||||||
<div class="agent">
|
|
||||||
<div class="sign" style="background:var(--a5)">Д</div>
|
|
||||||
<div class="mid">
|
|
||||||
<div class="nm">DevOps · деплой</div>
|
|
||||||
<div class="ad">http://192.168.5.10:8080/agentik</div>
|
|
||||||
</div>
|
|
||||||
<div class="radio"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="foot">
|
|
||||||
<button class="btn">Отмена</button>
|
|
||||||
<button class="btn prim">Создать →</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- ============ 6 · управление группами ============ -->
|
<!-- ============ 6 · управление группами ============ -->
|
||||||
<section class="screen">
|
<section class="screen">
|
||||||
<div class="lbl"><b>6 · управление группами</b> модалка со списком групп + добавить</div>
|
<div class="lbl"><b>6 · управление группами</b> модалка со списком групп + добавить</div>
|
||||||
@@ -967,7 +881,7 @@
|
|||||||
<div class="head-name">agentik · рабочий</div>
|
<div class="head-name">agentik · рабочий</div>
|
||||||
<div class="head-sub"><span class="dot">●</span> на связи</div>
|
<div class="head-sub"><span class="dot">●</span> на связи</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="icon-btn" title="Параметры диалога">⋯</button>
|
<button class="icon-btn" title="Параметры диалога"><svg viewBox="0 0 24 24"><circle cx="12" cy="5" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="12" cy="19" r="2"/></svg></button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="messages">
|
<div class="messages">
|
||||||
@@ -1040,7 +954,7 @@
|
|||||||
<div class="avatar pic"></div>
|
<div class="avatar pic"></div>
|
||||||
<div class="ag-mid">
|
<div class="ag-mid">
|
||||||
<div class="ag-name">Кот-ассистент</div>
|
<div class="ag-name">Кот-ассистент</div>
|
||||||
<div class="ag-addr">http://192.168.88.24:8080/agentik</div>
|
<div class="ag-addr">http://192.168.24.8080:8080/agentik</div>
|
||||||
<div class="ag-state err"><span class="d"></span>не отвечает</div>
|
<div class="ag-state err"><span class="d"></span>не отвечает</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="pick"></div>
|
<div class="pick"></div>
|
||||||
@@ -1051,6 +965,20 @@
|
|||||||
<span>Ассистент сейчас не отвечает. Диалог создать можно, но
|
<span>Ассистент сейчас не отвечает. Диалог создать можно, но
|
||||||
сообщение не уйдёт, пока он не поднимется.</span>
|
сообщение не уйдёт, пока он не поднимется.</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label>Название</label>
|
||||||
|
<input type="text" value="" placeholder="как назвать диалог">
|
||||||
|
<div class="note">Можно оставить пустым — диалог покажется как «Без названия». Переименуете в ⋯ в шапке чата.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="checkbox-row on">
|
||||||
|
<div class="box"></div>
|
||||||
|
<div class="mid">
|
||||||
|
<div class="lbl">Временный диалог</div>
|
||||||
|
<div class="desc">История не сохраняется на сервере. Подходит для коротких проверок и отладки.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-foot">
|
<div class="d-foot">
|
||||||
<button class="btn" style="flex:1">Отмена</button>
|
<button class="btn" style="flex:1">Отмена</button>
|
||||||
@@ -1422,5 +1350,240 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- ============ вход в окно информации о диалоге ============ -->
|
||||||
|
<section class="screen">
|
||||||
|
<div class="lbl"><b>вход в окно информации о диалоге</b> кнопка <code>⋯</code> в шапке открытого чата с подсветкой</div>
|
||||||
|
<div class="window">
|
||||||
|
<aside class="sidebar">
|
||||||
|
<div class="sb-head"><div class="sb-title"><span>Диалоги</span><button class="icon-btn" title="Новый диалог">+</button></div></div>
|
||||||
|
<input class="search" placeholder="Поиск диалога…" style="margin:0 16px 10px;width:calc(100% - 32px);">
|
||||||
|
<div class="chat-list">
|
||||||
|
<div class="chat-row active">
|
||||||
|
<div class="avatar">АГ</div>
|
||||||
|
<div class="chat-mid">
|
||||||
|
<div class="chat-name"><span>Spring Boot · настройка логов</span><span class="chat-time">14:22</span></div>
|
||||||
|
<div class="chat-prev">Логи теперь пишутся в отдельный файл…</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
<main class="main">
|
||||||
|
<header class="chat-head">
|
||||||
|
<div class="avatar">АГ</div>
|
||||||
|
<div class="head-meta">
|
||||||
|
<div class="head-name">Spring Boot · настройка логов</div>
|
||||||
|
<div class="head-sub"><span class="dot">●</span> на связи · agentik</div>
|
||||||
|
</div>
|
||||||
|
<button class="icon-btn hi-btn" title="Параметры диалога"><svg viewBox="0 0 24 24"><circle cx="12" cy="5" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="12" cy="19" r="2"/></svg></button>
|
||||||
|
</header>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ============ 20 · параметры диалога · обычный ============ -->
|
||||||
|
<section class="screen">
|
||||||
|
<div class="lbl"><b>20 · параметры диалога · обычный</b> чекбокс выключен и задизейблен (<code>isTemporal=false</code>). Открывается по <code>⋯</code> в шапке чата</div>
|
||||||
|
<div class="dialog">
|
||||||
|
<div class="d-head">
|
||||||
|
<h2>Spring Boot · настройка логов</h2>
|
||||||
|
<button class="x-btn">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="d-body">
|
||||||
|
<div class="field">
|
||||||
|
<label>Название</label>
|
||||||
|
<input type="text" value="Spring Boot · настройка логов">
|
||||||
|
<div class="note">Пустое имя — диалог покажется как «Без названия».</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label style="display:block;font-size:12px;color:var(--dim);margin:0 0 6px">Агент</label>
|
||||||
|
<div class="agent-card" style="margin-bottom:0">
|
||||||
|
<div class="avatar" style="background:var(--accent);color:var(--on-accent)">АГ</div>
|
||||||
|
<div class="ag-mid">
|
||||||
|
<div class="ag-name">agentik</div>
|
||||||
|
<div class="ag-addr">http://192.168.88.135:8080/agentik</div>
|
||||||
|
</div>
|
||||||
|
<div class="state"><span class="d"></span>на связи</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="checkbox-row off" style="margin-top:14px">
|
||||||
|
<div class="box"></div>
|
||||||
|
<div class="mid">
|
||||||
|
<div class="lbl">Временный диалог</div>
|
||||||
|
<div class="desc">Задаётся только при создании. Изменить временность существующего диалога нельзя.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="probe-list">
|
||||||
|
<div><span>Создан</span><b>1 окт 2026, 22:07</b></div>
|
||||||
|
<div><span>Обновлён</span><b>5 окт 2026, 11:30</b></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="danger-zone">
|
||||||
|
<div class="lbl">Опасное</div>
|
||||||
|
<button class="btn dang" style="width:100%">Удалить диалог</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-foot">
|
||||||
|
<button class="btn" style="flex:1">Закрыть</button>
|
||||||
|
<button class="btn prim" style="flex:1">Сохранить</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ============ 21 · параметры диалога · временный ============ -->
|
||||||
|
<section class="screen">
|
||||||
|
<div class="lbl"><b>21 · параметры диалога · временный</b> чекбокс включён и задизейблен (<code>isTemporal=true</code>)</div>
|
||||||
|
<div class="dialog">
|
||||||
|
<div class="d-head">
|
||||||
|
<h2>Черновик ТЗ</h2>
|
||||||
|
<button class="x-btn">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="d-body">
|
||||||
|
<div class="field">
|
||||||
|
<label>Название</label>
|
||||||
|
<input type="text" value="Черновик ТЗ">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label style="display:block;font-size:12px;color:var(--dim);margin:0 0 6px">Агент</label>
|
||||||
|
<div class="agent-card" style="margin-bottom:0">
|
||||||
|
<div class="avatar" style="background:#C9A886;color:var(--on-accent)">КО</div>
|
||||||
|
<div class="ag-mid">
|
||||||
|
<div class="ag-name">coder</div>
|
||||||
|
<div class="ag-addr">http://192.168.76.199:8080/agentik</div>
|
||||||
|
</div>
|
||||||
|
<div class="state"><span class="d"></span>на связи</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="checkbox-row on off" style="margin-top:14px">
|
||||||
|
<div class="box"></div>
|
||||||
|
<div class="mid">
|
||||||
|
<div class="lbl">Временный диалог</div>
|
||||||
|
<div class="desc">История не сохраняется на сервере.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="probe-list">
|
||||||
|
<div><span>Создан</span><b>только что</b></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="danger-zone">
|
||||||
|
<div class="lbl">Опасное</div>
|
||||||
|
<button class="btn dang" style="width:100%">Удалить диалог</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-foot">
|
||||||
|
<button class="btn" style="flex:1">Закрыть</button>
|
||||||
|
<button class="btn prim" style="flex:1">Сохранить</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ============ 22 · параметры диалога · без названия ============ -->
|
||||||
|
<section class="screen">
|
||||||
|
<div class="lbl"><b>22 · параметры диалога · без названия</b> пустое имя</div>
|
||||||
|
<div class="dialog">
|
||||||
|
<div class="d-head">
|
||||||
|
<h2>Без названия</h2>
|
||||||
|
<button class="x-btn">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="d-body">
|
||||||
|
<div class="field">
|
||||||
|
<label>Название</label>
|
||||||
|
<input type="text" value="" placeholder="как назвать диалог">
|
||||||
|
<div class="note">Пока имя не задано, диалог показывается как «Без названия».</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label style="display:block;font-size:12px;color:var(--dim);margin:0 0 6px">Агент</label>
|
||||||
|
<div class="agent-card" style="margin-bottom:0">
|
||||||
|
<div class="avatar" style="background:#86C99A;color:var(--on-accent)">ДМ</div>
|
||||||
|
<div class="ag-mid">
|
||||||
|
<div class="ag-name">home-assistant</div>
|
||||||
|
<div class="ag-addr">http://192.168.76.199:8080/agentik</div>
|
||||||
|
</div>
|
||||||
|
<div class="state err"><span class="d"></span>не отвечает</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="checkbox-row off" style="margin-top:14px">
|
||||||
|
<div class="box"></div>
|
||||||
|
<div class="mid">
|
||||||
|
<div class="lbl">Временный диалог</div>
|
||||||
|
<div class="desc">Задаётся только при создании. Изменить временность существующего диалога нельзя.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="probe-list">
|
||||||
|
<div><span>Создан</span><b>сегодня, 09:11</b></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="danger-zone">
|
||||||
|
<div class="lbl">Опасное</div>
|
||||||
|
<button class="btn dang" style="width:100%">Удалить диалог</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-foot">
|
||||||
|
<button class="btn" style="flex:1">Закрыть</button>
|
||||||
|
<button class="btn prim" style="flex:1">Сохранить</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ============ 23 · параметры диалога · ошибка сохранения ============ -->
|
||||||
|
<section class="screen">
|
||||||
|
<div class="lbl"><b>23 · параметры диалога · ошибка</b> сервер не принял имя — warn + «Повторить»</div>
|
||||||
|
<div class="dialog">
|
||||||
|
<div class="d-head">
|
||||||
|
<h2>Spring Boot · настройка логов</h2>
|
||||||
|
<button class="x-btn">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="d-body">
|
||||||
|
<div class="field">
|
||||||
|
<label>Название</label>
|
||||||
|
<input type="text" value="Spring Boot · настройка логов">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="warn">
|
||||||
|
<span class="i">⚠</span>
|
||||||
|
<span>Не удалось сохранить имя: агент не отвечает. Проверьте связь и попробуйте снова.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="danger-zone">
|
||||||
|
<div class="lbl">Опасное</div>
|
||||||
|
<button class="btn dang" style="width:100%">Удалить диалог</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-foot">
|
||||||
|
<button class="btn" style="flex:1">Закрыть</button>
|
||||||
|
<button class="btn prim" style="flex:1">Повторить</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ============ 24 · удаление диалога · подтверждение ============ -->
|
||||||
|
<section class="screen">
|
||||||
|
<div class="lbl"><b>24 · удаление диалога · подтверждение</b> открывается по «Удалить диалог» из 20–23. Действие необратимо — warn + красный CTA</div>
|
||||||
|
<div class="dialog">
|
||||||
|
<div class="d-head">
|
||||||
|
<h2>Удалить диалог?</h2>
|
||||||
|
<button class="x-btn">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="d-body">
|
||||||
|
<div class="warn">
|
||||||
|
<span class="i">⚠</span>
|
||||||
|
<span>Диалог <b>«Spring Boot · настройка логов»</b> и вся его история будут
|
||||||
|
безвозвратно удалены с сервера. Это действие нельзя отменить.</span>
|
||||||
|
</div>
|
||||||
|
<div class="info">
|
||||||
|
<span>ℹ</span>
|
||||||
|
<span>Корзины нет. Если передумаете — диалог придётся создавать заново, и
|
||||||
|
контекст будет потерян.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-foot">
|
||||||
|
<button class="btn" style="flex:1">Отмена</button>
|
||||||
|
<button class="btn dang solid" style="flex:1">Удалить</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
+44
-17
@@ -144,12 +144,12 @@ body {
|
|||||||
.rec-btn.rec-send:hover { background: #82BEF5; }
|
.rec-btn.rec-send:hover { background: #82BEF5; }
|
||||||
|
|
||||||
.composer-row { display: flex; align-items: center; gap: 10px; }
|
.composer-row { display: flex; align-items: center; gap: 10px; }
|
||||||
.field {
|
.composer-input {
|
||||||
flex: 1; min-width: 0; height: 46px; border-radius: 13px; background: var(--bg);
|
flex: 1; min-width: 0; height: 46px; border-radius: 13px; background: var(--bg);
|
||||||
border: 1px solid var(--line); display: flex; align-items: center; padding: 0 14px;
|
border: 1px solid var(--line); display: flex; align-items: center; padding: 0 14px;
|
||||||
font-size: 13.5px; color: var(--dim);
|
font-size: 13.5px; color: var(--dim);
|
||||||
}
|
}
|
||||||
.field.focus { border-color: var(--accent); }
|
.composer-input.focus { border-color: var(--accent); }
|
||||||
.mic {
|
.mic {
|
||||||
width: 52px; height: 52px; flex: 0 0 52px; border-radius: 50%;
|
width: 52px; height: 52px; flex: 0 0 52px; border-radius: 50%;
|
||||||
border: 2px solid var(--accent); background: var(--bg); color: var(--accent);
|
border: 2px solid var(--accent); background: var(--bg); color: var(--accent);
|
||||||
@@ -359,15 +359,6 @@ body {
|
|||||||
.state.err { color: var(--err); } .state.err .d { background: var(--err); }
|
.state.err { color: var(--err); } .state.err .d { background: var(--err); }
|
||||||
.card-acts { display: flex; gap: 4px; flex: 0 0 auto; }
|
.card-acts { display: flex; gap: 4px; flex: 0 0 auto; }
|
||||||
|
|
||||||
.field { margin-bottom: 15px; }
|
|
||||||
.field label { display: block; font-size: 12px; color: var(--dim); margin-bottom: 6px; }
|
|
||||||
.field input {
|
|
||||||
width: 100%; padding: 10px 12px; border-radius: 10px; border: 1px solid var(--line);
|
|
||||||
background: var(--bg); color: var(--fg); font-size: 13.5px; outline: none; font-family: inherit;
|
|
||||||
}
|
|
||||||
.field input:focus { border-color: var(--accent); }
|
|
||||||
.field input.mono { font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: 12.5px; }
|
|
||||||
.field .note { font-size: 11px; color: var(--dim); margin-top: 6px; line-height: 1.5; }
|
|
||||||
.note code, .hint-line code { background: var(--panel2); padding: 1px 4px; border-radius: 4px;
|
.note code, .hint-line code { background: var(--panel2); padding: 1px 4px; border-radius: 4px;
|
||||||
font-size: 10.5px; }
|
font-size: 10.5px; }
|
||||||
.addr-row { display: flex; gap: 8px; }
|
.addr-row { display: flex; gap: 8px; }
|
||||||
@@ -441,7 +432,9 @@ body {
|
|||||||
.icon-btn { width: 30px; height: 30px; border-radius: 8px; border: none;
|
.icon-btn { width: 30px; height: 30px; border-radius: 8px; border: none;
|
||||||
background: transparent; color: var(--dim); cursor: pointer; display: grid; place-items: center;
|
background: transparent; color: var(--dim); cursor: pointer; display: grid; place-items: center;
|
||||||
font-size: 17px; }
|
font-size: 17px; }
|
||||||
|
.icon-btn svg { width: 18px; height: 18px; fill: currentColor; }
|
||||||
.icon-btn:hover { background: var(--panel2); color: var(--fg); }
|
.icon-btn:hover { background: var(--panel2); color: var(--fg); }
|
||||||
|
.hi-btn { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 8px; }
|
||||||
.search { padding: 8px 12px; border-radius: 9px; border: 1px solid var(--line);
|
.search { padding: 8px 12px; border-radius: 9px; border: 1px solid var(--line);
|
||||||
background: var(--bg); color: var(--fg); font-size: 13px; outline: none; font-family: inherit; }
|
background: var(--bg); color: var(--fg); font-size: 13px; outline: none; font-family: inherit; }
|
||||||
.search:focus { border-color: var(--accent); }
|
.search:focus { border-color: var(--accent); }
|
||||||
@@ -556,6 +549,34 @@ kbd { background: var(--panel2); border: 1px solid var(--line); border-radius: 4
|
|||||||
margin-bottom: 8px; padding-left: 2px; }
|
margin-bottom: 8px; padding-left: 2px; }
|
||||||
.asr-note .live { width: 6px; height: 6px; border-radius: 50%; background: var(--accent); }
|
.asr-note .live { width: 6px; height: 6px; border-radius: 50%; background: var(--accent); }
|
||||||
|
|
||||||
|
/* === чекбокс (используется в новом диалоге и в параметрах диалога) === */
|
||||||
|
.checkbox-row {
|
||||||
|
display: flex; align-items: center; gap: 11px;
|
||||||
|
padding: 11px 13px; border-radius: 10px;
|
||||||
|
background: var(--panel2); margin-bottom: 9px;
|
||||||
|
cursor: pointer; user-select: none;
|
||||||
|
}
|
||||||
|
.checkbox-row:hover { background: #202C38; }
|
||||||
|
.checkbox-row .box {
|
||||||
|
width: 20px; height: 20px; flex: 0 0 20px; border-radius: 5px;
|
||||||
|
border: 1.5px solid var(--line); background: var(--bg);
|
||||||
|
display: grid; place-items: center; position: relative;
|
||||||
|
}
|
||||||
|
.checkbox-row.on .box { background: var(--accent); border-color: var(--accent); }
|
||||||
|
.checkbox-row.on .box::after {
|
||||||
|
content: ""; position: absolute; left: 5px; top: 2px;
|
||||||
|
width: 6px; height: 11px;
|
||||||
|
border: solid var(--on-accent); border-width: 0 2px 2px 0;
|
||||||
|
transform: rotate(42deg);
|
||||||
|
}
|
||||||
|
.checkbox-row .mid { flex: 1; min-width: 0; }
|
||||||
|
.checkbox-row .lbl { font-size: 13px; color: var(--fg); }
|
||||||
|
.checkbox-row .desc { font-size: 11.5px; color: var(--dim); margin-top: 2px; line-height: 1.45; }
|
||||||
|
.checkbox-row.off { cursor: default; }
|
||||||
|
.checkbox-row.off:hover { background: var(--panel2); }
|
||||||
|
.checkbox-row.off .lbl { color: var(--dim); }
|
||||||
|
.checkbox-row.off .box { opacity: .55; }
|
||||||
|
|
||||||
/* === 005 · выбор ассистента (agent-opt) === */
|
/* === 005 · выбор ассистента (agent-opt) === */
|
||||||
.d-lead { font-size: 12.5px; color: var(--dim); margin-bottom: 12px; }
|
.d-lead { font-size: 12.5px; color: var(--dim); margin-bottom: 12px; }
|
||||||
.agent-opt { display: flex; align-items: center; gap: 12px; padding: 11px 12px;
|
.agent-opt { display: flex; align-items: center; gap: 12px; padding: 11px 12px;
|
||||||
@@ -602,9 +623,15 @@ kbd { background: var(--panel2); border: 1px solid var(--line); border-radius: 4
|
|||||||
.grp.sys { background: transparent; border: 1px dashed var(--line); }
|
.grp.sys { background: transparent; border: 1px dashed var(--line); }
|
||||||
.grp.sys .nm { color: var(--fg); }
|
.grp.sys .nm { color: var(--fg); }
|
||||||
.grp .lock { font-size: 11px; color: var(--dim); flex: 0 0 auto; }
|
.grp .lock { font-size: 11px; color: var(--dim); flex: 0 0 auto; }
|
||||||
.field { margin-bottom: 15px; }
|
.field { margin-bottom: 14px; }
|
||||||
.field label { display: block; font-size: 12px; color: var(--dim); margin-bottom: 6px; }
|
.field label { display: block; font-size: 12px; color: var(--dim); margin-bottom: 4px; }
|
||||||
.field input { width: 100%; padding: 10px 12px; border-radius: 10px; border: 1px solid var(--line);
|
.field input {
|
||||||
background: var(--bg); color: var(--fg); font-size: 13.5px; outline: none; font-family: inherit; }
|
width: 100%; padding: 6px 0; background: transparent; color: var(--fg);
|
||||||
.field input:focus { border-color: var(--accent); }
|
font-size: 14px; outline: none; font-family: inherit;
|
||||||
.field .note { font-size: 11px; color: var(--dim); margin-top: 6px; }
|
border: 0; border-bottom: 1px solid var(--line);
|
||||||
|
transition: border-color .12s ease;
|
||||||
|
}
|
||||||
|
.field input::placeholder { color: var(--dim); opacity: .55; }
|
||||||
|
.field input:focus { border-bottom-color: var(--accent); border-bottom-width: 2px; }
|
||||||
|
.field input.mono { font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: 12.5px; }
|
||||||
|
.field .note { font-size: 11px; color: var(--dim); margin-top: 6px; line-height: 1.5; }
|
||||||
@@ -35,16 +35,19 @@ import pw.binom.agentik.desktop.ui.screens.AgentRail
|
|||||||
import pw.binom.agentik.desktop.ui.screens.AgentsDialog
|
import pw.binom.agentik.desktop.ui.screens.AgentsDialog
|
||||||
import pw.binom.agentik.desktop.ui.screens.ChatPane
|
import pw.binom.agentik.desktop.ui.screens.ChatPane
|
||||||
import pw.binom.agentik.desktop.ui.screens.ConversationList
|
import pw.binom.agentik.desktop.ui.screens.ConversationList
|
||||||
|
import pw.binom.agentik.desktop.ui.screens.DialogInfoDialog
|
||||||
import pw.binom.agentik.desktop.ui.screens.GroupsDialog
|
import pw.binom.agentik.desktop.ui.screens.GroupsDialog
|
||||||
import pw.binom.agentik.desktop.ui.screens.NewChatDialog
|
import pw.binom.agentik.desktop.ui.screens.NewChatDialog
|
||||||
import pw.binom.agentik.desktop.ui.theme.AgentikTheme
|
import pw.binom.agentik.desktop.ui.theme.AgentikTheme
|
||||||
import pw.binom.agentik.desktop.ui.theme.ProvideAgentikTheme
|
import pw.binom.agentik.desktop.ui.theme.ProvideAgentikTheme
|
||||||
import pw.binom.agentik.desktop.ui.theme.toAgentikTheme
|
import pw.binom.agentik.desktop.ui.theme.toAgentikTheme
|
||||||
|
import pw.binom.agentik.desktop.session.AgentStatus
|
||||||
|
|
||||||
private sealed interface AppDialog {
|
private sealed interface AppDialog {
|
||||||
data object Agents : AppDialog
|
data object Agents : AppDialog
|
||||||
data object Groups : AppDialog
|
data object Groups : AppDialog
|
||||||
data object NewChat : AppDialog
|
data object NewChat : AppDialog
|
||||||
|
data object DialogInfo : AppDialog
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -68,6 +71,15 @@ private fun Root(state: AppState) {
|
|||||||
var dialog by remember { mutableStateOf<AppDialog?>(null) }
|
var dialog by remember { mutableStateOf<AppDialog?>(null) }
|
||||||
var selectedGroupId by remember { mutableStateOf<Long?>(null) }
|
var selectedGroupId by remember { mutableStateOf<Long?>(null) }
|
||||||
|
|
||||||
|
// Кнопка ⋯ в шапке чата выставляет infoDialogOpen = true;
|
||||||
|
// синхронизируем это с роутером модалок.
|
||||||
|
LaunchedEffect(state.infoDialogOpen, state.activeConversationId) {
|
||||||
|
if (state.infoDialogOpen && state.activeConversationId != null && dialog == null) {
|
||||||
|
dialog = AppDialog.DialogInfo
|
||||||
|
state.consumeDialogInfoRequest()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
BoxWithConstraints(Modifier.fillMaxSize().background(colors.background)) {
|
BoxWithConstraints(Modifier.fillMaxSize().background(colors.background)) {
|
||||||
val wide = maxWidth >= 900.dp
|
val wide = maxWidth >= 900.dp
|
||||||
val hasChat = state.activeConversationId != null
|
val hasChat = state.activeConversationId != null
|
||||||
@@ -149,6 +161,37 @@ private fun Root(state: AppState) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
AppDialog.DialogInfo -> {
|
||||||
|
val conversation = state.activeConversation
|
||||||
|
if (conversation != null) {
|
||||||
|
Overlay(onDismiss = { dialog = null; state.closeDialogInfo() }) {
|
||||||
|
DialogInfoDialog(
|
||||||
|
conversation = conversation,
|
||||||
|
agentName = state.activeAgent?.let { state.agentDisplayName(it) },
|
||||||
|
agentAddress = state.activeAgent?.baseUrl,
|
||||||
|
agentColor = state.activeAgent?.let { state.agentColor(it) },
|
||||||
|
online = state.activeStatus is AgentStatus.Online,
|
||||||
|
onClose = {
|
||||||
|
dialog = null
|
||||||
|
state.closeDialogInfo()
|
||||||
|
},
|
||||||
|
onRename = { newTitle, cb ->
|
||||||
|
val id = conversation.id
|
||||||
|
state.renameConversation(id, newTitle, cb)
|
||||||
|
},
|
||||||
|
onDelete = {
|
||||||
|
val id = conversation.id
|
||||||
|
state.deleteConversation(id)
|
||||||
|
dialog = null
|
||||||
|
state.closeDialogInfo()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
dialog = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
null -> Unit
|
null -> Unit
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,6 +123,29 @@ class AppState(
|
|||||||
/** Каталог агентов. */
|
/** Каталог агентов. */
|
||||||
val agents: List<AgentConfig> get() = settingsState.agents
|
val agents: List<AgentConfig> get() = settingsState.agents
|
||||||
|
|
||||||
|
/** Открытый диалог как `UiConversation` (или `null`, если ничего не выбрано). */
|
||||||
|
val activeConversation: UiConversation?
|
||||||
|
get() = activeConversationId?.let { id -> conversations.firstOrNull { it.id == id } }
|
||||||
|
|
||||||
|
/** Состояние модалки «Информация о диалоге». */
|
||||||
|
var infoDialogOpen by mutableStateOf(false)
|
||||||
|
private set
|
||||||
|
|
||||||
|
/** Открыть модалку информации по активному диалогу. */
|
||||||
|
fun openDialogInfo() {
|
||||||
|
if (activeConversationId != null) infoDialogOpen = true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Закрыть модалку информации. */
|
||||||
|
fun closeDialogInfo() {
|
||||||
|
infoDialogOpen = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Сбросить флаг «хотим открыть модалку» (после того как роутер её открыл). */
|
||||||
|
fun consumeDialogInfoRequest() {
|
||||||
|
infoDialogOpen = false
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Отображаемое имя агента: локальное переименование → серверное
|
* Отображаемое имя агента: локальное переименование → серверное
|
||||||
* `AgentInfo.name` → адрес (пока не подключились).
|
* `AgentInfo.name` → адрес (пока не подключились).
|
||||||
@@ -334,11 +357,21 @@ class AppState(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun renameConversation(id: String, title: String?) {
|
fun renameConversation(
|
||||||
val conn = activeAgentId?.let { registry.connection(it) } ?: return
|
id: String,
|
||||||
|
title: String?,
|
||||||
|
onResult: (Result<Unit>) -> Unit = {},
|
||||||
|
) {
|
||||||
|
val conn = activeAgentId?.let { registry.connection(it) }
|
||||||
|
if (conn == null) {
|
||||||
|
onResult(Result.failure(IllegalStateException("Нет соединения с агентом")))
|
||||||
|
return
|
||||||
|
}
|
||||||
scope.launch {
|
scope.launch {
|
||||||
runCatching { conn.renameConversation(id, title) }.onFailure { reportError(it) }
|
val result = runCatching { conn.renameConversation(id, title) }
|
||||||
|
result.onFailure { reportError(it, "Не удалось переименовать") }
|
||||||
refresh()
|
refresh()
|
||||||
|
onResult(result.map { })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -420,7 +420,7 @@ internal fun DialogHeader(title: String, onClose: () -> Unit) {
|
|||||||
@Composable
|
@Composable
|
||||||
internal fun DialogFooter(content: @Composable androidx.compose.foundation.layout.RowScope.() -> Unit) {
|
internal fun DialogFooter(content: @Composable androidx.compose.foundation.layout.RowScope.() -> Unit) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth().padding(top = 12.dp, start = 18.dp, end = 18.dp),
|
modifier = Modifier.fillMaxWidth().padding(top = 12.dp, start = 18.dp, end = 18.dp, bottom = 6.dp),
|
||||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
) { this.content() }
|
) { this.content() }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import androidx.compose.foundation.text.KeyboardOptions
|
|||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
import androidx.compose.material.icons.automirrored.filled.Send
|
import androidx.compose.material.icons.automirrored.filled.Send
|
||||||
|
import androidx.compose.material.icons.filled.MoreVert
|
||||||
import androidx.compose.material.icons.filled.Mic
|
import androidx.compose.material.icons.filled.Mic
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
@@ -106,8 +107,34 @@ fun ChatPane(
|
|||||||
size = 36.dp,
|
size = 36.dp,
|
||||||
)
|
)
|
||||||
Column(Modifier.weight(1f)) {
|
Column(Modifier.weight(1f)) {
|
||||||
Text(agentName, color = colors.text, fontSize = 14.sp)
|
val conversationTitle = state.activeConversation?.title?.takeIf { it.isNotBlank() }
|
||||||
ConnectionLabel(state.activeStatus)
|
?: "Без названия"
|
||||||
|
Text(
|
||||||
|
text = conversationTitle,
|
||||||
|
color = colors.text,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold,
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
) {
|
||||||
|
ConnectionLabel(state.activeStatus)
|
||||||
|
if (agentName.isNotEmpty()) {
|
||||||
|
Text("·", color = colors.textDim, fontSize = 11.5.sp)
|
||||||
|
Text(agentName, color = colors.textDim, fontSize = 11.5.sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
IconButton(
|
||||||
|
onClick = { state.openDialogInfo() },
|
||||||
|
enabled = state.activeConversation != null,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.MoreVert,
|
||||||
|
contentDescription = "Параметры диалога",
|
||||||
|
tint = colors.textDim,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
if (state.thinking) {
|
if (state.thinking) {
|
||||||
IconButton(onClick = { state.stop() }) {
|
IconButton(onClick = { state.stop() }) {
|
||||||
|
|||||||
@@ -0,0 +1,436 @@
|
|||||||
|
package pw.binom.agentik.desktop.ui.screens
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.layout.requiredHeight
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import pw.binom.agentik.desktop.model.UiConversation
|
||||||
|
import pw.binom.agentik.desktop.model.UNTITLED_CONVERSATION
|
||||||
|
import pw.binom.agentik.desktop.ui.components.AgentAvatar
|
||||||
|
import pw.binom.agentik.desktop.ui.theme.AgentikTheme
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Модалка «Параметры диалога»: правка имени, просмотр обслуживающего агента
|
||||||
|
* и его статуса, признака «временный», дат создания/обновления, удаление
|
||||||
|
* диалога. Создание идёт через [NewChatDialog]. Одобренный макет —
|
||||||
|
* `sketches/index.html` секции 20–23.
|
||||||
|
*
|
||||||
|
* @param conversation диалог, по которому показываем информацию
|
||||||
|
* @param agentName отображаемое имя обслуживающего агента (или `null`,
|
||||||
|
* если диалог оказался на офлайн-агенте)
|
||||||
|
* @param agentAddress базовый URL агента (для подписи в карточке)
|
||||||
|
* @param agentColor цвет агента для аватара
|
||||||
|
* @param online жив ли агент прямо сейчас
|
||||||
|
* @param onClose закрыть без изменений
|
||||||
|
* @param onRename `onRename(newTitle ?: null, cb)` — `cb(Result)` зовётся,
|
||||||
|
* когда сервер ответил (или бросил)
|
||||||
|
* @param onDelete удалить диалог
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun DialogInfoDialog(
|
||||||
|
conversation: UiConversation,
|
||||||
|
agentName: String?,
|
||||||
|
agentAddress: String?,
|
||||||
|
agentColor: Color?,
|
||||||
|
online: Boolean,
|
||||||
|
onClose: () -> Unit,
|
||||||
|
onRename: (String?, (Result<Unit>) -> Unit) -> Unit,
|
||||||
|
onDelete: () -> Unit,
|
||||||
|
) {
|
||||||
|
val colors = AgentikTheme.colors
|
||||||
|
// Сервер даёт серверный дефолт `UNTITLED_CONVERSATION` («Новый диалог»)
|
||||||
|
// если пользователь не задал имя; в модалке это эквивалентно «без имени».
|
||||||
|
val titleInitial = conversation.title?.takeIf { it.isNotBlank() && it != UNTITLED_CONVERSATION }
|
||||||
|
var editing by remember { mutableStateOf(titleInitial == null) }
|
||||||
|
var draft by remember { mutableStateOf(titleInitial ?: "") }
|
||||||
|
var isSaving by remember { mutableStateOf(false) }
|
||||||
|
var saveError by remember { mutableStateOf<String?>(null) }
|
||||||
|
val focusRequester = remember { FocusRequester() }
|
||||||
|
|
||||||
|
LaunchedEffect(editing) {
|
||||||
|
if (editing) {
|
||||||
|
focusRequester.requestFocus()
|
||||||
|
saveError = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DialogCard(width = 480.dp) {
|
||||||
|
DialogHeader(
|
||||||
|
title = "Параметры диалога",
|
||||||
|
onClose = onClose,
|
||||||
|
)
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(horizontal = 18.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||||
|
) {
|
||||||
|
// ─── Название (input всегда виден; редактируется по клику на ✎) ───
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.Top,
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
Text(
|
||||||
|
text = "Название",
|
||||||
|
color = colors.textDim,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
)
|
||||||
|
if (editing) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = draft,
|
||||||
|
onValueChange = { v ->
|
||||||
|
val next = v.take(120)
|
||||||
|
draft = next
|
||||||
|
saveError = null
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.focusRequester(focusRequester),
|
||||||
|
singleLine = true,
|
||||||
|
placeholder = {
|
||||||
|
Text(
|
||||||
|
text = "как назвать диалог",
|
||||||
|
color = colors.textDim,
|
||||||
|
fontSize = 13.sp,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||||
|
// Пустое имя допустимо — сервер вернёт title=null,
|
||||||
|
// а клиентский маппер покажет его как «Новый диалог».
|
||||||
|
// Подсвечиваем красным только реальную ошибку — перебор по длине.
|
||||||
|
isError = draft.length > 120,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "До 120 символов",
|
||||||
|
color = colors.textDim,
|
||||||
|
fontSize = 11.sp,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text(
|
||||||
|
text = titleInitial ?: "Без названия",
|
||||||
|
color = if (titleInitial == null) colors.textDim else colors.text,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontStyle = if (titleInitial == null) FontStyle.Italic else null,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
modifier = Modifier.clickable(
|
||||||
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
|
indication = null,
|
||||||
|
) { editing = true },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
saveError?.let { msg ->
|
||||||
|
Text(text = msg, color = colors.error, fontSize = 11.5.sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!editing) {
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
text = "✎",
|
||||||
|
color = colors.accent,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
modifier = Modifier
|
||||||
|
.clip(CircleShape)
|
||||||
|
.clickable(
|
||||||
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
|
indication = null,
|
||||||
|
) { editing = true }
|
||||||
|
.padding(6.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Карточка агента ───
|
||||||
|
Text(text = "Агент", color = colors.textDim, fontSize = 12.sp)
|
||||||
|
if (agentName != null) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(RoundedCornerShape(10.dp))
|
||||||
|
.background(colors.panelElevated)
|
||||||
|
.padding(10.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
) {
|
||||||
|
AgentAvatar(
|
||||||
|
displayName = agentName,
|
||||||
|
color = agentColor ?: colors.accent,
|
||||||
|
avatarPath = null,
|
||||||
|
size = 32.dp,
|
||||||
|
)
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(agentName, color = colors.text, fontSize = 13.sp, fontWeight = FontWeight.Medium)
|
||||||
|
if (agentAddress != null) {
|
||||||
|
Text(
|
||||||
|
text = agentAddress,
|
||||||
|
color = colors.textDim,
|
||||||
|
fontSize = 11.sp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = if (online) "на связи" else "не отвечает",
|
||||||
|
color = if (online) colors.ok else colors.error,
|
||||||
|
fontSize = 11.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text(
|
||||||
|
text = "Агент недоступен (диалог будет жить до закрытия приложения).",
|
||||||
|
color = colors.textDim,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Чекбокс «Временный диалог» (всегда readonly — нет операции на бэке) ───
|
||||||
|
TemporalCheckbox(temporal = conversation.isTemporal)
|
||||||
|
|
||||||
|
// ─── Probe-list: даты ───
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
InfoRow(label = "Создан", value = formatDateTime(conversation.createdAt))
|
||||||
|
InfoRow(label = "Обновлён", value = formatDateTime(conversation.updatedAt))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Опасная зона: удалить диалог ───
|
||||||
|
Text(
|
||||||
|
text = "Опасное",
|
||||||
|
color = colors.error,
|
||||||
|
fontSize = 11.sp,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
)
|
||||||
|
Button(
|
||||||
|
onClick = onDelete,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = colors.error.copy(alpha = 0.18f),
|
||||||
|
contentColor = colors.error,
|
||||||
|
),
|
||||||
|
) { Text("Удалить диалог") }
|
||||||
|
}
|
||||||
|
DialogFooter {
|
||||||
|
when {
|
||||||
|
editing -> {
|
||||||
|
val trimmed = draft.trim()
|
||||||
|
// Голый Box+clickable вместо Material Button/OutlinedButton —
|
||||||
|
// Material3 в этой версии Compose (1.7.3) стабильно сжимает
|
||||||
|
// высоту кнопки внутри Row.weight(1f), и текст обрезается.
|
||||||
|
// Box с явным height(44dp) + кликабельностью решает это
|
||||||
|
// начисто.
|
||||||
|
val btnShape = RoundedCornerShape(10.dp)
|
||||||
|
// В edit-mode «Отмена» ведёт себя по-разному:
|
||||||
|
// • если у диалога нет имени (`titleInitial == null`) — мы
|
||||||
|
// открыли модалку сразу в edit-mode, view-mode здесь
|
||||||
|
// бессмысленен (там было бы «Без названия» italic).
|
||||||
|
// Поэтому «Отмена» закрывает модалку через `onClose`.
|
||||||
|
// • если имя было — пользователь сам зашёл в edit через ✎,
|
||||||
|
// «Отмена» возвращает в view-mode.
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.requiredHeight(44.dp)
|
||||||
|
.clip(btnShape)
|
||||||
|
.border(1.dp, colors.line, btnShape)
|
||||||
|
.clickable(enabled = !isSaving) {
|
||||||
|
if (titleInitial == null) {
|
||||||
|
onClose()
|
||||||
|
} else {
|
||||||
|
editing = false
|
||||||
|
draft = titleInitial ?: ""
|
||||||
|
saveError = null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"Отмена",
|
||||||
|
color = colors.text,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val saveEnabled = !isSaving && trimmed.length <= 120
|
||||||
|
val saveLabel = when {
|
||||||
|
isSaving -> "Сохранение…"
|
||||||
|
saveError != null -> "Повторить"
|
||||||
|
else -> "Сохранить"
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.requiredHeight(44.dp)
|
||||||
|
.clip(btnShape)
|
||||||
|
.background(if (saveEnabled) colors.accent else colors.accent.copy(alpha = 0.4f))
|
||||||
|
.clickable(enabled = saveEnabled) {
|
||||||
|
if (trimmed.length > 120) {
|
||||||
|
saveError = "Имя должно быть не длиннее 120 символов"
|
||||||
|
return@clickable
|
||||||
|
}
|
||||||
|
// Имя совпадает с текущим — оптимизация, сети не дёргаем.
|
||||||
|
if (trimmed == titleInitial) {
|
||||||
|
editing = false
|
||||||
|
saveError = null
|
||||||
|
return@clickable
|
||||||
|
}
|
||||||
|
// Пустое имя → null на сервер: клиентский маппер заменит
|
||||||
|
// null обратно на UNTITLED_CONVERSATION («Новый диалог»).
|
||||||
|
val next: String? = trimmed.takeIf { it.isNotEmpty() }
|
||||||
|
isSaving = true
|
||||||
|
saveError = null
|
||||||
|
onRename(next) { result ->
|
||||||
|
isSaving = false
|
||||||
|
result.onSuccess {
|
||||||
|
editing = false
|
||||||
|
}.onFailure { t ->
|
||||||
|
saveError = t.message ?: "Не удалось сохранить"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
saveLabel,
|
||||||
|
color = colors.onAccent,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
val btnShape = RoundedCornerShape(10.dp)
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.requiredHeight(44.dp)
|
||||||
|
.clip(btnShape)
|
||||||
|
.background(colors.accent)
|
||||||
|
.clickable(onClick = onClose),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"Закрыть",
|
||||||
|
color = colors.onAccent,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Чекбокс «Временный диалог»: значение только для чтения, выставляется при
|
||||||
|
* создании (на бэке нет операции смены `isTemporal` — см. секцию 20 макета).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun TemporalCheckbox(temporal: Boolean) {
|
||||||
|
val colors = AgentikTheme.colors
|
||||||
|
val accent = colors.accent
|
||||||
|
val onAccent = colors.onAccent
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(RoundedCornerShape(10.dp))
|
||||||
|
.background(colors.panelElevated)
|
||||||
|
.padding(10.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(20.dp)
|
||||||
|
.clip(RoundedCornerShape(5.dp))
|
||||||
|
.background(if (temporal) accent else colors.background)
|
||||||
|
.border(1.dp, if (temporal) accent else colors.line, RoundedCornerShape(5.dp)),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
if (temporal) {
|
||||||
|
Text("✓", color = onAccent, fontSize = 13.sp, fontWeight = FontWeight.Bold)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.height(20.dp),
|
||||||
|
contentAlignment = Alignment.CenterStart,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Временный диалог",
|
||||||
|
color = colors.text,
|
||||||
|
fontSize = 13.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun InfoRow(label: String, value: String) {
|
||||||
|
val colors = AgentikTheme.colors
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(text = label, color = colors.textDim, fontSize = 12.sp)
|
||||||
|
Text(text = value, color = colors.text, fontSize = 12.sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Полный формат даты-времени для информационных строк (`dd.MM.yyyy HH:mm`,
|
||||||
|
* системная тайм-зона).
|
||||||
|
*/
|
||||||
|
private val dateTimeFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Форматирует [instant] в локальное время пользователя (`dd.MM.yyyy HH:mm`).
|
||||||
|
* Отделено от [formatMessageTime], чтобы UI-слой не таскал лишние импорты.
|
||||||
|
*/
|
||||||
|
fun formatDateTime(instant: kotlin.time.Instant): String {
|
||||||
|
val zone = ZoneId.systemDefault()
|
||||||
|
val ldt = LocalDateTime.ofInstant(java.time.Instant.ofEpochMilli(instant.toEpochMilliseconds()), zone)
|
||||||
|
return dateTimeFormatter.format(ldt)
|
||||||
|
}
|
||||||
@@ -0,0 +1,406 @@
|
|||||||
|
package pw.binom.agentik.desktop.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.toPixelMap
|
||||||
|
import androidx.compose.ui.test.ExperimentalTestApi
|
||||||
|
import androidx.compose.ui.test.assertTextEquals
|
||||||
|
import androidx.compose.ui.test.captureToImage
|
||||||
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
|
import androidx.compose.ui.test.onRoot
|
||||||
|
import androidx.compose.ui.test.performClick
|
||||||
|
import androidx.compose.ui.test.performTextReplacement
|
||||||
|
import androidx.compose.ui.test.runComposeUiTest
|
||||||
|
import pw.binom.agentik.desktop.media.AvatarStorage
|
||||||
|
import pw.binom.agentik.desktop.model.UiConversation
|
||||||
|
import pw.binom.agentik.desktop.model.UNTITLED_CONVERSATION
|
||||||
|
import pw.binom.agentik.desktop.ui.components.LocalAvatarStorage
|
||||||
|
import pw.binom.agentik.desktop.ui.screens.DialogInfoDialog
|
||||||
|
import pw.binom.agentik.desktop.ui.screens.formatDateTime
|
||||||
|
import pw.binom.agentik.desktop.ui.theme.ProvideAgentikTheme
|
||||||
|
import pw.binom.agentik.desktop.ui.theme.darkAgentikColors
|
||||||
|
import pw.binom.agentik.desktop.ui.theme.toAgentikTheme
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertFalse
|
||||||
|
import kotlin.test.assertNull
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Модалка «Параметры диалога»: контракт и регрессии.
|
||||||
|
*
|
||||||
|
* Покрывает:
|
||||||
|
* - формат даты ([formatDateTime] — dd.MM.yyyy HH:mm, в системной тайм-зоне);
|
||||||
|
* - базовые секции присутствуют (название, агент, тип, даты, опасная зона);
|
||||||
|
* - кнопка ✎ переключает в режим редактирования, имя сохраняется через cb;
|
||||||
|
* - «безымянный» диалог (title == UNTITLED_CONVERSATION) сразу в edit-режиме;
|
||||||
|
* - чекбокс «Временный» отображает состояние, менять нельзя;
|
||||||
|
* - кнопка «Удалить диалог» триггерит onDelete;
|
||||||
|
* - ошибка сохранения показывает текст + переименовывает кнопку в «Повторить».
|
||||||
|
*/
|
||||||
|
class DialogInfoDialogTest {
|
||||||
|
|
||||||
|
// ── формат даты ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `formatDateTime renders dd MM yyyy HH mm`() {
|
||||||
|
val instant = Instant.parse("2026-10-05T11:30:00Z")
|
||||||
|
val rendered = formatDateTime(instant)
|
||||||
|
assertTrue(
|
||||||
|
rendered.matches(Regex("""\d{2}\.\d{2}\.\d{4} \d{2}:\d{2}""")),
|
||||||
|
"формат должен быть dd.MM.yyyy HH:mm, а получилось: $rendered",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `formatDateTime applies system zone offset`() {
|
||||||
|
// 2026-10-05 11:30 UTC -> в Москве (UTC+3, без перевода часов) 14:30.
|
||||||
|
// Проверяем, что форматирование учитывает системную тайм-зону — иначе
|
||||||
|
// час был бы равен UTC-часу в любой системе.
|
||||||
|
val instant = Instant.parse("2026-10-05T11:30:00Z")
|
||||||
|
val rendered = formatDateTime(instant)
|
||||||
|
val zone = java.time.ZoneId.systemDefault()
|
||||||
|
val offsetSeconds = zone.rules.getOffset(java.time.Instant.ofEpochMilli(instant.toEpochMilliseconds()))
|
||||||
|
.totalSeconds
|
||||||
|
val expectedHour = ((11 + offsetSeconds / 3600) + 24) % 24
|
||||||
|
// Проверяем, что в строке встречается « <час>:» — тогда смещение применено.
|
||||||
|
assertTrue(
|
||||||
|
rendered.contains(" ${expectedHour.toString().padStart(2, '0')}:"),
|
||||||
|
"должно учитываться смещение $zone (UTC${offsetSeconds / 3600} ч): ожидался час $expectedHour, получилось $rendered",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── рендер ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@OptIn(ExperimentalTestApi::class)
|
||||||
|
@Test
|
||||||
|
fun `dialog renders all required sections`() = runComposeUiTest {
|
||||||
|
val conversation = sampleConversation(title = "Spring Boot · настройка логов")
|
||||||
|
|
||||||
|
setContent {
|
||||||
|
withAvatarStorage {
|
||||||
|
ProvideAgentikTheme("dark".toAgentikTheme()) {
|
||||||
|
Box(Modifier.fillMaxSize().background(darkAgentikColors.background)) {
|
||||||
|
DialogInfoDialog(
|
||||||
|
conversation = conversation,
|
||||||
|
agentName = "agentik",
|
||||||
|
agentAddress = "http://192.168.88.135:8080/agentik",
|
||||||
|
agentColor = Color(0xFF6AB2F2),
|
||||||
|
online = true,
|
||||||
|
onClose = {},
|
||||||
|
onRename = { _, _ -> },
|
||||||
|
onDelete = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Заголовки секций
|
||||||
|
onNodeWithText("Название").assertTextEquals("Название")
|
||||||
|
onNodeWithText("Агент").assertTextEquals("Агент")
|
||||||
|
onNodeWithText("Временный диалог").assertTextEquals("Временный диалог")
|
||||||
|
onNodeWithText("Создан").assertTextEquals("Создан")
|
||||||
|
onNodeWithText("Обновлён").assertTextEquals("Обновлён")
|
||||||
|
onNodeWithText("Опасное").assertTextEquals("Опасное")
|
||||||
|
|
||||||
|
// Имя диалога (крупно) и имя агента
|
||||||
|
onNodeWithText("Spring Boot · настройка логов").assertTextEquals("Spring Boot · настройка логов")
|
||||||
|
onNodeWithText("agentik").assertTextEquals("agentik")
|
||||||
|
onNodeWithText("http://192.168.88.135:8080/agentik").assertTextEquals("http://192.168.88.135:8080/agentik")
|
||||||
|
// Статус «на связи»
|
||||||
|
onNodeWithText("на связи").assertTextEquals("на связи")
|
||||||
|
// Кнопка «Закрыть» в футере
|
||||||
|
onNodeWithText("Закрыть").assertTextEquals("Закрыть")
|
||||||
|
// Кнопка «Удалить диалог»
|
||||||
|
onNodeWithText("Удалить диалог").assertTextEquals("Удалить диалог")
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalTestApi::class)
|
||||||
|
@Test
|
||||||
|
fun `untitled conversation opens in edit mode by default`() = runComposeUiTest {
|
||||||
|
// Конвенция: `title == UNTITLED_CONVERSATION` означает «сервер не дал имя»,
|
||||||
|
// и модалка сразу переходит в edit-режим.
|
||||||
|
val conversation = sampleConversation(title = UNTITLED_CONVERSATION)
|
||||||
|
|
||||||
|
setContent {
|
||||||
|
withAvatarStorage {
|
||||||
|
ProvideAgentikTheme("dark".toAgentikTheme()) {
|
||||||
|
DialogInfoDialog(
|
||||||
|
conversation = conversation,
|
||||||
|
agentName = "agentik",
|
||||||
|
agentAddress = "http://x:8080/agentik",
|
||||||
|
agentColor = null,
|
||||||
|
online = true,
|
||||||
|
onClose = {},
|
||||||
|
onRename = { _, _ -> },
|
||||||
|
onDelete = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Режим редактирования открыт: видна подсказка «До 120 символов»,
|
||||||
|
// кнопка «Отмена» (а не «Закрыть»).
|
||||||
|
onNodeWithText("До 120 символов").assertTextEquals("До 120 символов")
|
||||||
|
onNodeWithText("Отмена").assertTextEquals("Отмена")
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalTestApi::class)
|
||||||
|
@Test
|
||||||
|
fun `pencil button enters edit mode and saving empty title passes null to onRename`() = runComposeUiTest {
|
||||||
|
val conversation = sampleConversation(title = "Spring Boot")
|
||||||
|
var renamedTo: String? = "sentinel"
|
||||||
|
|
||||||
|
setContent {
|
||||||
|
withAvatarStorage {
|
||||||
|
ProvideAgentikTheme("dark".toAgentikTheme()) {
|
||||||
|
DialogInfoDialog(
|
||||||
|
conversation = conversation,
|
||||||
|
agentName = null,
|
||||||
|
agentAddress = null,
|
||||||
|
agentColor = null,
|
||||||
|
online = false,
|
||||||
|
onClose = {},
|
||||||
|
onRename = { newTitle, cb ->
|
||||||
|
renamedTo = newTitle
|
||||||
|
cb(Result.success(Unit))
|
||||||
|
},
|
||||||
|
onDelete = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Режим просмотра → жмём ✎ → режим редактирования.
|
||||||
|
onNodeWithText("✎").performClick()
|
||||||
|
onNodeWithText("До 120 символов").assertTextEquals("До 120 символов")
|
||||||
|
|
||||||
|
// Стираем имя — пустое допустимо: сервер получит null, клиентский
|
||||||
|
// маппер отрисует его как UNTITLED_CONVERSATION.
|
||||||
|
onNodeWithText("Spring Boot").performTextReplacement("")
|
||||||
|
onNodeWithText("Сохранить").performClick()
|
||||||
|
assertNull(renamedTo, "пустое имя → null в onRename")
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalTestApi::class)
|
||||||
|
@Test
|
||||||
|
fun `saving unchanged title does NOT call onRename`() = runComposeUiTest {
|
||||||
|
val conversation = sampleConversation(title = "Spring Boot")
|
||||||
|
var renameCalled = false
|
||||||
|
|
||||||
|
setContent {
|
||||||
|
withAvatarStorage {
|
||||||
|
ProvideAgentikTheme("dark".toAgentikTheme()) {
|
||||||
|
DialogInfoDialog(
|
||||||
|
conversation = conversation,
|
||||||
|
agentName = null,
|
||||||
|
agentAddress = null,
|
||||||
|
agentColor = null,
|
||||||
|
online = false,
|
||||||
|
onClose = {},
|
||||||
|
onRename = { _, _ -> renameCalled = true },
|
||||||
|
onDelete = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onNodeWithText("✎").performClick()
|
||||||
|
// Не меняем — жмём «Сохранить»: оптимизация — сетевого вызова быть не должно.
|
||||||
|
onNodeWithText("Сохранить").performClick()
|
||||||
|
assertFalse(renameCalled, "если имя не меняли, onRename вызываться не должен")
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalTestApi::class)
|
||||||
|
@Test
|
||||||
|
fun `temporal dialog shows short hint`() = runComposeUiTest {
|
||||||
|
val conversation = sampleConversation(title = "Черновик ТЗ", temporal = true)
|
||||||
|
|
||||||
|
setContent {
|
||||||
|
withAvatarStorage {
|
||||||
|
ProvideAgentikTheme("dark".toAgentikTheme()) {
|
||||||
|
DialogInfoDialog(
|
||||||
|
conversation = conversation,
|
||||||
|
agentName = "coder",
|
||||||
|
agentAddress = "http://coder:8080/agentik",
|
||||||
|
agentColor = Color(0xFFC9A886),
|
||||||
|
online = true,
|
||||||
|
onClose = {},
|
||||||
|
onRename = { _, _ -> },
|
||||||
|
onDelete = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onNodeWithText("Временный диалог").assertTextEquals("Временный диалог")
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalTestApi::class)
|
||||||
|
@Test
|
||||||
|
fun `regular dialog shows long hint about immutability`() = runComposeUiTest {
|
||||||
|
val conversation = sampleConversation(title = "Обычный", temporal = false)
|
||||||
|
|
||||||
|
setContent {
|
||||||
|
withAvatarStorage {
|
||||||
|
ProvideAgentikTheme("dark".toAgentikTheme()) {
|
||||||
|
DialogInfoDialog(
|
||||||
|
conversation = conversation,
|
||||||
|
agentName = null,
|
||||||
|
agentAddress = null,
|
||||||
|
agentColor = null,
|
||||||
|
online = false,
|
||||||
|
onClose = {},
|
||||||
|
onRename = { _, _ -> },
|
||||||
|
onDelete = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onNodeWithText("Временный диалог").assertTextEquals("Временный диалог")
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalTestApi::class)
|
||||||
|
@Test
|
||||||
|
fun `rename failure shows error and retry label`() = runComposeUiTest {
|
||||||
|
val conversation = sampleConversation(title = "Имя")
|
||||||
|
setContent {
|
||||||
|
withAvatarStorage {
|
||||||
|
ProvideAgentikTheme("dark".toAgentikTheme()) {
|
||||||
|
var failedOnce by remember { mutableStateOf(false) }
|
||||||
|
DialogInfoDialog(
|
||||||
|
conversation = conversation,
|
||||||
|
agentName = null,
|
||||||
|
agentAddress = null,
|
||||||
|
agentColor = null,
|
||||||
|
online = false,
|
||||||
|
onClose = {},
|
||||||
|
onRename = { _, cb ->
|
||||||
|
if (!failedOnce) {
|
||||||
|
cb(Result.failure(RuntimeException("агент не отвечает")))
|
||||||
|
failedOnce = true
|
||||||
|
} else {
|
||||||
|
cb(Result.success(Unit))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDelete = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onNodeWithText("✎").performClick()
|
||||||
|
// Вводим реально новое имя, иначе оптимизация «имя не менялось» не пустит запрос.
|
||||||
|
onNodeWithText("Имя").performTextReplacement("Новое имя")
|
||||||
|
onNodeWithText("Сохранить").performClick()
|
||||||
|
onNodeWithText("агент не отвечает").assertTextEquals("агент не отвечает")
|
||||||
|
onNodeWithText("Повторить").assertTextEquals("Повторить")
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalTestApi::class)
|
||||||
|
@Test
|
||||||
|
fun `delete button fires onDelete`() = runComposeUiTest {
|
||||||
|
val conversation = sampleConversation(title = "X")
|
||||||
|
var deleted = false
|
||||||
|
|
||||||
|
setContent {
|
||||||
|
withAvatarStorage {
|
||||||
|
ProvideAgentikTheme("dark".toAgentikTheme()) {
|
||||||
|
DialogInfoDialog(
|
||||||
|
conversation = conversation,
|
||||||
|
agentName = null,
|
||||||
|
agentAddress = null,
|
||||||
|
agentColor = null,
|
||||||
|
online = false,
|
||||||
|
onClose = {},
|
||||||
|
onRename = { _, _ -> },
|
||||||
|
onDelete = { deleted = true },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onNodeWithText("Удалить диалог").performClick()
|
||||||
|
assertTrue(deleted, "onDelete должен сработать")
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalTestApi::class)
|
||||||
|
@Test
|
||||||
|
fun `pixels include red from delete button in dark theme`() = runComposeUiTest {
|
||||||
|
val conversation = sampleConversation(title = "X")
|
||||||
|
setContent {
|
||||||
|
withAvatarStorage {
|
||||||
|
ProvideAgentikTheme("dark".toAgentikTheme()) {
|
||||||
|
Box(Modifier.fillMaxSize().background(darkAgentikColors.background)) {
|
||||||
|
DialogInfoDialog(
|
||||||
|
conversation = conversation,
|
||||||
|
agentName = null,
|
||||||
|
agentAddress = null,
|
||||||
|
agentColor = null,
|
||||||
|
online = false,
|
||||||
|
onClose = {},
|
||||||
|
onRename = { _, _ -> },
|
||||||
|
onDelete = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val histogram = pixelHistogram(onRoot().captureToImage().toPixelMap())
|
||||||
|
val dangerPresent = histogram.keys.any { c ->
|
||||||
|
c.red > 0.7f && c.green < 0.4f && c.blue < 0.4f
|
||||||
|
}
|
||||||
|
assertTrue(
|
||||||
|
dangerPresent,
|
||||||
|
"в гистограмме должен быть пиксель красного: ${histogram.entries.sortedByDescending { it.value }.take(3)}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── helpers ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private fun sampleConversation(
|
||||||
|
title: String = "Диалог",
|
||||||
|
temporal: Boolean = false,
|
||||||
|
): UiConversation = UiConversation(
|
||||||
|
id = "conv-1",
|
||||||
|
title = title,
|
||||||
|
isTemporal = temporal,
|
||||||
|
createdAt = Instant.parse("2026-10-05T11:30:00Z"),
|
||||||
|
updatedAt = Instant.parse("2026-10-05T11:31:00Z"),
|
||||||
|
groupId = null,
|
||||||
|
preview = null,
|
||||||
|
unread = 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Оборачивает [content] в провайдер [AvatarStorage] с временной папкой.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun withAvatarStorage(content: @Composable () -> Unit) {
|
||||||
|
CompositionLocalProvider(LocalAvatarStorage provides AvatarStorage()) {
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun pixelHistogram(pixels: androidx.compose.ui.graphics.PixelMap): Map<Color, Int> {
|
||||||
|
val out = HashMap<Color, Int>()
|
||||||
|
for (y in 0 until pixels.height) {
|
||||||
|
for (x in 0 until pixels.width) {
|
||||||
|
val c = pixels[x, y]
|
||||||
|
out[c] = (out[c] ?: 0) + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user