Добавлен экран информации о диалоге: шапка с кнопкой ⋯, окно с редактированием названия, отображением агента, даты создания/обновления, признака временности и кнопкой удаления.

This commit is contained in:
2026-09-29 10:35:19 +03:00
parent e9f3fe96fe
commit 7a9f60274b
10 changed files with 1260 additions and 115 deletions
+5 -3
View File
@@ -81,9 +81,11 @@ pw.binom.agentik.desktop
- **Проверка связи** — «ответ сервера» + создание/удаление временного диалога,
с таймингами.
- **UI (Compose)** — рейка агентов, список диалогов (поиск, папки, бейджи),
чат с живым стримингом, композер, окна «Агенты», «Группы» и «Новый диалог»
(при нескольких агентах — выбор, при одном — сразу), пустые состояния,
адаптивная раскладка (узкое окно — список или чат).
чат с живым стримингом, композер, окна «Агенты», «Группы», «Новый диалог»
(при нескольких агентах — выбор, при одном — сразу) и «Параметры диалога»
(кнопка `⋯` в шапке чата: имя, обслуживающий агент, признак «Временный»,
даты создания/обновления, удаление), пустые состояния, адаптивная раскладка
(узкое окно — список или чат).
- **Голос** — микрофон → VAD → распознавание (`voice/`): кнопка микрофона
или `Ctrl+M` из чата запускает запись (`mic-api`), Silero VAD (`vad-kmp`) отмечает наличие речи,
Vosk (`asr-vosk`, модель внутри артефакта) отдаёт промежуточный текст над
+8
View File
@@ -82,6 +82,14 @@
## 4. Открытый диалог
- **R10.** Показ сообщений: мои и ассистента, с временем.
- **R10.1.** В шапке открытого диалога — **кнопка `⋯` (три вертикальные точки)**,
без визуальных границ, как в Telegram. Открывает окно «Параметры диалога».
- **R10.2.** Окно «Параметры диалога»: название (с редактированием по ✎),
карточка обслуживающего агента (имя + URL + статус «на связи»/«не отвечает»),
признак «Временный диалог» (только просмотр — задаётся только при создании),
«Создан» и «Обновлён» (`dd.MM.yyyy HH:mm`, системная тайм-зона),
«Опасное» с кнопкой «Удалить диалог». Создание идёт через модалку
выбора агента (R14.x), а не через это окно.
- **R11.** Ответ ассистента можно прервать — на видном месте кнопка «Стоп».
- **R12.** Сообщения приходят вживую, по мере генерации: текст дотекает куском
за куском, а не появляется целиком в конце. Отображать именно так.
+252 -89
View File
@@ -169,7 +169,7 @@
</div>
<div class="composer">
<div class="composer-row">
<div class="field">Сообщение агенту…</div>
<div class="composer-input">Сообщение агенту…</div>
<button class="mic" title="Голос">🎤</button>
<button class="send off" title="Отправить">➤</button>
</div>
@@ -253,92 +253,6 @@
</div>
</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 · управление группами ============ -->
<section class="screen">
<div class="lbl"><b>6 · управление группами</b> модалка со списком групп + добавить</div>
@@ -967,7 +881,7 @@
<div class="head-name">agentik · рабочий</div>
<div class="head-sub"><span class="dot">●</span> на связи</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>
<div class="messages">
@@ -1040,7 +954,7 @@
<div class="avatar pic"></div>
<div class="ag-mid">
<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>
<div class="pick"></div>
@@ -1051,6 +965,20 @@
<span>Ассистент сейчас не отвечает. Диалог создать можно, но
сообщение не уйдёт, пока он не поднимется.</span>
</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 class="d-foot">
<button class="btn" style="flex:1">Отмена</button>
@@ -1422,5 +1350,240 @@
</div>
</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>
</html>
+44 -17
View File
@@ -144,12 +144,12 @@ body {
.rec-btn.rec-send:hover { background: #82BEF5; }
.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);
border: 1px solid var(--line); display: flex; align-items: center; padding: 0 14px;
font-size: 13.5px; color: var(--dim);
}
.field.focus { border-color: var(--accent); }
.composer-input.focus { border-color: var(--accent); }
.mic {
width: 52px; height: 52px; flex: 0 0 52px; border-radius: 50%;
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); }
.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;
font-size: 10.5px; }
.addr-row { display: flex; gap: 8px; }
@@ -441,7 +432,9 @@ body {
.icon-btn { width: 30px; height: 30px; border-radius: 8px; border: none;
background: transparent; color: var(--dim); cursor: pointer; display: grid; place-items: center;
font-size: 17px; }
.icon-btn svg { width: 18px; height: 18px; fill: currentColor; }
.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);
background: var(--bg); color: var(--fg); font-size: 13px; outline: none; font-family: inherit; }
.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; }
.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) === */
.d-lead { font-size: 12.5px; color: var(--dim); margin-bottom: 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 .nm { color: var(--fg); }
.grp .lock { font-size: 11px; color: var(--dim); 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 .note { font-size: 11px; color: var(--dim); margin-top: 6px; }
.field { margin-bottom: 14px; }
.field label { display: block; font-size: 12px; color: var(--dim); margin-bottom: 4px; }
.field input {
width: 100%; padding: 6px 0; background: transparent; color: var(--fg);
font-size: 14px; outline: none; font-family: inherit;
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.ChatPane
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.NewChatDialog
import pw.binom.agentik.desktop.ui.theme.AgentikTheme
import pw.binom.agentik.desktop.ui.theme.ProvideAgentikTheme
import pw.binom.agentik.desktop.ui.theme.toAgentikTheme
import pw.binom.agentik.desktop.session.AgentStatus
private sealed interface AppDialog {
data object Agents : AppDialog
data object Groups : 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 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)) {
val wide = maxWidth >= 900.dp
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
}
}
@@ -123,6 +123,29 @@ class AppState(
/** Каталог агентов. */
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` → адрес (пока не подключились).
@@ -334,11 +357,21 @@ class AppState(
}
}
fun renameConversation(id: String, title: String?) {
val conn = activeAgentId?.let { registry.connection(it) } ?: return
fun renameConversation(
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 {
runCatching { conn.renameConversation(id, title) }.onFailure { reportError(it) }
val result = runCatching { conn.renameConversation(id, title) }
result.onFailure { reportError(it, "Не удалось переименовать") }
refresh()
onResult(result.map { })
}
}
@@ -420,7 +420,7 @@ internal fun DialogHeader(title: String, onClose: () -> Unit) {
@Composable
internal fun DialogFooter(content: @Composable androidx.compose.foundation.layout.RowScope.() -> Unit) {
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),
) { this.content() }
}
@@ -22,6 +22,7 @@ import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
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.material3.Icon
import androidx.compose.material3.HorizontalDivider
@@ -106,8 +107,34 @@ fun ChatPane(
size = 36.dp,
)
Column(Modifier.weight(1f)) {
Text(agentName, color = colors.text, fontSize = 14.sp)
val conversationTitle = state.activeConversation?.title?.takeIf { it.isNotBlank() }
?: "Без названия"
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) {
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
}
}