diff --git a/README.md b/README.md index 126fce7..fbdf9ed 100644 --- a/README.md +++ b/README.md @@ -81,9 +81,11 @@ pw.binom.agentik.desktop - **Проверка связи** — «ответ сервера» + создание/удаление временного диалога, с таймингами. - **UI (Compose)** — рейка агентов, список диалогов (поиск, папки, бейджи), - чат с живым стримингом, композер, окна «Агенты», «Группы» и «Новый диалог» - (при нескольких агентах — выбор, при одном — сразу), пустые состояния, - адаптивная раскладка (узкое окно — список или чат). + чат с живым стримингом, композер, окна «Агенты», «Группы», «Новый диалог» + (при нескольких агентах — выбор, при одном — сразу) и «Параметры диалога» + (кнопка `⋯` в шапке чата: имя, обслуживающий агент, признак «Временный», + даты создания/обновления, удаление), пустые состояния, адаптивная раскладка + (узкое окно — список или чат). - **Голос** — микрофон → VAD → распознавание (`voice/`): кнопка микрофона или `Ctrl+M` из чата запускает запись (`mic-api`), Silero VAD (`vad-kmp`) отмечает наличие речи, Vosk (`asr-vosk`, модель внутри артефакта) отдаёт промежуточный текст над diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md index d897790..572b1db 100644 --- a/REQUIREMENTS.md +++ b/REQUIREMENTS.md @@ -82,6 +82,14 @@ ## 4. Открытый диалог - **R10.** Показ сообщений: мои и ассистента, с временем. +- **R10.1.** В шапке открытого диалога — **кнопка `⋯` (три вертикальные точки)**, + без визуальных границ, как в Telegram. Открывает окно «Параметры диалога». +- **R10.2.** Окно «Параметры диалога»: название (с редактированием по ✎), + карточка обслуживающего агента (имя + URL + статус «на связи»/«не отвечает»), + признак «Временный диалог» (только просмотр — задаётся только при создании), + «Создан» и «Обновлён» (`dd.MM.yyyy HH:mm`, системная тайм-зона), + «Опасное» с кнопкой «Удалить диалог». Создание идёт через модалку + выбора агента (R14.x), а не через это окно. - **R11.** Ответ ассистента можно прервать — на видном месте кнопка «Стоп». - **R12.** Сообщения приходят вживую, по мере генерации: текст дотекает куском за куском, а не появляется целиком в конце. Отображать именно так. diff --git a/sketches/index.html b/sketches/index.html index b71e135..4e91bec 100644 --- a/sketches/index.html +++ b/sketches/index.html @@ -169,7 +169,7 @@
-
Сообщение агенту…
+
Сообщение агенту…
@@ -253,92 +253,6 @@
- -
-
5 · модалка нового диалога выбор агента для нового диалога
-
-
-

Диалоги

-
-
Все чаты3
-
Работа
-
Дом
- -
-
🔍 Поиск…
-
-
- -
С
-
-
agentik · рабочий14:22
-
Собрал отчёт, жду правок
-
-
-
-
-
-
-
💬
-

Выберите диалог слева

-

Или нажмите + в шапке списка, чтобы создать новый.

-
-
-
- -
-
-
-
6 · управление группами модалка со списком групп + добавить
@@ -967,7 +881,7 @@
agentik · рабочий
● на связи
- +
@@ -1040,7 +954,7 @@
Кот-ассистент
-
http://192.168.88.24:8080/agentik
+
http://192.168.24.8080:8080/agentik
не отвечает
@@ -1051,6 +965,20 @@ Ассистент сейчас не отвечает. Диалог создать можно, но сообщение не уйдёт, пока он не поднимется.
+ +
+ + +
Можно оставить пустым — диалог покажется как «Без названия». Переименуете в ⋯ в шапке чата.
+
+ +
+
+
+
Временный диалог
+
История не сохраняется на сервере. Подходит для коротких проверок и отладки.
+
+
@@ -1422,5 +1350,240 @@
+ +
+
вход в окно информации о диалоге кнопка ⋯ в шапке открытого чата с подсветкой
+
+ +
+
+
АГ
+
+
Spring Boot · настройка логов
+
● на связи · agentik
+
+ +
+
+
+
+ + +
+
20 · параметры диалога · обычный чекбокс выключен и задизейблен (isTemporal=false). Открывается по ⋯ в шапке чата
+
+
+

Spring Boot · настройка логов

+ +
+
+
+ + +
Пустое имя — диалог покажется как «Без названия».
+
+ + +
+
АГ
+
+
agentik
+
http://192.168.88.135:8080/agentik
+
+
на связи
+
+ +
+
+
+
Временный диалог
+
Задаётся только при создании. Изменить временность существующего диалога нельзя.
+
+
+ +
+
Создан1 окт 2026, 22:07
+
Обновлён5 окт 2026, 11:30
+
+ +
+
Опасное
+ +
+
+
+ + +
+
+
+ + +
+
21 · параметры диалога · временный чекбокс включён и задизейблен (isTemporal=true)
+
+
+

Черновик ТЗ

+ +
+
+
+ + +
+ + +
+
КО
+
+
coder
+
http://192.168.76.199:8080/agentik
+
+
на связи
+
+ +
+
+
+
Временный диалог
+
История не сохраняется на сервере.
+
+
+ +
+
Создантолько что
+
+ +
+
Опасное
+ +
+
+
+ + +
+
+
+ + +
+
22 · параметры диалога · без названия пустое имя
+
+
+

Без названия

+ +
+
+
+ + +
Пока имя не задано, диалог показывается как «Без названия».
+
+ + +
+
ДМ
+
+
home-assistant
+
http://192.168.76.199:8080/agentik
+
+
не отвечает
+
+ +
+
+
+
Временный диалог
+
Задаётся только при создании. Изменить временность существующего диалога нельзя.
+
+
+ +
+
Создансегодня, 09:11
+
+ +
+
Опасное
+ +
+
+
+ + +
+
+
+ + +
+
23 · параметры диалога · ошибка сервер не принял имя — warn + «Повторить»
+
+
+

Spring Boot · настройка логов

+ +
+
+
+ + +
+ +
+ ⚠ + Не удалось сохранить имя: агент не отвечает. Проверьте связь и попробуйте снова. +
+ +
+
Опасное
+ +
+
+
+ + +
+
+
+ + +
+
24 · удаление диалога · подтверждение открывается по «Удалить диалог» из 20–23. Действие необратимо — warn + красный CTA
+
+
+

Удалить диалог?

+ +
+
+
+ ⚠ + Диалог «Spring Boot · настройка логов» и вся его история будут + безвозвратно удалены с сервера. Это действие нельзя отменить. +
+
+ ℹ + Корзины нет. Если передумаете — диалог придётся создавать заново, и + контекст будет потерян. +
+
+
+ + +
+
+
+ \ No newline at end of file diff --git a/sketches/style.css b/sketches/style.css index 97eb792..84ece91 100644 --- a/sketches/style.css +++ b/sketches/style.css @@ -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; } \ No newline at end of file +.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; } \ No newline at end of file diff --git a/src/jvmMain/kotlin/pw/binom/agentik/desktop/ui/App.kt b/src/jvmMain/kotlin/pw/binom/agentik/desktop/ui/App.kt index d4a33c7..93dc17c 100644 --- a/src/jvmMain/kotlin/pw/binom/agentik/desktop/ui/App.kt +++ b/src/jvmMain/kotlin/pw/binom/agentik/desktop/ui/App.kt @@ -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(null) } var selectedGroupId by remember { mutableStateOf(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 } } diff --git a/src/jvmMain/kotlin/pw/binom/agentik/desktop/ui/AppState.kt b/src/jvmMain/kotlin/pw/binom/agentik/desktop/ui/AppState.kt index 427cf60..f7a7d63 100644 --- a/src/jvmMain/kotlin/pw/binom/agentik/desktop/ui/AppState.kt +++ b/src/jvmMain/kotlin/pw/binom/agentik/desktop/ui/AppState.kt @@ -123,6 +123,29 @@ class AppState( /** Каталог агентов. */ val agents: List 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 = {}, + ) { + 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 { }) } } diff --git a/src/jvmMain/kotlin/pw/binom/agentik/desktop/ui/screens/AgentsDialog.kt b/src/jvmMain/kotlin/pw/binom/agentik/desktop/ui/screens/AgentsDialog.kt index abe7d8a..e642a5c 100644 --- a/src/jvmMain/kotlin/pw/binom/agentik/desktop/ui/screens/AgentsDialog.kt +++ b/src/jvmMain/kotlin/pw/binom/agentik/desktop/ui/screens/AgentsDialog.kt @@ -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() } } diff --git a/src/jvmMain/kotlin/pw/binom/agentik/desktop/ui/screens/ChatPane.kt b/src/jvmMain/kotlin/pw/binom/agentik/desktop/ui/screens/ChatPane.kt index 5ed4eaf..8088d4f 100644 --- a/src/jvmMain/kotlin/pw/binom/agentik/desktop/ui/screens/ChatPane.kt +++ b/src/jvmMain/kotlin/pw/binom/agentik/desktop/ui/screens/ChatPane.kt @@ -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) - ConnectionLabel(state.activeStatus) + 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() }) { diff --git a/src/jvmMain/kotlin/pw/binom/agentik/desktop/ui/screens/DialogInfoDialog.kt b/src/jvmMain/kotlin/pw/binom/agentik/desktop/ui/screens/DialogInfoDialog.kt new file mode 100644 index 0000000..d6ce41a --- /dev/null +++ b/src/jvmMain/kotlin/pw/binom/agentik/desktop/ui/screens/DialogInfoDialog.kt @@ -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, + 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(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) +} diff --git a/src/jvmTest/kotlin/pw/binom/agentik/desktop/ui/DialogInfoDialogTest.kt b/src/jvmTest/kotlin/pw/binom/agentik/desktop/ui/DialogInfoDialogTest.kt new file mode 100644 index 0000000..5a20875 --- /dev/null +++ b/src/jvmTest/kotlin/pw/binom/agentik/desktop/ui/DialogInfoDialogTest.kt @@ -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 { + val out = HashMap() + 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 + } +}