From 6bc4e872ca52e707bbdafc5ca168ee2f2771b80f Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Fri, 28 Aug 2026 14:55:53 +0300 Subject: [PATCH 01/14] docs: add DESIGN.md design system specification (#1000) --- AGENTS.md | 9 +- CONTRIBUTING.md | 3 +- DESIGN.md | 305 ++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 315 insertions(+), 2 deletions(-) create mode 100644 DESIGN.md diff --git a/AGENTS.md b/AGENTS.md index 86820ea2..ec463978 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +1,12 @@ # AGENTS.md Guidance for AI coding agents working on this repository. -This complements [README.md](README.md) (for humans) with agent-focused context. +This complements [README.md](README.md) (for humans) and [DESIGN.md](DESIGN.md) +(architecture & design rationale) with agent-focused context. Before making any +non-trivial architectural change — new screens, navigation changes, theme/state +patterns, module boundaries — read `DESIGN.md` first and keep changes consistent +with the decisions recorded there. If a change would contradict `DESIGN.md`, +flag it explicitly rather than silently diverging. ## Project Overview @@ -311,6 +316,8 @@ com.m57.hermescontrol/ ## Further Reading - [README.md](README.md) — human-facing overview, features, screenshots, tech stack +- [DESIGN.md](DESIGN.md) — architecture and design decisions, rationale for the + patterns in this doc (Navigation3, HermesScaffold, theme presets, etc.) - [CONTRIBUTING.md](CONTRIBUTING.md) — contributor workflow, PR checklist, code style - [.github/workflows/android.yml](.github/workflows/android.yml) — CI pipeline source of truth - [.github/workflows/merge-conflict-detector.yml](.github/workflows/merge-conflict-detector.yml) — auto-labels conflicting PRs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 49c5c7ed..28a84dee 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -63,7 +63,7 @@ always creating a blank new one. If you use AI coding tools (including agents) to contribute: - **Never** add the AI tool as author, co-author, or `Co-Authored-By` in commit metadata. -- Direct AI agents to [`AGENTS.md`](AGENTS.md) in the repo root — it contains agent-specific guidance on project conventions, build quirks, and security considerations that complement this guide. +- Direct AI agents to [`AGENTS.md`](AGENTS.md) and [`DESIGN.md`](DESIGN.md) in the repo root — `AGENTS.md` contains operational conventions, build quirks, and testing rules, while `DESIGN.md` defines the normative design tokens, layout rules, and component styling contracts. --- @@ -121,5 +121,6 @@ Before submitting your PR, please verify: - [ ] No unused imports, unused parameters, or dead code. - [ ] Every `Image` and `Icon` element has a descriptive `contentDescription` for accessibility. - [ ] New screens use `HermesScaffold` and implement Loading/Error/Empty states. +- [ ] UI changes follow [`DESIGN.md`](DESIGN.md) (no emoji status glyphs, no FABs, verified >=3:1 contrast). - [ ] New components match the UI/UX style of similar existing screens (28+ screens for reference). - [ ] Commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) and are atomic (subject + ≤2 lines body). diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 00000000..d089628b --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,305 @@ +--- +version: "alpha" +name: "Hermes Mobile Design System" +description: "High-density, minimal Material 3 interface for the Hermes Agent Android client" +omitted: [] +colors: + # Slate Dark Baseline (DefaultScheme.kt) + bg-root: "#0F1416" + bg-surface: "#0F1416" + bg-surface-container-lowest: "#0F1416" + bg-surface-container-low: "#171C1E" + bg-surface-container: "#1B2022" + bg-surface-container-high: "#252B2D" + bg-surface-container-highest: "#303638" + bg-surface-variant: "#171C1E" + + # Foreground / Text Tokens + text-primary: "#DEE3E5" + text-secondary: "#CEE7EC" + text-muted: "#BEC8CA" + text-on-primary: "#0F1416" + text-on-secondary: "#0F1416" + text-on-tertiary: "#0F1416" + text-outline: "#889294" + + # Accents & Containers (Slate Bright Dark Mode) + primary: "#80D5D2" + primary-container: "#303638" + on-primary-container: "#DEE3E5" + secondary: "#B2CBD0" + secondary-container: "#252B2D" + on-secondary-container: "#CEE7EC" + tertiary: "#A3C9EC" + tertiary-container: "#303638" + on-tertiary-container: "#DEE3E5" + + # Semantic Status Tokens (HermesStatusColors.kt & DefaultScheme.kt) + status-success: "#81D692" + status-success-container: "#252B2D" + status-on-success: "#0F1416" + status-warning: "#ECC248" + status-warning-container: "#252B2D" + status-on-warning: "#0F1416" + status-error: "#FFB4AB" + status-error-container: "#252B2D" + status-on-error: "#0F1416" + status-on-error-container: "#FFB4AB" + status-info: "#8ECEFF" + status-info-container: "#252B2D" + status-on-info: "#0F1416" + + # Code Block & Terminal Tokens (Color.kt) + terminal-bg: "#1E1E1E" + terminal-border: "#333333" + terminal-text: "#D4D4D4" + terminal-muted: "#808080" + code-keyword: "#569CD6" + code-string: "#CE9178" + code-comment: "#6A9955" + code-number: "#B5CEA8" + + # Diff Viewer Tokens + diff-add-bg: "rgba(61, 220, 132, 0.15)" + diff-add-text: "#3DDC84" + diff-delete-bg: "rgba(255, 92, 92, 0.15)" + diff-delete-text: "#FF5C5C" + +typography: + font-sans: "Roboto, 'Segoe UI', system-ui, sans-serif" + font-mono: "'JetBrains Mono', 'Roboto Mono', monospace" + + display-large: + fontFamily: "{typography.font-sans}" + fontSize: "44px" + fontWeight: 700 + lineHeight: 52px + letterSpacing: "-0.5px" + display-medium: + fontFamily: "{typography.font-sans}" + fontSize: "36px" + fontWeight: 700 + lineHeight: 44px + letterSpacing: "-0.25px" + display-small: + fontFamily: "{typography.font-sans}" + fontSize: "30px" + fontWeight: 600 + lineHeight: 38px + letterSpacing: "0px" + headline-large: + fontFamily: "{typography.font-sans}" + fontSize: "28px" + fontWeight: 600 + lineHeight: 34px + letterSpacing: "0px" + headline-medium: + fontFamily: "{typography.font-sans}" + fontSize: "24px" + fontWeight: 600 + lineHeight: 30px + letterSpacing: "0px" + headline-small: + fontFamily: "{typography.font-sans}" + fontSize: "20px" + fontWeight: 600 + lineHeight: 26px + letterSpacing: "0.1px" + title-large: + fontFamily: "{typography.font-sans}" + fontSize: "20px" + fontWeight: 600 + lineHeight: 26px + letterSpacing: "0.1px" + title-medium: + fontFamily: "{typography.font-sans}" + fontSize: "16px" + fontWeight: 500 + lineHeight: 22px + letterSpacing: "0.15px" + title-small: + fontFamily: "{typography.font-sans}" + fontSize: "14px" + fontWeight: 500 + lineHeight: 20px + letterSpacing: "0.1px" + body-large: + fontFamily: "{typography.font-sans}" + fontSize: "16px" + fontWeight: 400 + lineHeight: 24px + letterSpacing: "0.25px" + body-medium: + fontFamily: "{typography.font-sans}" + fontSize: "14px" + fontWeight: 400 + lineHeight: 20px + letterSpacing: "0.25px" + body-small: + fontFamily: "{typography.font-sans}" + fontSize: "12px" + fontWeight: 400 + lineHeight: 16px + letterSpacing: "0.4px" + label-large: + fontFamily: "{typography.font-sans}" + fontSize: "14px" + fontWeight: 500 + lineHeight: 20px + letterSpacing: "0.1px" + label-medium: + fontFamily: "{typography.font-sans}" + fontSize: "12px" + fontWeight: 500 + lineHeight: 16px + letterSpacing: "0.5px" + label-small: + fontFamily: "{typography.font-sans}" + fontSize: "11px" + fontWeight: 500 + lineHeight: 16px + letterSpacing: "0.5px" + +rounded: + none: "0dp" + extra-small: "4dp" + small: "8dp" + medium: "12dp" + large: "16dp" + extra-large: "28dp" + full: "9999dp" + +spacing: + xs: "2dp" + sm: "6dp" + md: "12dp" + lg: "18dp" + xl: "24dp" + xxl: "36dp" + +components: + top-app-bar: + backgroundColor: "{colors.bg-surface}" + textColor: "{colors.text-primary}" + height: "56dp" + chat-user-bubble: + backgroundColor: "{colors.primary}" + textColor: "{colors.text-on-primary}" + rounded: "{rounded.medium}" + padding: "10dp 14dp" + chat-assistant-bubble: + backgroundColor: "{colors.bg-surface-container}" + textColor: "{colors.text-primary}" + rounded: "{rounded.medium}" + padding: "10dp 14dp" + tool-chip: + backgroundColor: "{colors.bg-surface-container-high}" + textColor: "{colors.text-primary}" + rounded: "{rounded.full}" + padding: "4dp 10dp" + code-card: + backgroundColor: "{colors.terminal-bg}" + textColor: "{colors.terminal-text}" + rounded: "{rounded.small}" + padding: "8dp 12dp" +--- + +## Overview +Hermes Mobile (`com.m57.hermescontrol`) is a high-density, keyboard-first, distraction-free Android client for the Hermes Agent platform. The UI philosophy prioritizes information density, low battery drain (OLED-friendly), and instantaneous feedback over decorative visual noise. + +The visual language follows Material 3 guidelines strictly adapted for developer utilities: +- **Tone**: Technical, focused, utilitarian, crisp. +- **Form Factors**: Native Android phones, foldables, and tablets via adaptive Navigation 3 layouts. +- **Core Principle**: Zero fluff. Fast render cycles, explicit states, flat surfaces, and zero visual ambiguity. + +## Colors +The app supports 6 dynamic presets managed by `PaletteTemplate.kt` (`Default/Slate`, `Monochrome`, `Gruvbox`, `Catppuccin`, `AMOLED`, `Nord`), plus dynamic Android 12+ wallpaper theming: + +1. **Surfaces**: + - `bg-root`: Deep canvas background (`#0F1416` in Default Dark, `#000000` in AMOLED). + - `bg-surface`: Top app bars, bottom sheets, navigation drawer background. + - `bg-surface-container`: Assistant message bubbles, session list cards, settings group panels (`#1B2022`). + - `bg-surface-container-high`: Dialogs, interactive chips, elevated tool surfaces (`#252B2D`). + - `bg-surface-container-highest`: Selected containers and active pills (`#303638`). +2. **Accents & Tints**: + - Primary accent (`#80D5D2` Slate Bright Teal) is reserved for interactive controls, user bubbles, selected drawer icons, and active tabs. + - Selected navigation drawer items MUST tint icons directly to `primary` to prevent low-contrast grey-on-grey visual loss. +3. **Semantic Status Colors (`HermesStatusColors`)**: + - Status indicators (`success`, `warning`, `error`, `info`) must always satisfy a minimum **3:1 contrast ratio** against their respective container backgrounds (`onErrorContainer` on `errorContainer`). + - Every theme preset must explicitly define `onErrorContainer` (`#FFB4AB` in Default Dark) to prevent invisible text bugs. + +## Typography +- **UI Fonts**: System `Roboto` / sans-serif with strict semantic scaling to prevent layout shifts. +- **Code & Logs**: Tabular `JetBrains Mono` for terminal traces, tool payloads, JSON viewers, and memory tokens. +- **Scale Discipline (`Type.kt`)**: + - Screen titles: `20sp` (`title-large`) with 0.1sp tracking. + - Card headings: `16sp` (`title-medium`) with 0.15sp tracking. + - Chat content: `16sp` (`body-large`) with 24sp line-height for readability. + - Metadata & Subtitles: `14sp` (`body-medium`) or `12sp` (`body-small` / `label-medium`). + - Hero stats & Display numbers: `44sp` (`display-large`) down to `30sp` (`display-small`). + +## Layout +- **Scaffolding (`HermesScaffold`)**: + - Global `TopAppBar` containing screen title, connection indicator, profile switch dropdown, and quick action icon buttons. + - **No Floating Action Buttons (FABs)**: Primary triggers live in the top-bar actions or fixed bottom composer docks. + - **Scaffold Padding Foot-Gun Rule**: Content lambdas must **NEVER** apply `Modifier.padding(paddingValues)` on root children — the scaffold's internal `Box` already handles top-bar offsets. +- **Spacing Scale (`Spacing.kt`)**: + - `xs: 2dp`: Hairline gaps, micro dividers. + - `sm: 6dp`: Default inner padding, icon-to-text gap. + - `md: 12dp`: Card content, screen edge, section gaps. + - `lg: 18dp`: Hero spacing, between major sections. + - `xl: 24dp`: Full-feature spacing, large section break. + - `xxl: 36dp`: Maximum outer margin. +- **Controls & Navigation**: + - Horizontal chip bars must be single-row with `horizontalScroll()`. Never stack chips vertically into multi-row wraps. + - Dropdown pickers take precedence over cycle buttons to ensure all options are discoverable in one tap. + +## Elevation & Depth +- **Elevation Language**: Surface luminance layering (`surfaceContainer` tiers) and 1px borders (`outline` / `outlineVariant`) replace heavy drop shadows. +- **Flat Surface Rule**: + - Chat background is flat `colorScheme.background`. + - User message bubbles use solid `colorScheme.primary` (no multi-color gradients). + - Shimmer effects (`StateViews.kt`) are used strictly as animated loading states, never as static background decorations. + +## Shapes +- **Corner Radii Hierarchy (`Shapes.kt`)**: + - `0dp` (`rounded.none`): Terminal panels, full-bleed code blocks. + - `4dp` (`rounded.extra-small`): Chips, badges, inline toggles. + - `8dp` (`rounded.small`): Text fields, small cards, code cards. + - `12dp` (`rounded.medium`): Standard cards, list rows, message bubbles, dialogs. + - `16dp` (`rounded.large`): Large cards, bottom sheets. + - `28dp` (`rounded.extra-large`): Feature cards, full-bleed surfaces. + - `9999dp` (`rounded.full`): Interactive filter chips, timeline markers, circular icon buttons. + +## Components +- **Top App Bar**: Flat surface, 56dp height, contains title marquee, WS connection status pill, and action buttons. +- **Chat Timeline**: + - **User Bubbles**: Right-aligned, solid primary fill, high-contrast text (`onPrimary`). + - **Assistant Bubbles**: Left-aligned, `surfaceContainer` fill (`#1B2022`), full Markdown renderer support. + - **Tool Bubbles**: Expandable rows with tool vector icon, execution state indicator, parameter summary, and full payload bottom sheet. +- **Action & Filter Chips**: + - Must represent real system state (e.g., active model, selected profile, filter criteria). + - Active chips: `primaryContainer` fill with `onPrimaryContainer` text/icon. + - Inactive chips: `surfaceContainerHigh` fill with `text-secondary` text. + - Disabled controls: Visually greyed out with `alpha = 0.38f`. +- **Dialogs & Bottom Sheets**: + - Standard CRUD flows must provide all 4 operations (Create, Read, Update, Delete) with confirmation gates for destructive actions. + - Every managed resource must have a discoverable navigation surface in the UI. + +## Do's and Don'ts + +### Do's +- **DO** use official `androidx.compose.material.icons` vectors for every UI element and status indicator. +- **DO** use `Icons.AutoMirrored.Filled.*` for directional icons (`ArrowBack`, `Send`, `VolumeUp`, `MenuBook`). +- **DO** maintain $\ge 4.5:1$ text contrast and $\ge 3:1$ container contrast across all 6 presets. +- **DO** use Android string `` for count labels (`1 agent` vs `2 agents`). +- **DO** grey out disabled controls (`enabled = false`) visibly. +- **DO** ensure every created resource has a discoverable UI list or navigation tab. + +### Don'ts +- **DON'T** render emoji glyphs (`✅`, `❌`, `⭕`, `🔄`, `⚡`, `🛠`, `📄`) in the app UI — replace them with real vector icons. +- **DON'T** use Floating Action Buttons (FABs); anchor actions in top bars or fixed docks. +- **DON'T** apply `Modifier.padding(paddingValues)` inside `HermesScaffold` content lambdas. +- **DON'T** use gradient backgrounds on chat surfaces or message bubbles. +- **DON'T** create multi-layer nested cards or unnecessary decorative borders. +- **DON'T** use cycle buttons where a dropdown menu provides immediate option visibility. From f04d8783581d3179d4ef514c7a43bceaecad29f9 Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Fri, 28 Aug 2026 17:50:37 +0300 Subject: [PATCH 02/14] feat(bots): add multi-agent group chat collaboration room (#998) Co-authored-by: M57 --- .../java/com/m57/hermescontrol/Navigation.kt | 8 + .../com/m57/hermescontrol/NavigationKeys.kt | 4 + .../m57/hermescontrol/data/model/Profile.kt | 37 +- .../m57/hermescontrol/ui/bots/BotsScreen.kt | 17 +- .../hermescontrol/ui/bots/CreateBotDialog.kt | 10 +- .../ui/bots/EditBotBottomSheet.kt | 10 +- .../ui/bots/group/GroupChatModels.kt | 111 ++++ .../ui/bots/group/GroupChatScreen.kt | 341 +++++++++++ .../ui/bots/group/GroupChatViewModel.kt | 578 ++++++++++++++++++ .../hermescontrol/ui/common/HermesScaffold.kt | 3 +- app/src/main/res/values-ko/strings.xml | 8 + app/src/main/res/values-zh/strings.xml | 8 + app/src/main/res/values/strings.xml | 8 + .../data/model/ProfileSerializationTest.kt | 235 +++---- .../ui/bots/group/GroupChatMentionsTest.kt | 71 +++ .../ui/bots/group/GroupChatViewModelTest.kt | 266 ++++++++ 16 files changed, 1589 insertions(+), 126 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatModels.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatScreen.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatViewModel.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/bots/group/GroupChatMentionsTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/bots/group/GroupChatViewModelTest.kt diff --git a/app/src/main/java/com/m57/hermescontrol/Navigation.kt b/app/src/main/java/com/m57/hermescontrol/Navigation.kt index 5f8d5530..cb20c51c 100644 --- a/app/src/main/java/com/m57/hermescontrol/Navigation.kt +++ b/app/src/main/java/com/m57/hermescontrol/Navigation.kt @@ -155,6 +155,14 @@ private fun appEntryProvider( onBack = { NavigationController.goBack() }, ) } + + // ── Multi-Agent Group Chat Room ───────────────────────────────────── + entry { key -> + com.m57.hermescontrol.ui.bots.group.GroupChatScreen( + groupName = key.groupName, + onBack = { NavigationController.goBack() }, + ) + } } @Composable diff --git a/app/src/main/java/com/m57/hermescontrol/NavigationKeys.kt b/app/src/main/java/com/m57/hermescontrol/NavigationKeys.kt index 2bacb4a1..1934804c 100644 --- a/app/src/main/java/com/m57/hermescontrol/NavigationKeys.kt +++ b/app/src/main/java/com/m57/hermescontrol/NavigationKeys.kt @@ -23,6 +23,10 @@ import kotlinx.serialization.Serializable @Serializable data object BotsScreen : NavKey +@Serializable data class GroupChatKey( + val groupName: String, +) : NavKey + @Serializable data object ToolsetsScreen : NavKey @Serializable data class ToolsetDetailKey( diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/Profile.kt b/app/src/main/java/com/m57/hermescontrol/data/model/Profile.kt index c2fcdff2..1e9e5a93 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/Profile.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/Profile.kt @@ -3,7 +3,10 @@ package com.m57.hermescontrol.data.model import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.decodeFromJsonElement +import kotlinx.serialization.json.jsonPrimitive @Serializable data class ProfilesResponse( @@ -121,12 +124,42 @@ data class GroupChatSyncSnapshot( @Serializable data class GroupChatRoomMeta( - val id: String, + val id: String? = null, + val roomId: String? = null, val name: String? = null, - val members: List? = null, + val members: List? = null, val picture: String? = null, + val image: String? = null, + val log: List? = null, + val revision: Long? = null, val updatedAt: Long? = null, val createdAt: Long? = null, +) { + val memberNames: List + get() = + members?.mapNotNull { el -> + when (el) { + is JsonPrimitive -> el.content + is JsonObject -> el["name"]?.jsonPrimitive?.content + else -> null + } + }.orEmpty() +} + +@Serializable +data class GroupChatSyncLogEntry( + val id: String? = null, + val from: GroupChatSyncFrom? = null, + val text: String? = null, + val at: Long? = null, + val thread: String? = null, +) + +@Serializable +data class GroupChatSyncFrom( + val kind: String? = null, // "user" | "member" + val name: String? = null, + val source: String? = null, ) @Serializable diff --git a/app/src/main/java/com/m57/hermescontrol/ui/bots/BotsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/bots/BotsScreen.kt index 8a18a0ce..279a479f 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/bots/BotsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/bots/BotsScreen.kt @@ -400,20 +400,9 @@ fun BotsScreen( group = group, onClick = { if (group.members.isNotEmpty()) { - scope.launch { - val primary = group.members.first() - viewModel.selectBot(primary) - val canonicalId = - primary.canonical_session?.resolved_id - ?: primary.canonical_session?.id - if (!canonicalId.isNullOrBlank()) { - NavigationController.openChatSession(canonicalId) - } else { - NavigationController.navigateTo( - com.m57.hermescontrol.ChatScreen, - ) - } - } + NavigationController.navigateTo( + com.m57.hermescontrol.GroupChatKey(group.name), + ) } }, onDisbandClick = { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/bots/CreateBotDialog.kt b/app/src/main/java/com/m57/hermescontrol/ui/bots/CreateBotDialog.kt index 0f70cf58..ec411ed7 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/bots/CreateBotDialog.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/bots/CreateBotDialog.kt @@ -3,6 +3,7 @@ package com.m57.hermescontrol.ui.bots import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -142,9 +143,14 @@ fun CreateBotDialog( color = MaterialTheme.colorScheme.onSurfaceVariant, ) + Spacer(modifier = Modifier.height(6.dp)) + Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = + Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp), ) { AVAILABLE_SHAPES.forEach { shape -> FilterChip( diff --git a/app/src/main/java/com/m57/hermescontrol/ui/bots/EditBotBottomSheet.kt b/app/src/main/java/com/m57/hermescontrol/ui/bots/EditBotBottomSheet.kt index bad687cf..c55f488a 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/bots/EditBotBottomSheet.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/bots/EditBotBottomSheet.kt @@ -3,6 +3,7 @@ package com.m57.hermescontrol.ui.bots import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -173,9 +174,14 @@ fun EditBotBottomSheet( color = MaterialTheme.colorScheme.onSurfaceVariant, ) + Spacer(modifier = Modifier.height(6.dp)) + Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = + Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp), ) { AVAILABLE_SHAPES.forEach { shape -> FilterChip( diff --git a/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatModels.kt b/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatModels.kt new file mode 100644 index 00000000..477603b0 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatModels.kt @@ -0,0 +1,111 @@ +package com.m57.hermescontrol.ui.bots.group + +import com.m57.hermescontrol.data.model.BotAvatarMeta +import com.m57.hermescontrol.data.model.ProfileInfo + +/** + * Single message entry in a multi-agent group room. + */ +data class GroupChatMessage( + val id: String, + val senderName: String, + val senderDisplayName: String, + val isUser: Boolean, + val avatarMeta: BotAvatarMeta? = null, + val text: String, + val timestamp: Long = System.currentTimeMillis(), + val isStreaming: Boolean = false, + val thread: String? = null, +) + +/** + * Pure helper for mention extraction and responder resolution matching Desktop parity. + */ +object GroupChatMentions { + data class MentionResult( + val isEveryone: Boolean, + val mentionedBots: Set, + ) + + /** + * Parse `@botname`, `@all`, `@everyone` from text against the member roster. + */ + fun parseMentions( + text: String, + members: List, + ): MentionResult { + val lowerText = text.lowercase() + val isEveryone = lowerText.contains("@all") || lowerText.contains("@everyone") + + val mentioned = mutableSetOf() + for (member in members) { + val handle = member.name.lowercase() + val titleHandle = member.effectiveTitle.lowercase().replace(" ", "") + if (lowerText.contains("@$handle") || lowerText.contains("@$titleHandle")) { + mentioned.add(member.name) + } + } + + return MentionResult( + isEveryone = isEveryone, + mentionedBots = mentioned, + ) + } + + /** + * Determine which bots should take a turn. If @all or no specific mention, all members participate. + */ + fun resolveResponders( + text: String, + members: List, + ): List { + val parsed = parseMentions(text, members) + return if (parsed.isEveryone || parsed.mentionedBots.isEmpty()) { + members + } else { + members.filter { parsed.mentionedBots.contains(it.name) } + } + } + + /** + * Build the room delta prompt injected into each bot's session. + */ + fun buildTurnPrompt( + groupName: String, + viewer: ProfileInfo, + peers: List, + recentLog: List, + ): String { + val viewerHandle = viewer.name + val peerNames = peers.joinToString(", ") { "@${it.name}" } + val lines = + recentLog.takeLast(10).joinToString("\n") { msg -> + val speaker = if (msg.isUser) "User" else "@${msg.senderName}" + " $speaker: ${msg.text}" + } + + val lastUserMsg = recentLog.lastOrNull { it.isUser }?.text.orEmpty() + val parsed = parseMentions(lastUserMsg, peers + viewer) + val isDirectOrAll = parsed.isEveryone || parsed.mentionedBots.contains(viewer.name) + + val actionGuidance = + if (isDirectOrAll) { + "- You were explicitly addressed (via @$viewerHandle or @all). Do NOT pass — reply conversationally with your greeting, insight, answer, or status." + } else { + "- If you have nothing new to add, reply with exactly \"(pass)\". Passing is good and lets the conversation settle." + } + + return """ +[Group chat: "$groupName"] You are @$viewerHandle, one participant in a group chat with $peerNames and the user. + +Recent messages in the room: +$lines + +Rules for this room: +- Reply with ONE conversational message (1-3 sentences). +$actionGuidance +- Mention a teammate as @name if you want to pull them in. +- Your reply goes to the room verbatim — no preamble. + """.trimIndent() + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatScreen.kt new file mode 100644 index 00000000..194a0e21 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatScreen.kt @@ -0,0 +1,341 @@ +package com.m57.hermescontrol.ui.bots.group + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Send +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.m57.hermescontrol.R +import com.m57.hermescontrol.ui.chat.MarkdownText +import com.m57.hermescontrol.ui.common.BotAvatar +import com.m57.hermescontrol.ui.common.EmptyState +import com.m57.hermescontrol.ui.common.HermesScaffold +import com.m57.hermescontrol.ui.common.NavIcon + +@Composable +fun GroupChatScreen( + groupName: String, + onBack: () -> Unit, + modifier: Modifier = Modifier, + viewModel: GroupChatViewModel = viewModel { GroupChatViewModel() }, +) { + val state by viewModel.uiState.collectAsState() + val listState = rememberLazyListState() + var inputFieldValue by rememberSaveable(stateSaver = TextFieldValue.Saver) { + mutableStateOf(TextFieldValue("")) + } + var isFocused by remember { mutableStateOf(false) } + + val handleSend = { + val text = inputFieldValue.text + if (text.isNotBlank()) { + viewModel.sendMessage(text) + inputFieldValue = TextFieldValue("") + } + } + + LaunchedEffect(groupName) { + viewModel.setGroup(groupName) + } + + LaunchedEffect(state.messages.size) { + if (state.messages.isNotEmpty()) { + listState.animateScrollToItem(state.messages.size - 1) + } + } + + HermesScaffold( + title = { + Column { + Text( + text = state.groupName.ifBlank { groupName }, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + if (state.members.isNotEmpty()) { + Text( + text = stringResource(R.string.group_chat_members_count, state.members.size), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + }, + navigationIcon = NavIcon.Back(onBack), + modifier = modifier, + ) { + Column( + modifier = + Modifier + .fillMaxSize() + .padding(bottom = 8.dp) + .imePadding(), + ) { + when { + state.isLoading -> { + Box( + modifier = + Modifier + .weight(1f) + .fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator(modifier = Modifier.size(32.dp)) + } + } + + state.messages.isEmpty() -> { + EmptyState( + title = stringResource(R.string.group_chat_empty_title), + subtitle = stringResource(R.string.group_chat_empty_subtitle), + modifier = + Modifier + .weight(1f) + .fillMaxWidth(), + ) + } + + else -> { + LazyColumn( + state = listState, + modifier = + Modifier + .weight(1f) + .fillMaxWidth(), + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + items( + items = state.messages, + key = { it.id }, + ) { message -> + GroupMessageCard(message = message) + } + + state.activeSpeaker?.let { speaker -> + item(key = "active_speaker") { + Row( + modifier = Modifier.padding(start = 8.dp, top = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + CircularProgressIndicator( + modifier = Modifier.size(14.dp), + strokeWidth = 2.dp, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(R.string.group_chat_thinking, speaker), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, + ) + } + } + } + } + } + } + + // ── COMPOSER (uses TextFieldValue + embedded Send button matching ChatComposer) ── + Surface( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 4.dp), + shape = RoundedCornerShape(20.dp), + border = + BorderStroke( + width = if (isFocused) 2.dp else 1.dp, + color = + if (isFocused) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.outline.copy(alpha = 0.5f) + }, + ), + color = MaterialTheme.colorScheme.surface, + ) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(start = 14.dp, end = 4.dp, top = 6.dp, bottom = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box(modifier = Modifier.weight(1f)) { + if (inputFieldValue.text.isEmpty()) { + Text( + text = + stringResource( + R.string.group_chat_composer_hint, + state.groupName.ifBlank { groupName }, + ), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + ) + } + BasicTextField( + value = inputFieldValue, + onValueChange = { inputFieldValue = it }, + modifier = + Modifier + .fillMaxWidth() + .heightIn(min = 24.dp, max = 120.dp) + .onFocusChanged { isFocused = it.isFocused } + .testTag("group_chat_input"), + textStyle = + MaterialTheme.typography.bodyMedium.copy( + color = MaterialTheme.colorScheme.onSurface, + ), + singleLine = false, + maxLines = 4, + cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send), + keyboardActions = KeyboardActions(onSend = { handleSend() }), + ) + } + + Spacer(modifier = Modifier.width(4.dp)) + + IconButton( + onClick = handleSend, + enabled = inputFieldValue.text.isNotBlank(), + colors = IconButtonDefaults.filledTonalIconButtonColors(), + modifier = + Modifier + .size(36.dp) + .testTag("group_chat_send_button"), + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.Send, + contentDescription = "Send", + modifier = Modifier.size(18.dp), + ) + } + } + } + } + } +} + +@Composable +private fun GroupMessageCard( + message: GroupChatMessage, + modifier: Modifier = Modifier, +) { + if (message.isUser) { + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + Surface( + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.fillMaxWidth(0.85f), + ) { + Column(modifier = Modifier.padding(12.dp)) { + Text( + text = message.text, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onPrimary, + ) + } + } + } + } else { + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Start, + ) { + BotAvatar( + name = message.senderName, + avatar = message.avatarMeta, + size = 32.dp, + showPresence = false, + ) + Spacer(modifier = Modifier.width(8.dp)) + Surface( + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.surfaceVariant, + modifier = Modifier.weight(1f, fill = false), + ) { + Column(modifier = Modifier.padding(12.dp)) { + Text( + text = message.senderDisplayName, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + ) + Spacer(modifier = Modifier.height(4.dp)) + if (message.isStreaming && message.text.isBlank()) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(vertical = 4.dp), + ) { + CircularProgressIndicator( + modifier = Modifier.size(12.dp), + strokeWidth = 1.5.dp, + color = MaterialTheme.colorScheme.primary, + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = stringResource(R.string.group_chat_typing), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + ) + } + } else if (message.text.isNotBlank()) { + MarkdownText( + text = message.text, + textColor = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatViewModel.kt new file mode 100644 index 00000000..1a74eaa2 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatViewModel.kt @@ -0,0 +1,578 @@ +package com.m57.hermescontrol.ui.bots.group + +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.m57.hermescontrol.data.model.GroupChatRoomMeta +import com.m57.hermescontrol.data.model.GroupChatSyncFrom +import com.m57.hermescontrol.data.model.GroupChatSyncLogEntry +import com.m57.hermescontrol.data.model.GroupChatSyncSnapshot +import com.m57.hermescontrol.data.model.ProfileInfo +import com.m57.hermescontrol.data.model.ProfilesResponse +import com.m57.hermescontrol.data.remote.ApiClient +import com.m57.hermescontrol.data.remote.NetworkResult +import com.m57.hermescontrol.data.remote.safeApiCall +import com.m57.hermescontrol.data.session.ActiveSessionHolder +import com.m57.hermescontrol.data.ws.HermesWsClient +import com.m57.hermescontrol.data.ws.WsEvent +import com.m57.hermescontrol.data.ws.WsMethods +import com.m57.hermescontrol.data.ws.toJsonElement +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.decodeFromJsonElement +import kotlinx.serialization.json.jsonPrimitive +import java.util.UUID + +data class GroupChatUiState( + val groupName: String = "", + val members: List = emptyList(), + val messages: List = emptyList(), + val activeSpeaker: String? = null, + val isLoading: Boolean = true, + val errorMessage: String? = null, +) + +data class MemberSession( + val runtimeSessionId: String, + val storedSessionId: String? = null, +) + +class GroupChatViewModel( + private var groupName: String = "", + private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, + private val json: Json = Json { ignoreUnknownKeys = true }, +) : ViewModel() { + private val _uiState = MutableStateFlow(GroupChatUiState(groupName = groupName)) + val uiState: StateFlow = _uiState.asStateFlow() + + // Map of bot.name -> active session info in this group + private val memberSessions = mutableMapOf() + + // In-flight turn completion deferreds keyed by session_id + private val inFlightTurns = mutableMapOf>() + + // Map of session_id -> active streaming message ID + private val activeStreamMsgId = mutableMapOf() + + // Map of session_id -> bot profile + private val sessionToBot = mutableMapOf() + + init { + if (groupName.isNotBlank()) { + loadGroup() + } + observeWsEvents() + } + + /** + * Point the shared instance at [name] and drop all state from the + * previously viewed group chat so one group's data never bleeds into another. + */ + fun setGroup(name: String) { + if (name == groupName && _uiState.value.groupName.isNotBlank()) return + groupName = name + memberSessions.clear() + inFlightTurns.clear() + activeStreamMsgId.clear() + sessionToBot.clear() + _uiState.value = GroupChatUiState(groupName = name) + loadGroup() + } + + private fun observeWsEvents() { + viewModelScope.launch(ioDispatcher) { + HermesWsClient.events.collect { event -> + when (event) { + is WsEvent.MessageToken -> { + val sid = event.sessionId?.trim('\"', ' ') + if (sid != null) { + val streamId = activeStreamMsgId[sid] + val bot = sessionToBot[sid] + if (streamId != null && bot != null && event.token.isNotEmpty()) { + _uiState.update { state -> + val existing = state.messages.find { it.id == streamId } + val updatedMessages = + if (existing != null) { + state.messages.map { msg -> + if (msg.id == streamId) { + msg.copy(text = msg.text + event.token) + } else { + msg + } + } + } else { + state.messages + + GroupChatMessage( + id = streamId, + senderName = bot.name, + senderDisplayName = bot.effectiveTitle, + isUser = false, + avatarMeta = bot.botMeta()?.avatar, + text = event.token, + isStreaming = true, + ) + } + state.copy(messages = updatedMessages) + } + } + } + } + + is WsEvent.MessageComplete -> { + val sid = event.sessionId?.trim('\"', ' ') + if (sid != null) { + Log.d("GroupChatViewModel", "MessageComplete for session $sid: '${event.text.take(50)}'") + inFlightTurns[sid]?.complete(event.text) + } + } + + else -> {} + } + } + } + } + + private suspend fun fetchProfiles(): List { + try { + val rpcResult = HermesWsClient.request(WsMethods.PROFILES_LIST).await() + val jsonElement = + when (rpcResult) { + is JsonElement -> rpcResult + null -> null + else -> rpcResult.toJsonElement() + } + if (jsonElement != null) { + val resp = json.decodeFromJsonElement(jsonElement) + if (!resp.profiles.isNullOrEmpty()) { + return resp.profiles + } + } + } catch (_: Exception) { + // Fallback to REST API below + } + val result = safeApiCall { ApiClient.hermesApi.getProfiles() } + return (result as? NetworkResult.Success)?.data?.profiles.orEmpty() + } + + fun loadGroup() { + viewModelScope.launch(ioDispatcher) { + _uiState.update { it.copy(isLoading = true, errorMessage = null) } + val allProfiles = fetchProfiles() + if (allProfiles.isNotEmpty()) { + var groupMembers = + allProfiles.filter { + val botGroups = it.botMeta()?.groups.orEmpty() + botGroups.contains(groupName) || botGroups.any { g -> g.equals(groupName, ignoreCase = true) } + } + + // Fallback: If group name was composed from bot names (e.g. "default, scoutbot") + if (groupMembers.isEmpty() && groupName.contains(",")) { + val targetNames = groupName.split(",").map { it.trim().lowercase() } + groupMembers = allProfiles.filter { targetNames.contains(it.name.lowercase()) } + } + + // Final safety fallback: If 0 members found, use all non-hidden bots + if (groupMembers.isEmpty()) { + groupMembers = allProfiles.filter { !it.isHidden } + } + + Log.d( + "GroupChatViewModel", + "Resolved ${groupMembers.size} members for group '$groupName': ${groupMembers.map { it.name }}", + ) + + // Hydrate previous group chat messages from cross-device sync snapshot + val defaultProfile = allProfiles.find { it.is_default == true || it.name == "default" } + val syncSnapshotElement = defaultProfile?.ui_meta?.get("hermes-bots-groups") + val syncSnapshot = + syncSnapshotElement?.let { el -> + try { + json.decodeFromJsonElement(el) + } catch (e: Exception) { + Log.w("GroupChatViewModel", "Failed to decode sync snapshot: ${e.message}") + null + } + } + + var initialMessages = _uiState.value.messages + if (initialMessages.isEmpty() && syncSnapshot?.rooms != null) { + val matchingRoom = + syncSnapshot.rooms[groupName] + ?: syncSnapshot.rooms["name:$groupName"] + ?: syncSnapshot.rooms["id:$groupName"] + ?: syncSnapshot.rooms.values.find { it.name.equals(groupName, ignoreCase = true) } + + val syncedLog = matchingRoom?.log.orEmpty() + if (syncedLog.isNotEmpty()) { + initialMessages = + syncedLog.map { entry -> + val isUser = entry.from?.kind != "member" + val senderName = entry.from?.name.orEmpty().ifBlank { if (isUser) "user" else "bot" } + val memberProfile = allProfiles.find { it.name.equals(senderName, ignoreCase = true) } + GroupChatMessage( + id = entry.id ?: UUID.randomUUID().toString(), + senderName = memberProfile?.name ?: senderName, + senderDisplayName = + if (isUser) "You" else (memberProfile?.effectiveTitle ?: senderName), + isUser = isUser, + avatarMeta = memberProfile?.botMeta()?.avatar, + text = entry.text.orEmpty(), + timestamp = entry.at ?: System.currentTimeMillis(), + thread = entry.thread, + ) + } + Log.d("GroupChatViewModel", "Hydrated ${initialMessages.size} messages from sync snapshot") + } + } + + _uiState.update { + it.copy( + members = groupMembers, + messages = initialMessages, + isLoading = false, + ) + } + } else { + _uiState.update { + it.copy( + isLoading = false, + errorMessage = "Failed to load group members", + ) + } + } + } + } + + private suspend fun ensureMemberSession(bot: ProfileInfo): MemberSession? { + val cached = memberSessions[bot.name] + if (cached != null) { + return cached + } + + val title = "Group: $groupName" + try { + val createParams = + buildMap { + put("profile", bot.name) + put("title", title) + put("source", "desktop") + put("hidden", true) + } + val deferred = HermesWsClient.request(WsMethods.SESSION_CREATE, createParams) + val res = deferred.await() + val sessionInfo = extractSessionInfo(res) + if (sessionInfo != null) { + memberSessions[bot.name] = sessionInfo + return sessionInfo + } + } catch (e: Exception) { + Log.w("GroupChatViewModel", "session.create failed for ${bot.name}: ${e.message}") + } + return null + } + + fun sendMessage(rawText: String) { + val text = rawText.trim() + Log.d("GroupChatViewModel", "sendMessage called with: '$text'") + if (text.isBlank()) return + + val members = _uiState.value.members + if (members.isEmpty()) { + Log.w("GroupChatViewModel", "sendMessage: No members in group, ignoring") + return + } + + val userMessage = + GroupChatMessage( + id = UUID.randomUUID().toString(), + senderName = "user", + senderDisplayName = "You", + isUser = true, + text = text, + ) + + _uiState.update { + it.copy(messages = it.messages + userMessage) + } + persistSyncSnapshot(_uiState.value.messages) + + viewModelScope.launch(ioDispatcher) { + val responders = GroupChatMentions.resolveResponders(text, members) + Log.d("GroupChatViewModel", "Responders: ${responders.map { it.name }}") + + for (bot in responders) { + _uiState.update { it.copy(activeSpeaker = bot.effectiveTitle) } + val prompt = + GroupChatMentions.buildTurnPrompt( + groupName = groupName, + viewer = bot, + peers = members.filter { it.name != bot.name }, + recentLog = _uiState.value.messages, + ) + + val streamMsgId = UUID.randomUUID().toString() + var activeRuntimeId: String? = null + var activeStoredId: String? = null + try { + val sessionInfo = ensureMemberSession(bot) + if (sessionInfo == null) { + Log.w("GroupChatViewModel", "Could not obtain session for ${bot.name}") + continue + } + val runtimeId = sessionInfo.runtimeSessionId + val storedId = sessionInfo.storedSessionId + activeRuntimeId = runtimeId + activeStoredId = storedId + + activeStreamMsgId[runtimeId] = streamMsgId + storedId?.let { activeStreamMsgId[it] = streamMsgId } + + sessionToBot[runtimeId] = bot + storedId?.let { sessionToBot[it] = bot } + + // Set ActiveSessionHolder so the active WebSocket context matches this turn + ActiveSessionHolder.set(runtimeId, runtimeId) + + // Register in-flight listener + val turnDeferred = CompletableDeferred() + inFlightTurns[runtimeId] = turnDeferred + storedId?.let { inFlightTurns[it] = turnDeferred } + + // Submit prompt via sendMessage + HermesWsClient.sendMessage( + sessionId = runtimeId, + text = prompt, + ) + + // Await event or polling fallback + var replyText = + withTimeoutOrNull(45_000L) { + turnDeferred.await() + } + inFlightTurns.remove(runtimeId) + storedId?.let { inFlightTurns.remove(it) } + + // Polling fallback if event dropped or session was reaped + if (replyText == null) { + val targetIds = listOfNotNull(storedId, runtimeId).distinct() + for (tid in targetIds) { + try { + val res = + safeApiCall { + ApiClient.hermesApi.getSessionMessages( + sessionId = tid, + limit = 10, + order = "latest", + ) + } + if (res is NetworkResult.Success) { + val lastAssistant = + res.data.messages.reversed().firstOrNull { it.role == "assistant" } + val candidate = lastAssistant?.contentText?.trim() + if (!candidate.isNullOrBlank()) { + replyText = candidate + break + } + } + } catch (e: Exception) { + Log.w("GroupChatViewModel", "REST fallback failed for $tid: ${e.message}") + } + } + } + + if (replyText != null && !isPass(replyText) && replyText.isNotBlank()) { + _uiState.update { state -> + val existing = state.messages.find { it.id == streamMsgId } + val finalMsg = + GroupChatMessage( + id = streamMsgId, + senderName = bot.name, + senderDisplayName = bot.effectiveTitle, + isUser = false, + avatarMeta = bot.botMeta()?.avatar, + text = replyText.trim(), + isStreaming = false, + ) + val updatedList = + if (existing != null) { + state.messages.map { if (it.id == streamMsgId) finalMsg else it } + } else { + state.messages + finalMsg + } + state.copy(messages = updatedList) + } + persistSyncSnapshot(_uiState.value.messages) + } else { + _uiState.update { state -> + state.copy(messages = state.messages.filter { it.id != streamMsgId }) + } + } + } catch (e: Exception) { + Log.w("GroupChatViewModel", "Turn failed for ${bot.name}: ${e.message}") + _uiState.update { state -> + state.copy(messages = state.messages.filter { it.id != streamMsgId }) + } + } finally { + activeRuntimeId?.let { + activeStreamMsgId.remove(it) + sessionToBot.remove(it) + inFlightTurns.remove(it) + } + activeStoredId?.let { + activeStreamMsgId.remove(it) + sessionToBot.remove(it) + inFlightTurns.remove(it) + } + } + } + _uiState.update { it.copy(activeSpeaker = null) } + } + } + + private fun persistSyncSnapshot(currentMessages: List) { + viewModelScope.launch(ioDispatcher) { + try { + val allProfiles = fetchProfiles() + val defaultProfile = + allProfiles.find { it.is_default == true || it.name == "default" } ?: return@launch + val existingElement = defaultProfile.ui_meta?.get("hermes-bots-groups") + val existingSnapshot = + existingElement?.let { + try { + json.decodeFromJsonElement(it) + } catch (_: Exception) { + null + } + } ?: GroupChatSyncSnapshot(version = 3, rooms = emptyMap()) + + val logEntries = + currentMessages.takeLast(32).map { msg -> + GroupChatSyncLogEntry( + id = msg.id, + from = + GroupChatSyncFrom( + kind = if (msg.isUser) "user" else "member", + name = if (msg.isUser) "You" else msg.senderName, + ), + text = msg.text, + at = msg.timestamp, + thread = msg.thread, + ) + } + + val roomKey = "name:$groupName" + val updatedRoom = + GroupChatRoomMeta( + name = groupName, + members = _uiState.value.members.map { JsonPrimitive(it.name) }, + log = logEntries, + updatedAt = System.currentTimeMillis(), + ) + + val updatedRooms = (existingSnapshot.rooms.orEmpty() + (roomKey to updatedRoom)) + val newSnapshot = + existingSnapshot.copy( + version = 3, + updatedAt = System.currentTimeMillis(), + rooms = updatedRooms, + ) + + val uiMetaPayload = mapOf("hermes-bots-groups" to snapshotToMap(newSnapshot)) + + HermesWsClient.request( + WsMethods.PROFILES_CONFIGURE, + mapOf( + "name" to defaultProfile.name, + "ui_meta" to uiMetaPayload, + ), + ).await() + } catch (e: Exception) { + Log.w("GroupChatViewModel", "persistSyncSnapshot failed: ${e.message}") + } + } + } + + private fun snapshotToMap(snapshot: GroupChatSyncSnapshot): Map { + val roomsMap = mutableMapOf() + snapshot.rooms?.forEach { (key, room) -> + val roomMap = mutableMapOf() + room.name?.let { roomMap["name"] = it } + room.roomId?.let { roomMap["roomId"] = it } + room.picture?.let { roomMap["picture"] = it } + room.image?.let { roomMap["image"] = it } + room.updatedAt?.let { roomMap["updatedAt"] = it } + room.createdAt?.let { roomMap["createdAt"] = it } + room.revision?.let { roomMap["revision"] = it } + room.members?.let { members -> + roomMap["members"] = + members.mapNotNull { + when (it) { + is JsonPrimitive -> it.content + is JsonObject -> it["name"]?.jsonPrimitive?.content + else -> null + } + } + } + room.log?.let { log -> + roomMap["log"] = + log.map { entry -> + buildMap { + entry.id?.let { put("id", it) } + entry.text?.let { put("text", it) } + entry.at?.let { put("at", it) } + entry.thread?.let { put("thread", it) } + entry.from?.let { f -> + put( + "from", + buildMap { + f.kind?.let { put("kind", it) } + f.name?.let { put("name", it) } + f.source?.let { put("source", it) } + }, + ) + } + } + } + } + roomsMap[key] = roomMap + } + + return buildMap { + put("version", snapshot.version ?: 3) + put("updatedAt", snapshot.updatedAt ?: System.currentTimeMillis()) + put("rooms", roomsMap) + snapshot.deleted?.let { put("deleted", it) } + } + } + + private fun isPass(text: String): Boolean { + val clean = text.trim().lowercase() + return clean == "(pass)" || clean == "pass" + } + + private fun extractSessionInfo(response: Any?): MemberSession? { + if (response is Map<*, *>) { + val runtimeId = + response["session_id"]?.toString()?.trim('\"', ' ') + ?: (response["result"] as? Map<*, *>)?.get("session_id")?.toString()?.trim('\"', ' ') + val storedId = + response["stored_session_id"]?.toString()?.trim('\"', ' ') + ?: (response["result"] as? Map<*, *>)?.get("stored_session_id")?.toString()?.trim('\"', ' ') + if (!runtimeId.isNullOrBlank()) { + return MemberSession(runtimeSessionId = runtimeId, storedSessionId = storedId) + } + } + return null + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/common/HermesScaffold.kt b/app/src/main/java/com/m57/hermescontrol/ui/common/HermesScaffold.kt index 32f285e4..2fca0126 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/common/HermesScaffold.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/common/HermesScaffold.kt @@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.calculateEndPadding import androidx.compose.foundation.layout.calculateStartPadding import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack @@ -160,7 +161,7 @@ fun HermesScaffold( Scaffold( modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection), - contentWindowInsets = WindowInsets(0, 0, 0, 0), + contentWindowInsets = WindowInsets.navigationBars, snackbarHost = { snackbarHost() }, topBar = { TopAppBar( diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index b7afce43..b43199b2 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -802,4 +802,12 @@ 에이전트 %1$d개 실행 중 작업 세부 정보 열기 + + %1$s에게 메시지 보내기… (@bot으로 멘션) + 메시지 없음 + 봇을 멘션하거나 @all을 사용하여 협업을 시작하세요. + %1$s 생각 중… + 입력 중… + 멤버 %1$d명 + @all \ No newline at end of file diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index cee14cbc..7f4f646a 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -1101,4 +1101,12 @@ %1$s 已开始 %1$s 失败:%2$s + + 发消息给 %1$s…(使用 @bot 提及) + 暂无消息 + 提及某个 Bot 或使用 @all 开始协同。 + %1$s 正在思考… + 正在输入… + %1$d 个成员 + @all diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b4466c30..bc801afe 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1232,4 +1232,12 @@ %1$s started %1$s failed: %2$s + + Message %1$s… (use @bot to mention) + No Messages Yet + Mention a bot or use @all to kick off collaboration. + %1$s is thinking… + Typing… + %1$d members + @all diff --git a/app/src/test/java/com/m57/hermescontrol/data/model/ProfileSerializationTest.kt b/app/src/test/java/com/m57/hermescontrol/data/model/ProfileSerializationTest.kt index 267590cb..7a4b5497 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/model/ProfileSerializationTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/model/ProfileSerializationTest.kt @@ -1,8 +1,9 @@ package com.m57.hermescontrol.data.model -import com.m57.hermescontrol.data.remote.OkHttpProvider -import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.encodeToJsonElement import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull @@ -11,120 +12,132 @@ import org.junit.Assert.assertTrue import org.junit.Test class ProfileSerializationTest { - private val json = OkHttpProvider.json + private val json = Json { ignoreUnknownKeys = true } @Test - fun testProfileInfoDeserializationWithBotMetadata() { - val jsonStr = - """ - { - "name": "researcher", - "path": "/home/user/.hermes/profiles/researcher", - "is_default": false, - "model": "anthropic/claude-3-7-sonnet", - "provider": "anthropic", - "skill_count": 5, - "description": "Base description", - "display_name": "Researcher Agent", - "has_avatar": true, - "canonical_session": { - "id": "sess-canon-123", - "resolved_id": "sess-canon-tip", - "root_title": "Bot Chat", - "title": "Bot Chat", - "preview": "Latest research report ready.", - "started_at": 1700000000, - "last_active": 1700005000, - "message_count": 42 - }, - "worker_session": { - "id": "worker-456", - "source": "kanban", - "title": "Background analysis", - "last_active": 1700006000 - }, - "last_session": { - "id": "last-789", - "title": "Ad-hoc task", - "preview": "Done!", - "started_at": 1700001000, - "last_active": 1700002000, - "message_count": 10 - }, - "ui_meta": { - "hermes-bots": { - "title": "Dr. Researcher", - "description": "Deep literature scout", - "avatar": { - "shape": "hexagon", - "color": "#4A90E2", - "icon": "science", - "image_url": "https://example.com/avatar.png" - }, - "hidden": false, - "groups": ["science-team", "daily-sync"] - } - } - } - """.trimIndent() + fun testBotRosterMetaSerialization() { + val meta = + BotRosterMeta( + title = "Literature Scout", + description = "Scrapes arXiv and summarizes papers", + avatar = + BotAvatarMeta( + shape = "circle", + color = "#4A90E2", + icon = "science", + ), + hidden = false, + groups = listOf("research", "ai-lab"), + group = "research", + created = 1718000000.0, + ) - val profile = json.decodeFromString(jsonStr) - assertEquals("researcher", profile.name) - assertEquals("Researcher Agent", profile.display_name) - assertTrue(profile.has_avatar == true) + val encoded = json.encodeToString(meta) + val decoded = json.decodeFromString(encoded) - // Canonical session - val canon = profile.canonical_session - assertNotNull(canon) - assertEquals("sess-canon-123", canon?.id) - assertEquals("sess-canon-tip", canon?.resolved_id) - assertEquals("Latest research report ready.", canon?.preview) - assertEquals(42, canon?.message_count) + assertEquals("Literature Scout", decoded.title) + assertEquals("Scrapes arXiv and summarizes papers", decoded.description) + assertEquals("circle", decoded.avatar?.shape) + assertEquals("#4A90E2", decoded.avatar?.color) + assertEquals("science", decoded.avatar?.icon) + assertEquals(false, decoded.hidden) + assertEquals(listOf("research", "ai-lab"), decoded.groups) + assertEquals("research", decoded.group) + assertEquals(1718000000.0, decoded.created) + } - // Worker session - val worker = profile.worker_session - assertNotNull(worker) - assertEquals("worker-456", worker?.id) - assertEquals("kanban", worker?.source) + @Test + fun testProfileInfoWithBotMetaExtraction() { + val botMeta = + BotRosterMeta( + title = "Custom Bot Title", + description = "Custom Bot Description", + avatar = BotAvatarMeta(shape = "hexagon", color = "#E91E63", icon = "robot"), + hidden = false, + ) - // Bot metadata decoding - val botMeta = profile.botMeta(json) - assertNotNull(botMeta) - assertEquals("Dr. Researcher", botMeta?.title) - assertEquals("Deep literature scout", botMeta?.description) - assertEquals("hexagon", botMeta?.avatar?.shape) - assertEquals("#4A90E2", botMeta?.avatar?.color) - assertEquals(listOf("science-team", "daily-sync"), botMeta?.groups) + val profile = + ProfileInfo( + name = "custom_bot", + display_name = "Original Name", + description = "Original Description", + ui_meta = mapOf("hermes-bots" to json.encodeToJsonElement(botMeta)), + canonical_session = + CanonicalSessionInfo( + id = "sess-123", + last_active = 1720000000.0, + ), + worker_session = + ProfileWorkerSummary( + id = "worker-456", + source = "kanban", + title = "Task #42", + last_active = 1720000100.0, + ), + ) - // Helpers - assertEquals("Dr. Researcher", profile.effectiveTitle) - assertEquals("Deep literature scout", profile.effectiveDescription) + val extractedMeta = profile.botMeta(json) + assertNotNull(extractedMeta) + assertEquals("Custom Bot Title", extractedMeta?.title) + assertEquals("Custom Bot Title", profile.effectiveTitle) + assertEquals("Custom Bot Description", profile.effectiveDescription) + assertFalse(profile.isHidden) + assertEquals("sess-123", profile.canonical_session?.id) + assertEquals("worker-456", profile.worker_session?.id) + } + + @Test + fun testProfileInfoWithoutBotMeta_fallsBackToProfileFields() { + val profile = + ProfileInfo( + name = "regular_profile", + display_name = "My Profile", + description = "Just a profile", + ) + + assertNull(profile.botMeta(json)) + assertEquals("My Profile", profile.effectiveTitle) + assertEquals("Just a profile", profile.effectiveDescription) assertFalse(profile.isHidden) } @Test - fun testProfileInfoBackwardCompatibility() { - val legacyJson = - """ - { - "name": "default", - "is_default": true, - "model": "openai/gpt-4o", - "provider": "openai", - "description": "Default Assistant" - } - """.trimIndent() + fun testProfileInfoHidden_derivedFromBotMeta() { + val hiddenBot = + ProfileInfo( + name = "hidden_bot", + ui_meta = + mapOf( + "hermes-bots" to + json.encodeToJsonElement( + BotRosterMeta(title = "Hidden", hidden = true), + ), + ), + ) + + val visibleBot = + ProfileInfo( + name = "visible_bot", + ui_meta = + mapOf( + "hermes-bots" to + json.encodeToJsonElement( + BotRosterMeta(title = "Visible", hidden = false), + ), + ), + ) + + assertTrue(hiddenBot.isHidden) + assertFalse(visibleBot.isHidden) + } + + @Test + fun testProfileInfoDefault_fallbackNames() { + val profile = ProfileInfo(name = "default") - val profile = json.decodeFromString(legacyJson) - assertEquals("default", profile.name) - assertTrue(profile.is_default == true) - assertNull(profile.canonical_session) - assertNull(profile.worker_session) - assertNull(profile.last_session) - assertNull(profile.ui_meta) assertNull(profile.botMeta(json)) assertEquals("default", profile.effectiveTitle) - assertEquals("Default Assistant", profile.effectiveDescription) + assertEquals("", profile.effectiveDescription) assertFalse(profile.isHidden) } @@ -140,8 +153,17 @@ class ProfileSerializationTest { GroupChatRoomMeta( id = "room-1", name = "War Room", - members = listOf("default", "researcher"), + members = listOf(JsonPrimitive("default"), JsonPrimitive("researcher")), picture = null, + log = + listOf( + GroupChatSyncLogEntry( + id = "msg-1", + from = GroupChatSyncFrom(kind = "user", name = "You"), + text = "Let's review the code", + at = 1724000000L, + ), + ), updatedAt = 1724000000L, createdAt = 1723000000L, ), @@ -153,8 +175,11 @@ class ProfileSerializationTest { val decoded = json.decodeFromString(encoded) assertEquals(3, decoded.version) assertEquals(1, decoded.rooms?.size) - assertEquals("War Room", decoded.rooms?.get("room-1")?.name) - assertEquals(listOf("default", "researcher"), decoded.rooms?.get("room-1")?.members) + val room = decoded.rooms?.get("room-1") + assertEquals("War Room", room?.name) + assertEquals(listOf("default", "researcher"), room?.memberNames) + assertEquals(1, room?.log?.size) + assertEquals("Let's review the code", room?.log?.first()?.text) assertEquals(1723500000L, decoded.deleted?.get("old-room")) } } diff --git a/app/src/test/java/com/m57/hermescontrol/ui/bots/group/GroupChatMentionsTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/bots/group/GroupChatMentionsTest.kt new file mode 100644 index 00000000..4171f758 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/bots/group/GroupChatMentionsTest.kt @@ -0,0 +1,71 @@ +package com.m57.hermescontrol.ui.bots.group + +import com.m57.hermescontrol.data.model.BotAvatarMeta +import com.m57.hermescontrol.data.model.BotRosterMeta +import com.m57.hermescontrol.data.model.ProfileInfo +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.encodeToJsonElement +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class GroupChatMentionsTest { + private val json = Json { ignoreUnknownKeys = true } + + private val botA = + ProfileInfo( + name = "scoutbot", + ui_meta = + mapOf( + "hermes-bots" to + json.encodeToJsonElement( + BotRosterMeta(title = "Scout Bot", avatar = BotAvatarMeta()), + ), + ), + ) + + private val botB = + ProfileInfo( + name = "coder", + ui_meta = + mapOf( + "hermes-bots" to + json.encodeToJsonElement( + BotRosterMeta(title = "Dev Bot", avatar = BotAvatarMeta()), + ), + ), + ) + + private val members = listOf(botA, botB) + + @Test + fun parseMentions_detectsDirectBotHandle() { + val result = GroupChatMentions.parseMentions("Hey @scoutbot can you check this?", members) + assertFalse(result.isEveryone) + assertTrue(result.mentionedBots.contains("scoutbot")) + assertFalse(result.mentionedBots.contains("coder")) + } + + @Test + fun parseMentions_detectsEveryoneAndAll() { + val resEveryone = GroupChatMentions.parseMentions("Hello @everyone!", members) + assertTrue(resEveryone.isEveryone) + + val resAll = GroupChatMentions.parseMentions("@all status update please", members) + assertTrue(resAll.isEveryone) + } + + @Test + fun resolveResponders_defaultsToAllWhenNoMention() { + val responders = GroupChatMentions.resolveResponders("General discussion message", members) + assertEquals(2, responders.size) + } + + @Test + fun resolveResponders_filtersToMentionedOnly() { + val responders = GroupChatMentions.resolveResponders("@coder build the apk", members) + assertEquals(1, responders.size) + assertEquals("coder", responders.first().name) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/bots/group/GroupChatViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/bots/group/GroupChatViewModelTest.kt new file mode 100644 index 00000000..e91babd7 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/bots/group/GroupChatViewModelTest.kt @@ -0,0 +1,266 @@ +package com.m57.hermescontrol.ui.bots.group + +import android.util.Log +import com.m57.hermescontrol.data.model.BotAvatarMeta +import com.m57.hermescontrol.data.model.BotRosterMeta +import com.m57.hermescontrol.data.model.GroupChatRoomMeta +import com.m57.hermescontrol.data.model.GroupChatSyncFrom +import com.m57.hermescontrol.data.model.GroupChatSyncLogEntry +import com.m57.hermescontrol.data.model.GroupChatSyncSnapshot +import com.m57.hermescontrol.data.model.ProfileInfo +import com.m57.hermescontrol.data.model.ProfilesResponse +import com.m57.hermescontrol.data.remote.ApiClient +import com.m57.hermescontrol.data.remote.HermesApiService +import com.m57.hermescontrol.data.ws.HermesWsClient +import com.m57.hermescontrol.data.ws.WsEvent +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.mockkStatic +import io.mockk.unmockkObject +import io.mockk.unmockkStatic +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.encodeToJsonElement +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import retrofit2.Response + +@OptIn(ExperimentalCoroutinesApi::class) +class GroupChatViewModelTest { + private val testDispatcher = StandardTestDispatcher() + private lateinit var mockApi: HermesApiService + private val eventsFlow = MutableSharedFlow() + private val json = Json { ignoreUnknownKeys = true } + + private val botA = + ProfileInfo( + name = "scout", + ui_meta = + mapOf( + "hermes-bots" to + json.encodeToJsonElement( + BotRosterMeta( + title = "Scout Bot", + groups = listOf("Dev Team"), + avatar = BotAvatarMeta(icon = "science"), + ), + ), + ), + ) + + private val botB = + ProfileInfo( + name = "coder", + ui_meta = + mapOf( + "hermes-bots" to + json.encodeToJsonElement( + BotRosterMeta( + title = "Dev Bot", + groups = listOf("Dev Team"), + avatar = BotAvatarMeta(icon = "code"), + ), + ), + ), + ) + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + mockkStatic(Log::class) + every { Log.d(any(), any()) } returns 0 + every { Log.i(any(), any()) } returns 0 + every { Log.w(any(), any()) } returns 0 + every { Log.e(any(), any()) } returns 0 + + mockkObject(ApiClient) + mockApi = mockk(relaxed = true) + every { ApiClient.hermesApi } returns mockApi + + coEvery { mockApi.getProfiles() } returns + Response.success( + ProfilesResponse( + profiles = listOf(botA, botB), + ), + ) + + mockkObject(HermesWsClient) + every { HermesWsClient.events } returns eventsFlow + every { HermesWsClient.send(any(), any(), any()) } returns "req-1" + every { HermesWsClient.sendMessage(any(), any(), any()) } returns "req-msg" + + val createDeferred = CompletableDeferred() + createDeferred.complete(mapOf("session_id" to "session-scout-1")) + every { HermesWsClient.request(any(), any(), any()) } returns createDeferred + } + + @After + fun tearDown() { + unmockkObject(ApiClient) + unmockkObject(HermesWsClient) + unmockkStatic(Log::class) + Dispatchers.resetMain() + } + + @Test + fun testLoadGroup_resolvesMembers() = + runTest(testDispatcher) { + val viewModel = GroupChatViewModel("Dev Team", ioDispatcher = testDispatcher) + advanceUntilIdle() + + val state = viewModel.uiState.value + assertFalse(state.isLoading) + assertNull(state.errorMessage) + assertEquals(2, state.members.size) + assertEquals("scout", state.members[0].name) + assertEquals("coder", state.members[1].name) + } + + @Test + fun testLoadGroup_hydratesFromSyncSnapshot() = + runTest(testDispatcher) { + val syncSnapshot = + GroupChatSyncSnapshot( + version = 3, + updatedAt = 1724000000L, + rooms = + mapOf( + "name:Dev Team" to + GroupChatRoomMeta( + name = "Dev Team", + members = listOf(JsonPrimitive("scout"), JsonPrimitive("coder")), + log = + listOf( + GroupChatSyncLogEntry( + id = "msg-prev-1", + from = GroupChatSyncFrom(kind = "user", name = "You"), + text = "Initial design spec", + at = 1724000000L, + ), + GroupChatSyncLogEntry( + id = "msg-prev-2", + from = GroupChatSyncFrom(kind = "member", name = "scout"), + text = "Looks solid, ready to build.", + at = 1724000010L, + ), + ), + ), + ), + ) + + val defaultProfileWithSnapshot = + ProfileInfo( + name = "default", + is_default = true, + ui_meta = mapOf("hermes-bots-groups" to json.encodeToJsonElement(syncSnapshot)), + ) + + coEvery { mockApi.getProfiles() } returns + Response.success( + ProfilesResponse( + profiles = listOf(defaultProfileWithSnapshot, botA, botB), + ), + ) + + val viewModel = GroupChatViewModel("Dev Team", ioDispatcher = testDispatcher) + advanceUntilIdle() + + val state = viewModel.uiState.value + assertEquals(2, state.messages.size) + assertEquals("msg-prev-1", state.messages[0].id) + assertTrue(state.messages[0].isUser) + assertEquals("Initial design spec", state.messages[0].text) + + assertEquals("msg-prev-2", state.messages[1].id) + assertFalse(state.messages[1].isUser) + assertEquals("scout", state.messages[1].senderName) + assertEquals("Looks solid, ready to build.", state.messages[1].text) + } + + @Test + fun testSendMessage_streamsTokensAndCompletes() = + runTest(testDispatcher) { + val viewModel = GroupChatViewModel("Dev Team", ioDispatcher = testDispatcher) + testScheduler.runCurrent() + + // Send message targeting only @scout + viewModel.sendMessage("@scout what's the status?") + testScheduler.runCurrent() + + // State should contain user message + val userMsg = viewModel.uiState.value.messages.first() + assertTrue(userMsg.isUser) + assertEquals("@scout what's the status?", userMsg.text) + + // Stream tokens + eventsFlow.emit(WsEvent.MessageToken(token = "Building ", sessionId = "session-scout-1")) + testScheduler.runCurrent() + + val streamingMsg = viewModel.uiState.value.messages.last() + assertFalse(streamingMsg.isUser) + assertEquals("scout", streamingMsg.senderName) + assertEquals("Building ", streamingMsg.text) + assertTrue(streamingMsg.isStreaming) + + eventsFlow.emit(WsEvent.MessageToken(token = "the APK...", sessionId = "session-scout-1")) + testScheduler.runCurrent() + + val updatedStreaming = viewModel.uiState.value.messages.last() + assertEquals("Building the APK...", updatedStreaming.text) + + // Complete turn + eventsFlow.emit( + WsEvent.MessageComplete( + text = "Building the APK now! Everything looks green.", + sessionId = "session-scout-1", + ), + ) + testScheduler.runCurrent() + + val finalState = viewModel.uiState.value + assertEquals(2, finalState.messages.size) + val botMsg = finalState.messages[1] + assertEquals("Building the APK now! Everything looks green.", botMsg.text) + assertFalse(botMsg.isStreaming) + assertNull(finalState.activeSpeaker) + } + + @Test + fun testSendMessage_filtersOutPass() = + runTest(testDispatcher) { + val viewModel = GroupChatViewModel("Dev Team", ioDispatcher = testDispatcher) + testScheduler.runCurrent() + + viewModel.sendMessage("@scout any updates?") + testScheduler.runCurrent() + + eventsFlow.emit( + WsEvent.MessageComplete( + text = "(pass)", + sessionId = "session-scout-1", + ), + ) + testScheduler.runCurrent() + + val finalState = viewModel.uiState.value + // Only the user message should remain; (pass) is filtered out + assertEquals(1, finalState.messages.size) + assertTrue(finalState.messages.first().isUser) + } +} From 2332bdd5d5d5df8a9d28dbaca0dfc90257620211 Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Fri, 28 Aug 2026 18:38:01 +0300 Subject: [PATCH 03/14] feat(nav): root swipe-back on chat with double-back exit and subpage hierarchy (#1001) Co-authored-by: M57 --- .../java/com/m57/hermescontrol/Navigation.kt | 23 +++ .../m57/hermescontrol/NavigationController.kt | 49 +++--- app/src/main/res/values-ko/strings.xml | 1 + app/src/main/res/values-zh/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + .../m57/hermescontrol/E2eIntegrationTest.kt | 10 +- .../hermescontrol/NavigationControllerTest.kt | 157 +++++++++--------- 7 files changed, 141 insertions(+), 101 deletions(-) diff --git a/app/src/main/java/com/m57/hermescontrol/Navigation.kt b/app/src/main/java/com/m57/hermescontrol/Navigation.kt index cb20c51c..be5727ef 100644 --- a/app/src/main/java/com/m57/hermescontrol/Navigation.kt +++ b/app/src/main/java/com/m57/hermescontrol/Navigation.kt @@ -1,5 +1,7 @@ package com.m57.hermescontrol +import android.app.Activity +import android.widget.Toast import androidx.activity.compose.BackHandler import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box @@ -31,10 +33,13 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp @@ -191,7 +196,25 @@ fun MainNavigation(sessionId: String? = null) { val openDrawer: () -> Unit = { scope.launch { drawerState.open() } } + val context = LocalContext.current + var lastBackPressTime by remember { mutableLongStateOf(0L) } + val backToastText = stringResource(R.string.press_back_again_to_exit) + + // Double-back to exit on root ChatScreen BackHandler(enabled = currentScreen == ChatScreen && backStack.size == 1) { + val now = System.currentTimeMillis() + if (now - lastBackPressTime < 2000L) { + (context as? Activity)?.finish() + } else { + lastBackPressTime = now + Toast.makeText(context, backToastText, Toast.LENGTH_SHORT).show() + } + } + + // Safety fallback: if a non-chat screen somehow sits alone on stack, swipe back returns to ChatScreen + BackHandler( + enabled = currentScreen != ChatScreen && currentScreen != LandingScreen && backStack.size == 1, + ) { NavigationController.goBack() } diff --git a/app/src/main/java/com/m57/hermescontrol/NavigationController.kt b/app/src/main/java/com/m57/hermescontrol/NavigationController.kt index 8659dcbc..16ddc35f 100644 --- a/app/src/main/java/com/m57/hermescontrol/NavigationController.kt +++ b/app/src/main/java/com/m57/hermescontrol/NavigationController.kt @@ -31,30 +31,37 @@ object NavigationController { private var nextChatNavigationRequestId = 0L - // Top-level primary screens (Chat, Skills, Cron, System, Settings) - private val primaryScreens: MutableSet = - mutableSetOf( - ChatScreen, - SkillsScreen, - CronJobsScreen, - SystemScreen, - SettingsScreen, - ) - - /** Returns whether the given key is a primary top-level screen. */ - fun isPrimaryScreen(key: NavKey): Boolean = key in primaryScreens + /** + * Top-level primary screens (all drawer-accessible screens). + * Navigating to any of these clears the stack to `[ChatScreen, key]` (or `[ChatScreen]` for Chat), + * ensuring swiping back returns to ChatScreen. + */ + fun isPrimaryScreen(key: NavKey): Boolean = key == ChatScreen || ScreenRegistry.ALL_SCREENS.any { it.key == key } fun navigateTo(key: NavKey) { val stack = backStack ?: return if (stack.lastOrNull() == key) return + if (key == ChatScreen) { + stack.clear() + stack.add(ChatScreen) + return + } + + if (key == LandingScreen) { + stack.clear() + stack.add(LandingScreen) + return + } + if (isPrimaryScreen(key)) { stack.clear() + stack.add(ChatScreen) + stack.add(key) + return } - // Drawer dismissal is handled by DrawerGestureController (issue #619): - // when a non-gesture sub-page composes, its HermesScaffold reconciles - // drawerGesturesEnabled=false and the controller closes the drawer - // itself via SideEffect. No synchronous closeDrawer callback here. + + // Subscreen / drill-down navigation: append to current stack stack.add(key) } @@ -98,15 +105,13 @@ object NavigationController { */ fun goBack(fallback: NavKey = ChatScreen) { val stack = backStack ?: return - if (stack.lastOrNull() == ChatScreen) { - resetTo(HistoryScreen) - return - } if (stack.size > 1) { stack.removeLastOrNull() } else if (stack.size == 1) { - stack.clear() - stack.add(fallback) + if (stack.lastOrNull() != fallback && stack.lastOrNull() != LandingScreen) { + stack.clear() + stack.add(fallback) + } } } } diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index b43199b2..18ba68fb 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -49,6 +49,7 @@ 충전 + 종료하려면 뒤로가기를 한 번 더 누르세요 헤르메스 AI 에이전트 제어 대화 diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 7f4f646a..e015cf23 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -55,6 +55,7 @@ 剩余额度:%1$s 套餐 充值 + 再次返回以退出应用 Hermes AI Agent 控制台 对话 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index bc801afe..77e22e62 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -66,6 +66,7 @@ Top-up + Press back again to exit Hermes AI Agent Control Converse diff --git a/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt b/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt index ffe83b49..5540298d 100644 --- a/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt @@ -718,17 +718,19 @@ class E2eIntegrationTest { NavigationController.navigateTo(SkillsScreen) assertEquals(SkillsScreen, backStack.lastOrNull()) - assertEquals(1, backStack.size) + assertEquals(2, backStack.size) + assertEquals(ChatScreen, backStack[0]) NavigationController.navigateTo(CronJobsScreen) assertEquals(CronJobsScreen, backStack.lastOrNull()) - assertEquals(1, backStack.size) + assertEquals(2, backStack.size) + assertEquals(ChatScreen, backStack[0]) - // Non-clearing drawer screen adds to stack + // Drawer screen navigation always roots on ChatScreen NavigationController.navigateTo(ProfilesScreen) assertEquals(ProfilesScreen, backStack.lastOrNull()) assertEquals(2, backStack.size) - assertEquals(CronJobsScreen, backStack[0]) + assertEquals(ChatScreen, backStack[0]) } // ── Tier 2: Boundary & Corner Cases (>=5 per feature) ──────────────── diff --git a/app/src/test/java/com/m57/hermescontrol/NavigationControllerTest.kt b/app/src/test/java/com/m57/hermescontrol/NavigationControllerTest.kt index b92167a2..4013dd96 100644 --- a/app/src/test/java/com/m57/hermescontrol/NavigationControllerTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/NavigationControllerTest.kt @@ -12,15 +12,8 @@ import org.junit.Test /** * Unit tests for [NavigationController] — the central navigation guard that - * prevents duplicate screen entries on the back stack. - * - * Issue #291 (Critical Test Coverage): Verifies the deduplication logic at - * line 45 (`if (stack.lastOrNull() == key) return`) and the primary-screen - * stack-clearing behavior at line 47-49. - * - * All tests are pure Kotlin with no Android dependencies — they only exercise - * [NavigationController]'s companion object methods against a real - * [NavBackStack] instance. + * prevents duplicate screen entries on the back stack and enforces + * swipe-back-to-chat hierarchy. */ class NavigationControllerTest { @Before @@ -47,7 +40,7 @@ class NavigationControllerTest { @Test fun `navigateTo on same primary screen is a no-op`() { - val backStack = NavBackStack(SkillsScreen) + val backStack = NavBackStack(ChatScreen, SkillsScreen) NavigationController.backStack = backStack val sizeBefore = backStack.size @@ -58,69 +51,60 @@ class NavigationControllerTest { } @Test - fun `navigateTo on same non-primary screen is a no-op`() { - val backStack = NavBackStack(ProfilesScreen) + fun `navigateTo on same subscreen is a no-op`() { + val backStack = NavBackStack(ChatScreen, SettingsScreen, SettingsAppearance) NavigationController.backStack = backStack - // ProfilesScreen is NOT in the default primaryScreens so it stays on the stack - NavigationController.navigateTo(ProfilesScreen) + NavigationController.navigateTo(SettingsAppearance) - assertEquals(1, backStack.size) + assertEquals(3, backStack.size) + assertEquals(SettingsAppearance, backStack.lastOrNull()) + } + + // ── Primary-screen behaviour: resets stack to [ChatScreen, Target] ─────── + + @Test + fun `navigateTo on a different primary screen resets stack with ChatScreen as root`() { + val backStack = NavBackStack(ChatScreen) + NavigationController.backStack = backStack + + NavigationController.navigateTo(SkillsScreen) + assertEquals(2, backStack.size) + assertEquals(ChatScreen, backStack.firstOrNull()) + assertEquals(SkillsScreen, backStack.lastOrNull()) + + // Now navigate to another primary screen (e.g. ProfilesScreen) + NavigationController.navigateTo(ProfilesScreen) + assertEquals("primary screen navigation should reset stack to [ChatScreen, target]", 2, backStack.size) + assertEquals(ChatScreen, backStack.firstOrNull()) assertEquals(ProfilesScreen, backStack.lastOrNull()) } - // ── Primary-screen behaviour: stack clearing ────────────────────────── - @Test - fun `navigateTo on a different primary screen clears the stack`() { - val backStack = NavBackStack(ChatScreen) + fun `navigateTo ChatScreen clears stack to single ChatScreen root`() { + val backStack = NavBackStack(ChatScreen, ProfilesScreen) NavigationController.backStack = backStack - // Navigate to a non-primary screen first (should add to stack) - NavigationController.navigateTo(LogsScreen) - assertEquals(2, backStack.size) - - // Now navigate to a different primary screen — must clear - NavigationController.navigateTo(SkillsScreen) - - assertEquals("primary screen navigation should clear the stack", 1, backStack.size) - assertEquals(SkillsScreen, backStack.lastOrNull()) - } - - @Test - fun `navigateTo on the current primary screen clears the stack`() { - val backStack = NavBackStack(ChatScreen) - NavigationController.backStack = backStack - - // Add a non-primary screen - NavigationController.navigateTo(ProfilesScreen) - assertEquals(2, backStack.size) - - // Navigate to ChatScreen — it's a primary screen, so the stack gets - // cleared before adding it. The dedup guard only fires when the key - // is already the LAST item (ChatScreen is at index 0, ProfilesScreen - // is last), not when it's merely present somewhere in the stack. NavigationController.navigateTo(ChatScreen) - // Primary screen navigation clears the stack - assertEquals("primary navigation clears stack", 1, backStack.size) + assertEquals("chat navigation should result in single root entry", 1, backStack.size) assertEquals(ChatScreen, backStack.lastOrNull()) } - // ── Non-primary screen behaviour: stack appending ───────────────────── + // ── Subscreen behaviour: appends to stack ─────────────────────────────── @Test - fun `navigateTo on a non-primary screen appends to the stack`() { - val backStack = NavBackStack(ChatScreen) + fun `navigateTo on a subscreen appends to the current stack`() { + val backStack = NavBackStack(ChatScreen, SettingsScreen) NavigationController.backStack = backStack - NavigationController.navigateTo(ProfilesScreen) - assertEquals(2, backStack.size) - assertEquals(ProfilesScreen, backStack.lastOrNull()) - - NavigationController.navigateTo(KeysScreen) + NavigationController.navigateTo(SettingsAppearance) assertEquals(3, backStack.size) - assertEquals(KeysScreen, backStack.lastOrNull()) + assertEquals(SettingsAppearance, backStack.lastOrNull()) + + NavigationController.navigateTo(SettingsAbout) + assertEquals(4, backStack.size) + assertEquals(SettingsAbout, backStack.lastOrNull()) } @Test @@ -176,13 +160,9 @@ class NavigationControllerTest { @Test fun `resetTo clears the stack and sets the target screen`() { - val backStack = NavBackStack(ChatScreen) + val backStack = NavBackStack(ChatScreen, ProfilesScreen, KeysScreen) NavigationController.backStack = backStack - NavigationController.navigateTo(ProfilesScreen) - NavigationController.navigateTo(KeysScreen) - assertEquals(3, backStack.size) - NavigationController.resetTo(SettingsScreen) assertEquals(1, backStack.size) @@ -197,14 +177,17 @@ class NavigationControllerTest { assertNull(NavigationController.backStack) } - // ── goBack: never leave the stack empty ─────────────────────────────── + // ── goBack: pops stack, preserves chat root ─────────────────────────── @Test fun `goBack removes the top screen when stack has more than one`() { - val backStack = NavBackStack(ChatScreen) + val backStack = NavBackStack(ChatScreen, SettingsScreen, SettingsAppearance) NavigationController.backStack = backStack - NavigationController.navigateTo(ProfilesScreen) + + NavigationController.goBack() + assertEquals(2, backStack.size) + assertEquals(SettingsScreen, backStack.lastOrNull()) NavigationController.goBack() @@ -213,18 +196,18 @@ class NavigationControllerTest { } @Test - fun `goBack from chat opens history`() { + fun `goBack on root ChatScreen keeps ChatScreen`() { val backStack = NavBackStack(ChatScreen) NavigationController.backStack = backStack NavigationController.goBack() assertEquals(1, backStack.size) - assertEquals(HistoryScreen, backStack.lastOrNull()) + assertEquals(ChatScreen, backStack.lastOrNull()) } @Test - fun `goBack falls back to default screen when stack has one item`() { + fun `goBack falls back to default screen when non-root stack has one item`() { val backStack = NavBackStack(ProfilesScreen) NavigationController.backStack = backStack @@ -256,18 +239,42 @@ class NavigationControllerTest { // ── primaryScreens ──────────────────────────────────────────────────── @Test - fun `isPrimaryScreen returns true for default screens`() { - assertTrue("ChatScreen should be primary by default", NavigationController.isPrimaryScreen(ChatScreen)) - assertTrue("SkillsScreen should be primary by default", NavigationController.isPrimaryScreen(SkillsScreen)) - assertTrue("CronJobsScreen should be primary by default", NavigationController.isPrimaryScreen(CronJobsScreen)) - assertTrue("SystemScreen should be primary by default", NavigationController.isPrimaryScreen(SystemScreen)) - assertTrue("SettingsScreen should be primary by default", NavigationController.isPrimaryScreen(SettingsScreen)) + fun `isPrimaryScreen returns true for all drawer screens`() { + assertTrue("ChatScreen should be primary", NavigationController.isPrimaryScreen(ChatScreen)) + assertTrue("SkillsScreen should be primary", NavigationController.isPrimaryScreen(SkillsScreen)) + assertTrue("CronJobsScreen should be primary", NavigationController.isPrimaryScreen(CronJobsScreen)) + assertTrue("SystemScreen should be primary", NavigationController.isPrimaryScreen(SystemScreen)) + assertTrue("SettingsScreen should be primary", NavigationController.isPrimaryScreen(SettingsScreen)) + assertTrue("ProfilesScreen should be primary", NavigationController.isPrimaryScreen(ProfilesScreen)) + assertTrue("LogsScreen should be primary", NavigationController.isPrimaryScreen(LogsScreen)) + assertTrue("ConfigScreen should be primary", NavigationController.isPrimaryScreen(ConfigScreen)) + assertTrue("BotsScreen should be primary", NavigationController.isPrimaryScreen(BotsScreen)) + assertTrue("HistoryScreen should be primary", NavigationController.isPrimaryScreen(HistoryScreen)) } @Test - fun `isPrimaryScreen returns false for non-default screens`() { - assertFalse("ProfilesScreen should NOT be primary", NavigationController.isPrimaryScreen(ProfilesScreen)) - assertFalse("LogsScreen should NOT be primary", NavigationController.isPrimaryScreen(LogsScreen)) - assertFalse("ConfigScreen should NOT be primary", NavigationController.isPrimaryScreen(ConfigScreen)) + fun `isPrimaryScreen returns false for subscreen detail keys`() { + assertFalse( + "SettingsAppearance should NOT be primary", + NavigationController.isPrimaryScreen(SettingsAppearance), + ) + assertFalse( + "SettingsConnection should NOT be primary", + NavigationController.isPrimaryScreen(SettingsConnection), + ) + assertFalse("SettingsLanguage should NOT be primary", NavigationController.isPrimaryScreen(SettingsLanguage)) + assertFalse("SettingsChat should NOT be primary", NavigationController.isPrimaryScreen(SettingsChat)) + assertFalse("SettingsBehavior should NOT be primary", NavigationController.isPrimaryScreen(SettingsBehavior)) + assertFalse("SettingsAbout should NOT be primary", NavigationController.isPrimaryScreen(SettingsAbout)) + assertFalse( + "ToolsetDetailKey should NOT be primary", + NavigationController.isPrimaryScreen(ToolsetDetailKey("tool")), + ) + assertFalse( + "MemoryProviderDetailKey should NOT be primary", + NavigationController.isPrimaryScreen(MemoryProviderDetailKey("mem")), + ) + assertFalse("GroupChatKey should NOT be primary", NavigationController.isPrimaryScreen(GroupChatKey("room"))) + assertFalse("AuthLoginScreen should NOT be primary", NavigationController.isPrimaryScreen(AuthLoginScreen)) } } From 7a2c453f586d325a0e1899c4ffeef3c1871d5e63 Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Fri, 28 Aug 2026 19:44:28 +0300 Subject: [PATCH 04/14] fix(history): guard fluid pagination against infinite network loop on small session lists (#1002) Co-authored-by: M57 --- .../ui/sessions/SessionsScreen.kt | 1 + .../ui/sessions/SessionsViewModel.kt | 38 ++++++++++----- .../ui/sessions/SessionsViewModelTest.kt | 47 ++++++++++++++----- 3 files changed, 62 insertions(+), 24 deletions(-) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt index 4987dec1..a905cca3 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt @@ -768,6 +768,7 @@ fun SessionsScreen( val info = listState.layoutInfo val lastVisible = info.visibleItemsInfo.lastOrNull()?.index ?: -1 lastVisible >= 0 && + info.totalItemsCount >= AUTO_LOAD_THRESHOLD && lastVisible >= info.totalItemsCount - AUTO_LOAD_THRESHOLD } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt index f5a10e6d..7b0c9308 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt @@ -64,6 +64,7 @@ data class SessionsUiState( val isLoadingMore: Boolean = false, val sessions: List = emptyList(), val total: Int = 0, + val hasMore: Boolean = false, val errorMessage: String? = null, val stats: SessionStats = SessionStats(), val isLoadingStats: Boolean = false, @@ -89,7 +90,6 @@ data class SessionsUiState( val searchResults: List = emptyList(), val searchError: String? = null, ) { - val hasMore: Boolean get() = total > sessions.size val isSearchMode: Boolean get() = searchQuery.isNotBlank() } @@ -118,9 +118,12 @@ class SessionsViewModel : }, onSuccess = { data -> _uiState.update { + val newSessions = data.sessions.orEmpty().pinnedFirst() + val hasMore = data.sessions.size >= PAGE_SIZE && data.total > newSessions.size it.copy( - sessions = data.sessions.orEmpty().pinnedFirst(), - total = data.total, + sessions = newSessions, + total = if (hasMore) data.total else newSessions.size, + hasMore = hasMore, ) } }, @@ -153,12 +156,15 @@ class SessionsViewModel : }, onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } }, onSuccess = { data -> + val sessionsList = data.sessions.orEmpty().pinnedFirst() + val hasMore = data.sessions.size >= PAGE_SIZE && data.total > sessionsList.size _uiState.update { it.copy( isLoading = false, isLoadingMore = false, - sessions = data.sessions.orEmpty().pinnedFirst(), - total = data.total, + sessions = sessionsList, + total = if (hasMore) data.total else sessionsList.size, + hasMore = hasMore, selectedIds = emptySet(), ) } @@ -194,6 +200,13 @@ class SessionsViewModel : is NetworkResult.Success -> { val data = result.data _uiState.update { + val newSessions = + (it.sessions + data.sessions) + .distinctBy { s -> s.id } + .pinnedFirst() + val receivedFullPage = data.sessions.size >= PAGE_SIZE + val addedNewItems = newSessions.size > it.sessions.size + val hasMore = receivedFullPage && addedNewItems && data.total > newSessions.size it.copy( isLoadingMore = false, // distinctBy guards offset-pagination churn: if a new @@ -201,11 +214,9 @@ class SessionsViewModel : // shift and the next page can repeat an id we already // have — LazyColumn would crash on the duplicate key. // pinnedFirst keeps pinned sessions above the rest. - sessions = - (it.sessions + data.sessions) - .distinctBy { s -> s.id } - .pinnedFirst(), - total = data.total, + sessions = newSessions, + total = if (hasMore) data.total else newSessions.size, + hasMore = hasMore, ) } } @@ -470,11 +481,14 @@ class SessionsViewModel : when (result) { is NetworkResult.Success -> { _uiState.update { + val updatedSessions = it.sessions.filter { s -> s.id != sessionId } + val newTotal = (it.total - 1).coerceAtLeast(0) it.copy( deletingSessionIds = it.deletingSessionIds - sessionId, - sessions = it.sessions.filter { s -> s.id != sessionId }, + sessions = updatedSessions, searchResults = it.searchResults.filter { it.session_id != sessionId }, - total = (it.total - 1).coerceAtLeast(0), + total = newTotal, + hasMore = it.hasMore && newTotal > updatedSessions.size, toastMessage = "Session deleted", ) } diff --git a/app/src/test/java/com/m57/hermescontrol/ui/sessions/SessionsViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/sessions/SessionsViewModelTest.kt index ef2ba99b..94799d90 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/sessions/SessionsViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/sessions/SessionsViewModelTest.kt @@ -101,37 +101,60 @@ class SessionsViewModelTest { // ── Pagination (fluid load-more) ────────────────────────────────────── @Test - fun `loadMore appends the next page and dedupes overlapping ids`() { + fun `loadSessions with fewer than page size items caps total and sets hasMore false`() { val vm = createViewModel() - // Page 1: 2 sessions of 3 total — hasMore stays true. + // Server reports total = 10 (e.g. cross-profile count), but returns only 2 sessions (< PAGE_SIZE 50). + // hasMore must be false and total must be capped at 2 to prevent infinite auto-load loops. coEvery { mockApi.getSessions(any(), any(), any()) } returns Response.success( SessionListResponse( sessions = listOf(SessionInfo("s-1"), SessionInfo("s-2")), - total = 3, + total = 10, ), ) vm.loadSessions() testDispatcher.scheduler.advanceUntilIdle() + assertEquals(listOf("s-1", "s-2"), vm.uiState.value.sessions.map { it.id }) + assertEquals(2, vm.uiState.value.total) + assertFalse(vm.uiState.value.hasMore) + assertFalse(vm.uiState.value.isLoadingMore) + } + + @Test + fun `loadMore appends the next page and dedupes overlapping ids`() { + val vm = createViewModel() + + val page1Sessions = (1..50).map { SessionInfo("s-$it") } + // Page 1: 50 sessions of 51 total — hasMore stays true. + coEvery { mockApi.getSessions(limit = 50, offset = 0, order = any()) } returns + Response.success( + SessionListResponse( + sessions = page1Sessions, + total = 51, + ), + ) + vm.loadSessions() + testDispatcher.scheduler.advanceUntilIdle() + assertEquals(50, vm.uiState.value.sessions.size) assertTrue(vm.uiState.value.hasMore) assertFalse(vm.uiState.value.isLoadingMore) // Page 2 overlaps page 1 (offset churn: a new session landed on top // between loads) — the duplicate id must not double-append. - coEvery { mockApi.getSessions(any(), any(), any()) } returns + coEvery { mockApi.getSessions(limit = 50, offset = 50, order = any()) } returns Response.success( SessionListResponse( - sessions = listOf(SessionInfo("s-3"), SessionInfo("s-2")), - total = 3, + sessions = listOf(SessionInfo("s-51"), SessionInfo("s-50")), + total = 51, ), ) vm.loadMore() testDispatcher.scheduler.advanceUntilIdle() - assertEquals(listOf("s-1", "s-2", "s-3"), vm.uiState.value.sessions.map { it.id }) - assertEquals(3, vm.uiState.value.total) + assertEquals(51, vm.uiState.value.sessions.size) + assertEquals(51, vm.uiState.value.total) assertFalse(vm.uiState.value.hasMore) assertFalse(vm.uiState.value.isLoadingMore) } @@ -140,11 +163,12 @@ class SessionsViewModelTest { fun `loadMore is a no-op while a load is already running`() { val vm = createViewModel() - coEvery { mockApi.getSessions(any(), any(), any()) } returns + val page1Sessions = (1..50).map { SessionInfo("s-$it") } + coEvery { mockApi.getSessions(limit = 50, offset = 0, order = any()) } returns Response.success( SessionListResponse( - sessions = listOf(SessionInfo("s-1")), - total = 2, + sessions = page1Sessions, + total = 52, ), ) vm.loadSessions() @@ -158,7 +182,6 @@ class SessionsViewModelTest { // loadSessions (1) + exactly one loadMore (1) = 2 API hits total. coVerify(exactly = 2) { mockApi.getSessions(any(), any(), any()) } - assertEquals(listOf("s-1"), vm.uiState.value.sessions.map { it.id }) assertFalse(vm.uiState.value.isLoadingMore) } From db3f8c736ee922783b3d5ee2ae12c47b5dd6de54 Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Fri, 28 Aug 2026 20:27:17 +0300 Subject: [PATCH 05/14] feat(markdown): support variable-length code fences and nested codeblocks (#1003) Co-authored-by: M57 --- .../m57/hermescontrol/ui/chat/MarkdownText.kt | 47 ++++++++-- .../ui/chat/MarkdownTextFeatureTest.kt | 86 +++++++++++++++++++ 2 files changed, 126 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/MarkdownText.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/MarkdownText.kt index f76cca5f..34c68070 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/MarkdownText.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/MarkdownText.kt @@ -694,15 +694,15 @@ internal fun parseBlocks(src: String): List { continue } + val codeFence = parseCodeFenceStart(line) when { - line.startsWith("```") -> { - val lang = line.removePrefix("```").trim().ifBlank { null } - val end = (i + 1 until lines.size).firstOrNull { lines[it].startsWith("```") } + codeFence != null -> { + val end = (i + 1 until lines.size).firstOrNull { isCodeFenceEnd(lines[it], codeFence) } if (end != null) { blocks.add( MdBlock.Code( code = lines.subList(i + 1, end).joinToString("\n"), - language = lang, + language = codeFence.language, ), ) i = end + 1 @@ -710,7 +710,7 @@ internal fun parseBlocks(src: String): List { blocks.add( MdBlock.Code( code = lines.subList(i + 1, lines.size).joinToString("\n"), - language = lang, + language = codeFence.language, ), ) i = lines.size @@ -1023,12 +1023,45 @@ private fun splitRow(line: String): List { return trimmed.split('|').map { it.trim() } } +private data class CodeFenceInfo( + val fenceChar: Char, + val fenceLength: Int, + val language: String?, +) + +private fun parseCodeFenceStart(line: String): CodeFenceInfo? { + val trimmed = line.trimStart() + if (trimmed.length < 3) return null + val firstChar = trimmed[0] + if (firstChar != '`' && firstChar != '~') return null + val count = trimmed.takeWhile { it == firstChar }.length + if (count < 3) return null + val rest = trimmed.substring(count).trim() + if (firstChar == '`' && rest.contains('`')) return null + return CodeFenceInfo( + fenceChar = firstChar, + fenceLength = count, + language = rest.ifBlank { null }, + ) +} + +private fun isCodeFenceEnd( + line: String, + fence: CodeFenceInfo, +): Boolean { + val trimmed = line.trimStart() + val count = trimmed.takeWhile { it == fence.fenceChar }.length + if (count < fence.fenceLength) return false + val rest = trimmed.substring(count).trim() + return rest.isEmpty() +} + private fun isDefListStart( lines: List, i: Int, ): Boolean { val l = lines[i].trim() - if (l.isBlank() || l.startsWith("#") || l.startsWith(">") || l.startsWith("```")) return false + if (l.isBlank() || l.startsWith("#") || l.startsWith(">") || parseCodeFenceStart(l) != null) return false if (LIST_ITEM_LINE_RE.matches(lines[i])) return false if (i + 1 >= lines.size) return false return lines[i + 1].trim().startsWith(":") @@ -1050,7 +1083,7 @@ private fun fallthroughToParagraph( while ( i < lines.size && lines[i].isNotBlank() && - !lines[i].startsWith("```") && + parseCodeFenceStart(lines[i]) == null && !isValidHeading(lines[i]) && !lines[i].startsWith(">") && !LIST_ITEM_LINE_RE.matches(lines[i]) && diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/MarkdownTextFeatureTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/MarkdownTextFeatureTest.kt index 0139b0d2..032c9db4 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/MarkdownTextFeatureTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/MarkdownTextFeatureTest.kt @@ -13,6 +13,7 @@ import com.m57.hermescontrol.theme.StatusYellowContainer import com.m57.hermescontrol.theme.searchHighlightColors import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test @@ -452,4 +453,89 @@ class MarkdownTextFeatureTest { assertEquals(2, o16.index) assertEquals(0, o16.level) } + + // 19. NESTED CODEBLOCKS & EXTENDED CODE FENCES + @Test + fun testNestedCodeBlock_fourBackticksWrappingThreeBackticks() { + val md = + """ + ````markdown + Here is a nested block: + ```python + def foo(): + print("hello") + ``` + End of inner block. + ```` + """.trimIndent() + + val blocks = parseBlocks(md) + assertEquals("Should parse as single code block", 1, blocks.size) + val codeBlock = blocks.single() as MdBlock.Code + assertEquals("markdown", codeBlock.language) + val expectedInner = + """ + Here is a nested block: + ```python + def foo(): + print("hello") + ``` + End of inner block. + """.trimIndent() + assertEquals(expectedInner, codeBlock.code) + } + + @Test + fun testNestedCodeBlock_fiveBackticksWrappingFourAndThree() { + val md = + """ + ````` + ````markdown + ```python + print("deep") + ``` + ```` + ````` + """.trimIndent() + + val blocks = parseBlocks(md) + assertEquals(1, blocks.size) + val codeBlock = blocks.single() as MdBlock.Code + assertNull("Language should be null when omitted", codeBlock.language) + assertTrue(codeBlock.code.contains("````markdown")) + assertTrue(codeBlock.code.contains("```python")) + } + + @Test + fun testTildeCodeBlock_parses() { + val md = + """ + ~~~json + {"key": "value"} + ~~~ + """.trimIndent() + + val blocks = parseBlocks(md) + assertEquals(1, blocks.size) + val codeBlock = blocks.single() as MdBlock.Code + assertEquals("json", codeBlock.language) + assertEquals("{\"key\": \"value\"}", codeBlock.code) + } + + @Test + fun testTildeCodeBlock_wrappingBackticks() { + val md = + """ + ~~~~ + ```python + def foo(): pass + ``` + ~~~~ + """.trimIndent() + + val blocks = parseBlocks(md) + assertEquals(1, blocks.size) + val codeBlock = blocks.single() as MdBlock.Code + assertTrue(codeBlock.code.contains("```python")) + } } From 5bc6e5f855c912fd957679a2766465c1f56ca088 Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Sat, 29 Aug 2026 11:36:56 +0300 Subject: [PATCH 06/14] feat(settings): in-app chat font size setting (#1004) (#1005) Co-authored-by: M57 --- .../com/m57/hermescontrol/MainActivity.kt | 2 + .../data/config/ServerStoreState.kt | 1 + .../hermescontrol/data/local/AuthManager.kt | 13 + .../java/com/m57/hermescontrol/theme/Theme.kt | 3 + .../ui/bots/group/GroupChatScreen.kt | 76 +++-- .../ui/chat/fullbleed/FullBleedChatList.kt | 320 +++++++++--------- .../ui/settings/SettingsSubPages.kt | 2 + .../ui/settings/SettingsViewModel.kt | 8 + .../settings/components/AppearanceSection.kt | 170 ++++++++++ app/src/main/res/values-ko/strings.xml | 11 + app/src/main/res/values-zh/strings.xml | 11 + app/src/main/res/values/strings.xml | 11 + .../data/config/ServerStoreTest.kt | 1 + .../ui/settings/SettingsViewModelTest.kt | 14 + 14 files changed, 461 insertions(+), 182 deletions(-) diff --git a/app/src/main/java/com/m57/hermescontrol/MainActivity.kt b/app/src/main/java/com/m57/hermescontrol/MainActivity.kt index 75692ca4..1c73dc76 100644 --- a/app/src/main/java/com/m57/hermescontrol/MainActivity.kt +++ b/app/src/main/java/com/m57/hermescontrol/MainActivity.kt @@ -54,10 +54,12 @@ class MainActivity : ComponentActivity() { val themePreference by AuthManager.themePreferenceFlow.collectAsState() val useDynamicColors by AuthManager.useDynamicColorsFlow.collectAsState() val themePreset by AuthManager.themePresetFlow.collectAsState() + val chatFontScale by AuthManager.chatFontScaleFlow.collectAsState() HermesControlTheme( themePreference = themePreference, useDynamicColors = useDynamicColors, themePreset = themePreset, + chatFontScale = chatFontScale, ) { Surface( modifier = Modifier.fillMaxSize(), diff --git a/app/src/main/java/com/m57/hermescontrol/data/config/ServerStoreState.kt b/app/src/main/java/com/m57/hermescontrol/data/config/ServerStoreState.kt index 0aaa86e2..1de600f7 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/config/ServerStoreState.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/config/ServerStoreState.kt @@ -21,6 +21,7 @@ data class ServerStoreState( val wsAuthParam: String = "token", val typingEffectEnabled: Boolean = true, val typingEffectDelayMs: Int = 30, + val chatFontScale: Float = 1.0f, // App display language. "system" = follow device locale; otherwise a BCP-47 // language code such as "en" or "ko". Applied via ContextWrapper in MainActivity. val appLanguage: String = "system", diff --git a/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt b/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt index f290768e..4c06b7bf 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt @@ -74,6 +74,9 @@ object AuthManager { private val _themePresetFlow = MutableStateFlow(ThemePreset.DEFAULT) val themePresetFlow: StateFlow = _themePresetFlow.asStateFlow() + private val _chatFontScaleFlow = MutableStateFlow(1.0f) + val chatFontScaleFlow: StateFlow = _chatFontScaleFlow.asStateFlow() + private val _tokenFlow = MutableStateFlow(null) val tokenFlow: StateFlow = _tokenFlow.asStateFlow() @@ -180,6 +183,7 @@ object AuthManager { _themePreferenceFlow.value = state.themePreference _useDynamicColorsFlow.value = state.useDynamicColors _themePresetFlow.value = state.themePreset + _chatFontScaleFlow.value = state.chatFontScale // B7 (Jul 08 2026, kanban t_470): keep cookie scope aligned with active profile. appScope?.launch { syncCookieStoreForProfile(state.selectedProfileId) } } @@ -640,6 +644,15 @@ object AuthManager { serverStore.update { it.copy(typingEffectDelayMs = delayMs) } } + // ── Chat Font Scale (issue #1004) ─────────────────────────────────── + + fun getChatFontScale(): Float = serverStore.getLatestState().chatFontScale + + fun setChatFontScale(scale: Float) { + serverStore.update { it.copy(chatFontScale = scale) } + _chatFontScaleFlow.value = scale + } + // ── In-app update check (issue #867) ───────────────────────────────── /** App version the silent update check last completed for (null = never). */ diff --git a/app/src/main/java/com/m57/hermescontrol/theme/Theme.kt b/app/src/main/java/com/m57/hermescontrol/theme/Theme.kt index 66db1e83..5767ec7e 100644 --- a/app/src/main/java/com/m57/hermescontrol/theme/Theme.kt +++ b/app/src/main/java/com/m57/hermescontrol/theme/Theme.kt @@ -26,6 +26,7 @@ enum class ThemePreset { DEFAULT, MONOCHROME, GRUVBOX, CATPPUCCIN, AMOLED, NORD val LocalThemePreference = compositionLocalOf { ThemePreference.SYSTEM } val LocalThemePreset = compositionLocalOf { ThemePreset.DEFAULT } +val LocalChatFontScale = compositionLocalOf { 1.0f } /** * The 6 preset themes — one file each, all built from the same @@ -75,6 +76,7 @@ fun HermesControlTheme( themePreference: ThemePreference = LocalThemePreference.current, useDynamicColors: Boolean = true, themePreset: ThemePreset = ThemePreset.DEFAULT, + chatFontScale: Float = LocalChatFontScale.current, content: @Composable () -> Unit, ) { val darkTheme = @@ -103,6 +105,7 @@ fun HermesControlTheme( CompositionLocalProvider( LocalThemePreference provides themePreference, LocalThemePreset provides themePreset, + LocalChatFontScale provides chatFontScale, LocalHermesStatusColors provides statusColors, LocalSpacing provides SpacingDefaults, LocalMotion provides MotionDefaults, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatScreen.kt index 194a0e21..d39b926d 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatScreen.kt @@ -32,6 +32,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -43,14 +44,17 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.m57.hermescontrol.R +import com.m57.hermescontrol.theme.LocalChatFontScale import com.m57.hermescontrol.ui.chat.MarkdownText import com.m57.hermescontrol.ui.common.BotAvatar import com.m57.hermescontrol.ui.common.EmptyState @@ -141,38 +145,50 @@ fun GroupChatScreen( } else -> { - LazyColumn( - state = listState, - modifier = - Modifier - .weight(1f) - .fillMaxWidth(), - contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - items( - items = state.messages, - key = { it.id }, - ) { message -> - GroupMessageCard(message = message) + val currentDensity = LocalDensity.current + val chatFontScale = LocalChatFontScale.current + val chatDensity = + remember(currentDensity, chatFontScale) { + Density( + density = currentDensity.density, + fontScale = currentDensity.fontScale * chatFontScale, + ) } - state.activeSpeaker?.let { speaker -> - item(key = "active_speaker") { - Row( - modifier = Modifier.padding(start = 8.dp, top = 4.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - CircularProgressIndicator( - modifier = Modifier.size(14.dp), - strokeWidth = 2.dp, - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = stringResource(R.string.group_chat_thinking, speaker), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.primary, - ) + CompositionLocalProvider(LocalDensity provides chatDensity) { + LazyColumn( + state = listState, + modifier = + Modifier + .weight(1f) + .fillMaxWidth(), + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + items( + items = state.messages, + key = { it.id }, + ) { message -> + GroupMessageCard(message = message) + } + + state.activeSpeaker?.let { speaker -> + item(key = "active_speaker") { + Row( + modifier = Modifier.padding(start = 8.dp, top = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + CircularProgressIndicator( + modifier = Modifier.size(14.dp), + strokeWidth = 2.dp, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(R.string.group_chat_thinking, speaker), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, + ) + } } } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatList.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatList.kt index 58e8e8b9..5f3eccdf 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatList.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatList.kt @@ -10,13 +10,17 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material3.CircularProgressIndicator import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.dp import com.m57.hermescontrol.R +import com.m57.hermescontrol.theme.LocalChatFontScale import com.m57.hermescontrol.ui.chat.ChatBubble import com.m57.hermescontrol.ui.chat.ChatMessage import com.m57.hermescontrol.ui.chat.ChatSearchState @@ -116,179 +120,191 @@ fun FullBleedChatList( } } - LazyColumn( - state = listState, - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(vertical = 8.dp), - ) { - if (isLoadingOlder) { - item(key = "loading-older") { - Box( - modifier = Modifier.fillMaxWidth().padding(12.dp), - contentAlignment = Alignment.Center, - ) { - CircularProgressIndicator(modifier = Modifier.size(24.dp)) - } - } + val currentDensity = LocalDensity.current + val chatFontScale = LocalChatFontScale.current + val chatDensity = + remember(currentDensity, chatFontScale) { + Density( + density = currentDensity.density, + fontScale = currentDensity.fontScale * chatFontScale, + ) } - var entryIndex = 0 - turns.forEach { turn -> - when (turn) { - is ChatTurn.User -> { - // Eager captures: item lambda reads these at - // composition time (lazy), so capture now. - val userMessage = turn.message - val milestone = toolMilestones[entryIndex] - item(key = "user-${userMessage.id}") { - Column(modifier = Modifier.padding(bottom = 12.dp)) { - renderChatBubble( - message = userMessage, - searchQuery = if (searchState.isActive) searchState.query else "", - isCurrentMatch = - searchState.currentMatchId != null && - searchState.currentMatchId == userMessage.id, - onOpenAttachment = viewModel::openAttachment, - onSaveAttachment = onSaveAttachment, - savingAttachmentPath = savingAttachmentPath, - openingAttachmentPath = openingAttachmentPath, - onImageClick = onImageClick, - ) - milestone?.let { count -> - ToolCallDivider(count = count, maxPerTurn = maxToolCallsPerTurn) - } - } + CompositionLocalProvider(LocalDensity provides chatDensity) { + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(vertical = 8.dp), + ) { + if (isLoadingOlder) { + item(key = "loading-older") { + Box( + modifier = Modifier.fillMaxWidth().padding(12.dp), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator(modifier = Modifier.size(24.dp)) } - entryIndex++ } + } - is ChatTurn.Agent -> { - var firstProseSeen = false - // Reasoning hoist: the turn's reasoning renders at the - // TOP of the turn — above tool rows — so thinking - // leads, then the tool work, then the answer. The - // matching prose entry renders without its own card. - val turnReasoning = - turn.entries - .filterIsInstance() - .firstOrNull { it.message.reasoningText.isNotBlank() } - if (turnReasoning != null) { - val reasoning = turnReasoning.message - item(key = "reasoning-${reasoning.id}") { - Column(modifier = Modifier.padding(bottom = 6.dp)) { - // Lead the turn with the assistant identity - // and timestamp, then the thinking block. - AssistantTurnHeader(timestamp = reasoning.timestamp) - ReasoningCard( - reasoningText = reasoning.reasoningText, - isStreaming = reasoning.isStreaming, + var entryIndex = 0 + turns.forEach { turn -> + when (turn) { + is ChatTurn.User -> { + // Eager captures: item lambda reads these at + // composition time (lazy), so capture now. + val userMessage = turn.message + val milestone = toolMilestones[entryIndex] + item(key = "user-${userMessage.id}") { + Column(modifier = Modifier.padding(bottom = 12.dp)) { + renderChatBubble( + message = userMessage, + searchQuery = if (searchState.isActive) searchState.query else "", + isCurrentMatch = + searchState.currentMatchId != null && + searchState.currentMatchId == userMessage.id, + onOpenAttachment = viewModel::openAttachment, + onSaveAttachment = onSaveAttachment, + savingAttachmentPath = savingAttachmentPath, + openingAttachmentPath = openingAttachmentPath, + onImageClick = onImageClick, ) + milestone?.let { count -> + ToolCallDivider(count = count, maxPerTurn = maxToolCallsPerTurn) + } } } + entryIndex++ } - turn.entries.forEach { entry -> - when (entry) { - is AgentEntry.Prose -> { - val proseMessage = entry.message - val showTurnHeader = !firstProseSeen && turnReasoning == null - val hoistedReasoning = - turnReasoning != null && - proseMessage.id == turnReasoning.message.id - val milestone = toolMilestones[entryIndex] - item(key = "prose-${proseMessage.id}") { - Column(modifier = Modifier.padding(bottom = 12.dp)) { - if (proseMessage.isStreaming && typingEffectEnabled) { - StreamingFullBleedWithTypingEffect( - streaming = proseMessage, - typingDelayMs = typingEffectDelayMs, - isDark = isDark, - showTurnHeader = showTurnHeader, - showReasoning = !hoistedReasoning, - ) - } else { - FullBleedAgentMessage( - message = proseMessage, - showTurnHeader = showTurnHeader, - isDarkTheme = isDark, - // Highlight only bubbles that actually contain a match — - // the rest skip the highlight scan entirely. - searchQuery = - if (searchState.isActive && - proseMessage.id in searchState.matchedIds - ) { - searchState.query - } else { - "" - }, - isCurrentMatch = - searchState.currentMatchId != null && - searchState.currentMatchId == proseMessage.id, - showReasoning = !hoistedReasoning, - onOpenAttachment = viewModel::openAttachment, - onSaveAttachment = onSaveAttachment, - savingAttachmentPath = savingAttachmentPath, - openingAttachmentPath = openingAttachmentPath, - canSaveAttachment = savingAttachmentPath == null, - onImageClick = onImageClick, - ) - } - milestone?.let { count -> - ToolCallDivider(count = count, maxPerTurn = maxToolCallsPerTurn) - } - } - } - firstProseSeen = true - entryIndex++ - } - is AgentEntry.ToolRow -> { - val toolMessage = entry.message - val milestone = toolMilestones[entryIndex] - item(key = "tool-${toolMessage.id}") { - Column(modifier = Modifier.padding(bottom = 6.dp)) { - FullBleedToolRow(toolMessage) - milestone?.let { count -> - ToolCallDivider(count = count, maxPerTurn = maxToolCallsPerTurn) - } - } + is ChatTurn.Agent -> { + var firstProseSeen = false + // Reasoning hoist: the turn's reasoning renders at the + // TOP of the turn — above tool rows — so thinking + // leads, then the tool work, then the answer. The + // matching prose entry renders without its own card. + val turnReasoning = + turn.entries + .filterIsInstance() + .firstOrNull { it.message.reasoningText.isNotBlank() } + if (turnReasoning != null) { + val reasoning = turnReasoning.message + item(key = "reasoning-${reasoning.id}") { + Column(modifier = Modifier.padding(bottom = 6.dp)) { + // Lead the turn with the assistant identity + // and timestamp, then the thinking block. + AssistantTurnHeader(timestamp = reasoning.timestamp) + ReasoningCard( + reasoningText = reasoning.reasoningText, + isStreaming = reasoning.isStreaming, + ) } - entryIndex++ } - - is AgentEntry.SystemEvent -> { - val sysMessage = entry.message - item(key = "sys-${sysMessage.id}") { - Column(modifier = Modifier.padding(bottom = 6.dp)) { - if (sysMessage.displayKind != null) { - // Timeline marker (issue #904): - // model/personality switches and - // auto-continues render as a chip. - TimelineMarkerChip(message = sysMessage) - } else { - FullBleedSystemEvent( - message = sysMessage, - onRespondApproval = viewModel::respondToApproval, - ) + } + turn.entries.forEach { entry -> + when (entry) { + is AgentEntry.Prose -> { + val proseMessage = entry.message + val showTurnHeader = !firstProseSeen && turnReasoning == null + val hoistedReasoning = + turnReasoning != null && + proseMessage.id == turnReasoning.message.id + val milestone = toolMilestones[entryIndex] + item(key = "prose-${proseMessage.id}") { + Column(modifier = Modifier.padding(bottom = 12.dp)) { + if (proseMessage.isStreaming && typingEffectEnabled) { + StreamingFullBleedWithTypingEffect( + streaming = proseMessage, + typingDelayMs = typingEffectDelayMs, + isDark = isDark, + showTurnHeader = showTurnHeader, + showReasoning = !hoistedReasoning, + ) + } else { + FullBleedAgentMessage( + message = proseMessage, + showTurnHeader = showTurnHeader, + isDarkTheme = isDark, + // Highlight only bubbles that actually contain a match — + // the rest skip the highlight scan entirely. + searchQuery = + if (searchState.isActive && + proseMessage.id in searchState.matchedIds + ) { + searchState.query + } else { + "" + }, + isCurrentMatch = + searchState.currentMatchId != null && + searchState.currentMatchId == proseMessage.id, + showReasoning = !hoistedReasoning, + onOpenAttachment = viewModel::openAttachment, + onSaveAttachment = onSaveAttachment, + savingAttachmentPath = savingAttachmentPath, + openingAttachmentPath = openingAttachmentPath, + canSaveAttachment = savingAttachmentPath == null, + onImageClick = onImageClick, + ) + } + milestone?.let { count -> + ToolCallDivider(count = count, maxPerTurn = maxToolCallsPerTurn) + } } } + firstProseSeen = true + entryIndex++ + } + + is AgentEntry.ToolRow -> { + val toolMessage = entry.message + val milestone = toolMilestones[entryIndex] + item(key = "tool-${toolMessage.id}") { + Column(modifier = Modifier.padding(bottom = 6.dp)) { + FullBleedToolRow(toolMessage) + milestone?.let { count -> + ToolCallDivider(count = count, maxPerTurn = maxToolCallsPerTurn) + } + } + } + entryIndex++ + } + + is AgentEntry.SystemEvent -> { + val sysMessage = entry.message + item(key = "sys-${sysMessage.id}") { + Column(modifier = Modifier.padding(bottom = 6.dp)) { + if (sysMessage.displayKind != null) { + // Timeline marker (issue #904): + // model/personality switches and + // auto-continues render as a chip. + TimelineMarkerChip(message = sysMessage) + } else { + FullBleedSystemEvent( + message = sysMessage, + onRespondApproval = viewModel::respondToApproval, + ) + } + } + } + entryIndex++ } - entryIndex++ } } } } } - } - // Clarify bubble — rendered at the very bottom - if (clarifyRequest != null) { - item(key = "clarify_bubble") { - ClarifyBubble( - text = clarifyRequest.text, - options = clarifyRequest.options, - onOptionSelected = { option -> onRespondClarify?.invoke(option) }, - onDismiss = { onDismissClarify?.invoke() }, - ) + // Clarify bubble — rendered at the very bottom + if (clarifyRequest != null) { + item(key = "clarify_bubble") { + ClarifyBubble( + text = clarifyRequest.text, + options = clarifyRequest.options, + onOptionSelected = { option -> onRespondClarify?.invoke(option) }, + onDismiss = { onDismissClarify?.invoke() }, + ) + } } } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsSubPages.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsSubPages.kt index b97d5f68..7e019895 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsSubPages.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsSubPages.kt @@ -191,6 +191,8 @@ internal fun SettingsChatPage( onTypingEffectEnabledChange = viewModel::onTypingEffectEnabledChange, typingEffectDelayMs = state.typingEffectDelayMs, onTypingEffectDelayMsChange = viewModel::onTypingEffectDelayMsChange, + chatFontScale = state.chatFontScale, + onChatFontScaleChange = viewModel::onChatFontScaleChange, ) } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsViewModel.kt index 400d71b8..7d542fd0 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsViewModel.kt @@ -36,6 +36,7 @@ data class SettingsUiState( val isSaved: Boolean = false, val typingEffectEnabled: Boolean = false, val typingEffectDelayMs: Int = 30, + val chatFontScale: Float = 1.0f, val profiles: List = emptyList(), val selectedProfileId: String? = null, val renameProfileName: String = "", @@ -76,6 +77,7 @@ class SettingsViewModel( val themePreset = AuthManager.getThemePreset() val typingEffectEnabled = AuthManager.isTypingEffectEnabled() val typingEffectDelayMs = AuthManager.getTypingEffectDelayMs() + val chatFontScale = AuthManager.getChatFontScale() val profiles = AuthManager.getConnectionProfiles() val appLanguage = AuthManager.getAppLanguage() val renameProfileName = @@ -95,6 +97,7 @@ class SettingsViewModel( themePreset = themePreset, typingEffectEnabled = typingEffectEnabled, typingEffectDelayMs = typingEffectDelayMs, + chatFontScale = chatFontScale, profiles = profiles, selectedProfileId = selectedId, renameProfileName = renameProfileName, @@ -310,6 +313,11 @@ class SettingsViewModel( AuthManager.setTypingEffectDelayMs(delayMs) } + fun onChatFontScaleChange(scale: Float) { + _uiState.update { it.copy(chatFontScale = scale, isSaved = false) } + AuthManager.setChatFontScale(scale) + } + /** Clear all auth credentials — logs out and returns to landing screen. */ fun logout() { AuthManager.setToken(null) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/components/AppearanceSection.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/components/AppearanceSection.kt index 9df266d1..fab1dd3b 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/settings/components/AppearanceSection.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/components/AppearanceSection.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.MaterialTheme @@ -18,22 +19,29 @@ import androidx.compose.material3.SegmentedButton import androidx.compose.material3.SegmentedButtonDefaults import androidx.compose.material3.SingleChoiceSegmentedButtonRow import androidx.compose.material3.Slider +import androidx.compose.material3.Surface import androidx.compose.material3.Switch import androidx.compose.material3.SwitchDefaults import androidx.compose.material3.Text 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.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.dp import com.m57.hermescontrol.R import com.m57.hermescontrol.theme.ThemePreference import com.m57.hermescontrol.theme.ThemePreset import com.m57.hermescontrol.ui.settings.SectionCard +import kotlin.math.abs +import kotlin.math.roundToInt @Composable internal fun AppearanceSection( @@ -194,10 +202,172 @@ internal fun ChatSection( onTypingEffectEnabledChange: (Boolean) -> Unit, typingEffectDelayMs: Int, onTypingEffectDelayMsChange: (Int) -> Unit, + chatFontScale: Float = 1.0f, + onChatFontScaleChange: (Float) -> Unit = {}, ) { + val fontScaleOptions = listOf(0.85f, 1.0f, 1.15f, 1.30f, 1.50f) + val currentIndex = + fontScaleOptions.indexOfFirst { abs(it - chatFontScale) < 0.05f } + .takeIf { it >= 0 } ?: 1 + SectionCard { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.settings_item_chat_font_size), + style = MaterialTheme.typography.bodyLarge, + ) + Text( + text = stringResource(R.string.settings_desc_chat_font_size), + style = + MaterialTheme.typography.bodySmall.copy( + color = MaterialTheme.colorScheme.onSurfaceVariant, + ), + ) + } + val scaleLabel = + when (currentIndex) { + 0 -> stringResource(R.string.settings_font_scale_small) + 1 -> stringResource(R.string.settings_font_scale_default) + 2 -> stringResource(R.string.settings_font_scale_large) + 3 -> stringResource(R.string.settings_font_scale_xlarge) + else -> stringResource(R.string.settings_font_scale_huge) + } + Text( + text = scaleLabel, + style = + MaterialTheme.typography.labelMedium.copy( + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.SemiBold, + ), + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + Slider( + value = currentIndex.toFloat(), + onValueChange = { onChatFontScaleChange(fontScaleOptions[it.roundToInt()]) }, + valueRange = 0f..(fontScaleOptions.size - 1).toFloat(), + steps = fontScaleOptions.size - 2, + modifier = Modifier.fillMaxWidth(), + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = "0.85×", + style = + MaterialTheme.typography.labelSmall.copy( + color = MaterialTheme.colorScheme.onSurfaceVariant, + ), + ) + Text( + text = "1.0×", + style = + MaterialTheme.typography.labelSmall.copy( + color = MaterialTheme.colorScheme.onSurfaceVariant, + ), + ) + Text( + text = "1.5×", + style = + MaterialTheme.typography.labelSmall.copy( + color = MaterialTheme.colorScheme.onSurfaceVariant, + ), + ) + } + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = stringResource(R.string.settings_chat_preview_title), + style = + MaterialTheme.typography.labelMedium.copy( + color = MaterialTheme.colorScheme.onSurfaceVariant, + ), + ) + + Spacer(modifier = Modifier.height(8.dp)) + + val currentDensity = LocalDensity.current + val previewDensity = + remember(currentDensity, chatFontScale) { + Density( + density = currentDensity.density, + fontScale = currentDensity.fontScale * chatFontScale, + ) + } + + CompositionLocalProvider(LocalDensity provides previewDensity) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surfaceContainer, + ) { + Column( + modifier = Modifier.padding(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + // User bubble preview + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + Surface( + shape = + RoundedCornerShape( + topStart = 14.dp, + topEnd = 14.dp, + bottomStart = 14.dp, + bottomEnd = 4.dp, + ), + color = MaterialTheme.colorScheme.primary, + ) { + Text( + text = stringResource(R.string.settings_chat_preview_user), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), + ) + } + } + + // Assistant bubble preview + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Start, + ) { + Surface( + shape = + RoundedCornerShape( + topStart = 14.dp, + topEnd = 14.dp, + bottomStart = 4.dp, + bottomEnd = 14.dp, + ), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + ) { + Text( + text = stringResource(R.string.settings_chat_preview_agent), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), + ) + } + } + } + } + } + + Spacer(modifier = Modifier.height(20.dp)) + Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 18ba68fb..e281ef44 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -187,6 +187,17 @@ 지연: %1$d ms 10 ms 100 ms + 채팅 글꼴 크기 + 채팅 메시지의 텍스트 크기를 조절합니다 + 글꼴 크기: %1$s + 작게 (0.85×) + 기본 (1.0×) + 크게 (1.15×) + 아주 크게 (1.3×) + 최대 (1.5×) + 미리보기 + 채팅 텍스트 크기를 조절할 수 있나요? + 완료되었습니다! 여기에서 글꼴 크기를 조정할 수 있습니다. 연결 테스트 정보 헤르메스 모바일 diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index e015cf23..15c51dc7 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -184,6 +184,17 @@ 延迟:%1$d 毫秒 10 毫秒 100 毫秒 + 聊天字体大小 + 缩放聊天消息文字大小 + 字体大小:%1$s + 小 (0.85×) + 默认 (1.0×) + 大 (1.15×) + 特大 (1.3×) + 极大 (1.5×) + 预览 + 可以调整聊天字体大小吗? + 已搞定!你可以在这里自定义字体缩放比例。 测试连接 关于 语言 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 77e22e62..c24ab7cf 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -207,6 +207,17 @@ Delay: %1$d ms 10 ms 100 ms + Chat Font Size + Scale text size in chat messages + Font Size: %1$s + Small (0.85×) + Default (1.0×) + Large (1.15×) + Extra Large (1.3×) + Huge (1.5×) + Preview + Can you adjust the chat text size? + Done! You can customize the font scale right here. Test Connection About Language diff --git a/app/src/test/java/com/m57/hermescontrol/data/config/ServerStoreTest.kt b/app/src/test/java/com/m57/hermescontrol/data/config/ServerStoreTest.kt index 9441ef35..2996d277 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/config/ServerStoreTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/config/ServerStoreTest.kt @@ -15,6 +15,7 @@ class ServerStoreTest { assertEquals(9119, state.port) assertTrue(state.autoReconnect) assertEquals("token", state.wsAuthParam) + assertEquals(1.0f, state.chatFontScale) assertTrue(state.connectionProfiles.isEmpty()) assertNull(state.selectedProfileId) } diff --git a/app/src/test/java/com/m57/hermescontrol/ui/settings/SettingsViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/settings/SettingsViewModelTest.kt index 3b3e94c6..c50acacc 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/settings/SettingsViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/settings/SettingsViewModelTest.kt @@ -68,6 +68,7 @@ class SettingsViewModelTest { every { AuthManager.getThemePreset() } returns ThemePreset.DEFAULT every { AuthManager.isTypingEffectEnabled() } returns true every { AuthManager.getTypingEffectDelayMs() } returns 30 + every { AuthManager.getChatFontScale() } returns 1.0f every { AuthManager.getConnectionProfiles() } returns emptyList() every { AuthManager.getSelectedProfileId() } answers { storedSelectedProfileId } every { AuthManager.baseUrl() } returns "http://127.0.0.1:9119/" @@ -79,6 +80,7 @@ class SettingsViewModelTest { every { AuthManager.setThemePreset(any()) } returns Unit every { AuthManager.setTypingEffectEnabled(any()) } returns Unit every { AuthManager.setTypingEffectDelayMs(any()) } returns Unit + every { AuthManager.setChatFontScale(any()) } returns Unit every { AuthManager.setSelectedProfileId(any()) } answers { storedSelectedProfileId = firstArg() } @@ -130,6 +132,7 @@ class SettingsViewModelTest { every { AuthManager.getThemePreset() } returns ThemePreset.CATPPUCCIN every { AuthManager.isTypingEffectEnabled() } returns false every { AuthManager.getTypingEffectDelayMs() } returns 15 + every { AuthManager.getChatFontScale() } returns 1.30f every { AuthManager.getConnectionProfiles() } returns testProfiles every { AuthManager.getAppLanguage() } returns "fr" @@ -146,12 +149,23 @@ class SettingsViewModelTest { assertEquals(ThemePreset.CATPPUCCIN, state.themePreset) assertEquals(false, state.typingEffectEnabled) assertEquals(15, state.typingEffectDelayMs) + assertEquals(1.30f, state.chatFontScale) assertEquals(testProfiles, state.profiles) assertEquals("prof-2", state.selectedProfileId) assertEquals("Home", state.renameProfileName) assertEquals("fr", state.appLanguage) } + @Test + fun testOnChatFontScaleChange_updatesStateAndAuthManager() { + val viewModel = createViewModel() + + viewModel.onChatFontScaleChange(1.15f) + + assertEquals(1.15f, viewModel.uiState.value.chatFontScale) + verify { AuthManager.setChatFontScale(1.15f) } + } + @Test fun testLoadSettings_noSelectedProfile_renameEmpty() { every { AuthManager.getConnectionProfiles() } returns testProfiles From 7e24f5eba31474aea1e3a58d1fe48eb7d65be324 Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Sat, 29 Aug 2026 13:56:40 +0300 Subject: [PATCH 07/14] fix(bots): sync cross-device group chats and roster from desktop projection (#1006) Co-authored-by: M57 --- .../m57/hermescontrol/data/model/Profile.kt | 86 +++++++- .../hermescontrol/ui/bots/BotsViewModel.kt | 201 ++++++++++++++++-- .../ui/bots/group/GroupChatViewModel.kt | 138 +++++------- .../ui/bots/BotsViewModelTest.kt | 66 ++++++ 4 files changed, 387 insertions(+), 104 deletions(-) diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/Profile.kt b/app/src/main/java/com/m57/hermescontrol/data/model/Profile.kt index 1e9e5a93..711ed2b1 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/Profile.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/Profile.kt @@ -49,6 +49,21 @@ data class ProfileInfo( } } + fun groupChatSyncSnapshot( + json: Json = + Json { + ignoreUnknownKeys = true + isLenient = true + }, + ): GroupChatSyncSnapshot? { + val element = ui_meta?.get("hermes-bots-groups") ?: return null + return try { + json.decodeFromJsonElement(element) + } catch (_: Exception) { + null + } + } + val effectiveTitle: String get() = botMeta()?.title?.takeIf { it.isNotBlank() } @@ -104,7 +119,15 @@ data class BotRosterMeta( val groups: List? = null, val group: String? = null, val created: Double? = null, -) +) { + val allGroups: List + get() { + val list = mutableListOf() + groups?.forEach { if (it.isNotBlank()) list.add(it.trim()) } + group?.takeIf { it.isNotBlank() }?.let { if (!list.contains(it.trim())) list.add(it.trim()) } + return list.distinct() + } +} @Serializable data class BotAvatarMeta( @@ -120,7 +143,62 @@ data class GroupChatSyncSnapshot( val updatedAt: Long? = null, val rooms: Map? = null, val deleted: Map? = null, -) +) { + fun toMap(): Map { + val roomsMap = mutableMapOf() + rooms?.forEach { (key, room) -> + val roomMap = mutableMapOf() + room.name?.let { roomMap["name"] = it } + room.roomId?.let { roomMap["roomId"] = it } + room.picture?.let { roomMap["picture"] = it } + room.image?.let { roomMap["image"] = it } + room.updatedAt?.let { roomMap["updatedAt"] = it } + room.createdAt?.let { roomMap["createdAt"] = it } + room.revision?.let { roomMap["revision"] = it } + room.syncRevision?.let { roomMap["syncRevision"] = it } + room.tombstone?.let { roomMap["tombstone"] = it } + room.members?.let { members -> + roomMap["members"] = + members.mapNotNull { + when (it) { + is JsonPrimitive -> it.content + is JsonObject -> it["name"]?.jsonPrimitive?.content ?: it["handle"]?.jsonPrimitive?.content + else -> null + } + } + } + room.log?.let { log -> + roomMap["log"] = + log.map { entry -> + buildMap { + entry.id?.let { put("id", it) } + entry.text?.let { put("text", it) } + entry.at?.let { put("at", it) } + entry.thread?.let { put("thread", it) } + entry.from?.let { f -> + put( + "from", + buildMap { + f.kind?.let { put("kind", it) } + f.name?.let { put("name", it) } + f.source?.let { put("source", it) } + }, + ) + } + } + } + } + roomsMap[key] = roomMap + } + + return buildMap { + put("version", version ?: 3) + put("updatedAt", updatedAt ?: System.currentTimeMillis()) + put("rooms", roomsMap) + deleted?.let { put("deleted", it) } + } + } +} @Serializable data class GroupChatRoomMeta( @@ -132,15 +210,17 @@ data class GroupChatRoomMeta( val image: String? = null, val log: List? = null, val revision: Long? = null, + val syncRevision: Long? = null, val updatedAt: Long? = null, val createdAt: Long? = null, + val tombstone: Boolean? = null, ) { val memberNames: List get() = members?.mapNotNull { el -> when (el) { is JsonPrimitive -> el.content - is JsonObject -> el["name"]?.jsonPrimitive?.content + is JsonObject -> el["name"]?.jsonPrimitive?.content ?: el["handle"]?.jsonPrimitive?.content else -> null } }.orEmpty() diff --git a/app/src/main/java/com/m57/hermescontrol/ui/bots/BotsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/bots/BotsViewModel.kt index db218902..c1a9764e 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/bots/BotsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/bots/BotsViewModel.kt @@ -5,6 +5,8 @@ import androidx.lifecycle.viewModelScope import com.m57.hermescontrol.data.model.BotAvatarMeta import com.m57.hermescontrol.data.model.BotRosterMeta import com.m57.hermescontrol.data.model.CreateProfileRequest +import com.m57.hermescontrol.data.model.GroupChatRoomMeta +import com.m57.hermescontrol.data.model.GroupChatSyncSnapshot import com.m57.hermescontrol.data.model.ProfileInfo import com.m57.hermescontrol.data.model.ProfilesResponse import com.m57.hermescontrol.data.remote.ApiClient @@ -24,6 +26,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.decodeFromJsonElement enum class BotsTab { @@ -34,6 +37,7 @@ enum class BotsTab { data class GroupInfo( val name: String, val members: List, + val lastActivity: Long = 0L, ) data class BotsUiState( @@ -52,17 +56,104 @@ data class BotsUiState( val allGroups: List get() { - val groupNames = - profiles - .flatMap { it.botMeta()?.groups.orEmpty() } - .distinct() - .sorted() - return groupNames.map { gName -> + val defaultProfile = + profiles.find { it.is_default == true || it.name == "default" } + ?: profiles.firstOrNull() + val syncSnapshot = defaultProfile?.groupChatSyncSnapshot() + val deletedKeys = syncSnapshot?.deleted?.keys.orEmpty().map { it.lowercase() } + + val groupNameMap = linkedMapOf() + + // 1. Collect from bot metadata (both `groups` array and `group` scalar) + for (profile in profiles) { + for (g in profile.botMeta()?.allGroups.orEmpty()) { + val trimmed = g.trim() + if (trimmed.isNotBlank()) { + val lower = trimmed.lowercase() + if (!groupNameMap.containsKey(lower)) { + groupNameMap[lower] = trimmed + } + } + } + } + + // 2. Collect from cross-device snapshot rooms (Desktop parity) + val snapshotRooms = syncSnapshot?.rooms.orEmpty() + for ((key, room) in snapshotRooms) { + if (room.tombstone == true) continue + val roomKeyLower = key.lowercase() + if (deletedKeys.contains(roomKeyLower) || + deletedKeys.contains("name:${room.name?.lowercase()}") || + deletedKeys.contains("id:${room.roomId?.lowercase()}") + ) { + continue + } + val roomName = + room.name?.trim()?.takeIf { it.isNotBlank() } + ?: key.removePrefix("name:").removePrefix("id:").trim() + if (roomName.isNotBlank()) { + val lower = roomName.lowercase() + if (!groupNameMap.containsKey(lower)) { + groupNameMap[lower] = roomName + } + } + } + + return groupNameMap.values.map { gName -> + val lower = gName.lowercase() + val matchingRoom = + snapshotRooms[gName] + ?: snapshotRooms["name:$gName"] + ?: snapshotRooms["id:$gName"] + ?: snapshotRooms.values.find { it.name.equals(gName, ignoreCase = true) } + + val roomMemberNames = matchingRoom?.memberNames.orEmpty().map { it.lowercase() } + + val matchedProfiles = + profiles.filter { profile -> + val botGroups = profile.botMeta()?.allGroups.orEmpty() + botGroups.any { it.equals(gName, ignoreCase = true) } || + roomMemberNames.contains(profile.name.lowercase()) || + roomMemberNames.contains(profile.effectiveTitle.lowercase()) + }.toMutableList() + + // If room listed members (e.g. remote bots or bots not in local profiles), ensure they are seated + if (matchingRoom != null && roomMemberNames.isNotEmpty()) { + for (mName in matchingRoom.memberNames) { + val exists = + matchedProfiles.any { + it.name.equals(mName, ignoreCase = true) || + it.effectiveTitle.equals(mName, ignoreCase = true) + } + if (!exists) { + val local = profiles.find { it.name.equals(mName, ignoreCase = true) } + matchedProfiles.add(local ?: ProfileInfo(name = mName)) + } + } + } + + var groupMembers: List = matchedProfiles + + // Fallback: If group name was composed from bot names (e.g. "default, scoutbot") + if (groupMembers.isEmpty() && gName.contains(",")) { + val targetNames = gName.split(",").map { it.trim().lowercase() } + groupMembers = profiles.filter { targetNames.contains(it.name.lowercase()) } + } + + val lastAt = + matchingRoom?.log?.lastOrNull()?.at + ?: matchingRoom?.updatedAt + ?: 0L + GroupInfo( name = gName, - members = profiles.filter { it.botMeta()?.groups.orEmpty().contains(gName) }, + members = groupMembers, + lastActivity = lastAt, ) - } + }.sortedWith( + compareByDescending { it.lastActivity } + .thenBy { it.name }, + ) } val displayGroups: List @@ -321,13 +412,15 @@ class BotsViewModel( ) { viewModelScope.launch(ioDispatcher) { val currentProfiles = _uiState.value.profiles + // 1. Update bot metadata on each member bot for (name in botNames) { val bot = currentProfiles.find { it.name == name } ?: continue - val existingGroups = bot.botMeta()?.groups.orEmpty() - if (!existingGroups.contains(groupName)) { + val existingGroups = bot.botMeta()?.allGroups.orEmpty() + if (!existingGroups.any { it.equals(groupName, ignoreCase = true) }) { val updatedMeta = (bot.botMeta() ?: BotRosterMeta()).copy( groups = existingGroups + groupName, + group = existingGroups.firstOrNull() ?: groupName, ) try { wsClientConfigureBot(name, updatedMeta).await() @@ -335,6 +428,47 @@ class BotsViewModel( } } } + + // 2. Also register room in hermes-bots-groups sync snapshot on default profile + try { + val defaultProfile = + currentProfiles.find { it.is_default == true || it.name == "default" } + ?: currentProfiles.firstOrNull() + if (defaultProfile != null) { + val existingSnapshot = + defaultProfile.groupChatSyncSnapshot() + ?: GroupChatSyncSnapshot(version = 3, rooms = emptyMap()) + val roomKey = "name:$groupName" + val newRoom = + GroupChatRoomMeta( + name = groupName, + members = botNames.map { JsonPrimitive(it) }, + updatedAt = System.currentTimeMillis(), + createdAt = System.currentTimeMillis(), + ) + val updatedRooms = existingSnapshot.rooms.orEmpty() + (roomKey to newRoom) + val updatedDeleted = + existingSnapshot.deleted.orEmpty().filterKeys { + !it.equals(roomKey, ignoreCase = true) && !it.equals(groupName, ignoreCase = true) + } + val newSnapshot = + existingSnapshot.copy( + version = 3, + updatedAt = System.currentTimeMillis(), + rooms = updatedRooms, + deleted = updatedDeleted, + ) + HermesWsClient.request( + WsMethods.PROFILES_CONFIGURE, + mapOf( + "name" to defaultProfile.name, + "ui_meta" to mapOf("hermes-bots-groups" to newSnapshot.toMap()), + ), + ).await() + } + } catch (_: Exception) { + } + loadBots() onSuccess() } @@ -346,12 +480,15 @@ class BotsViewModel( ) { viewModelScope.launch(ioDispatcher) { val currentProfiles = _uiState.value.profiles + // 1. Remove from all member bots' metadata for (bot in currentProfiles) { - val existingGroups = bot.botMeta()?.groups.orEmpty() - if (existingGroups.contains(groupName)) { + val existingGroups = bot.botMeta()?.allGroups.orEmpty() + if (existingGroups.any { it.equals(groupName, ignoreCase = true) }) { + val filtered = existingGroups.filterNot { it.equals(groupName, ignoreCase = true) } val updatedMeta = (bot.botMeta() ?: BotRosterMeta()).copy( - groups = existingGroups - groupName, + groups = filtered, + group = filtered.firstOrNull(), ) try { wsClientConfigureBot(bot.name, updatedMeta).await() @@ -359,6 +496,44 @@ class BotsViewModel( } } } + + // 2. Remove room from hermes-bots-groups snapshot and register tombstone in deleted map + try { + val defaultProfile = + currentProfiles.find { it.is_default == true || it.name == "default" } + ?: currentProfiles.firstOrNull() + if (defaultProfile != null) { + val existingSnapshot = defaultProfile.groupChatSyncSnapshot() + if (existingSnapshot != null) { + val updatedRooms = + existingSnapshot.rooms.orEmpty().filterKeys { key -> + val room = existingSnapshot.rooms?.get(key) + !key.equals(groupName, ignoreCase = true) && + !key.equals("name:$groupName", ignoreCase = true) && + !key.equals("id:$groupName", ignoreCase = true) && + !(room?.name.equals(groupName, ignoreCase = true)) + } + val deletedMap = + existingSnapshot.deleted.orEmpty() + ("name:$groupName" to System.currentTimeMillis()) + val newSnapshot = + existingSnapshot.copy( + version = 3, + updatedAt = System.currentTimeMillis(), + rooms = updatedRooms, + deleted = deletedMap, + ) + HermesWsClient.request( + WsMethods.PROFILES_CONFIGURE, + mapOf( + "name" to defaultProfile.name, + "ui_meta" to mapOf("hermes-bots-groups" to newSnapshot.toMap()), + ), + ).await() + } + } + } catch (_: Exception) { + } + loadBots() onSuccess() } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatViewModel.kt index 1a74eaa2..ed73fb4d 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatViewModel.kt @@ -28,10 +28,8 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeoutOrNull import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonElement -import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.decodeFromJsonElement -import kotlinx.serialization.json.jsonPrimitive import java.util.UUID data class GroupChatUiState( @@ -170,11 +168,44 @@ class GroupChatViewModel( _uiState.update { it.copy(isLoading = true, errorMessage = null) } val allProfiles = fetchProfiles() if (allProfiles.isNotEmpty()) { - var groupMembers = + // Hydrate previous group chat messages from cross-device sync snapshot + val defaultProfile = + allProfiles.find { it.is_default == true || it.name == "default" } + ?: allProfiles.firstOrNull() + val syncSnapshot = defaultProfile?.groupChatSyncSnapshot() + + val matchingRoom = + syncSnapshot?.rooms?.get(groupName) + ?: syncSnapshot?.rooms?.get("name:$groupName") + ?: syncSnapshot?.rooms?.get("id:$groupName") + ?: syncSnapshot?.rooms?.values?.find { it.name.equals(groupName, ignoreCase = true) } + + val roomMemberNames = matchingRoom?.memberNames.orEmpty().map { it.lowercase() } + + val matchedProfiles = allProfiles.filter { - val botGroups = it.botMeta()?.groups.orEmpty() - botGroups.contains(groupName) || botGroups.any { g -> g.equals(groupName, ignoreCase = true) } + val botGroups = it.botMeta()?.allGroups.orEmpty() + botGroups.any { g -> g.equals(groupName, ignoreCase = true) } || + roomMemberNames.contains(it.name.lowercase()) || + roomMemberNames.contains(it.effectiveTitle.lowercase()) + }.toMutableList() + + // If room listed members (e.g. remote bots or bots not in local profiles), ensure they are seated + if (matchingRoom != null && roomMemberNames.isNotEmpty()) { + for (mName in matchingRoom.memberNames) { + val exists = + matchedProfiles.any { + it.name.equals(mName, ignoreCase = true) || + it.effectiveTitle.equals(mName, ignoreCase = true) + } + if (!exists) { + val local = allProfiles.find { it.name.equals(mName, ignoreCase = true) } + matchedProfiles.add(local ?: ProfileInfo(name = mName)) + } } + } + + var groupMembers: List = matchedProfiles // Fallback: If group name was composed from bot names (e.g. "default, scoutbot") if (groupMembers.isEmpty() && groupName.contains(",")) { @@ -192,34 +223,19 @@ class GroupChatViewModel( "Resolved ${groupMembers.size} members for group '$groupName': ${groupMembers.map { it.name }}", ) - // Hydrate previous group chat messages from cross-device sync snapshot - val defaultProfile = allProfiles.find { it.is_default == true || it.name == "default" } - val syncSnapshotElement = defaultProfile?.ui_meta?.get("hermes-bots-groups") - val syncSnapshot = - syncSnapshotElement?.let { el -> - try { - json.decodeFromJsonElement(el) - } catch (e: Exception) { - Log.w("GroupChatViewModel", "Failed to decode sync snapshot: ${e.message}") - null - } - } - var initialMessages = _uiState.value.messages if (initialMessages.isEmpty() && syncSnapshot?.rooms != null) { - val matchingRoom = - syncSnapshot.rooms[groupName] - ?: syncSnapshot.rooms["name:$groupName"] - ?: syncSnapshot.rooms["id:$groupName"] - ?: syncSnapshot.rooms.values.find { it.name.equals(groupName, ignoreCase = true) } - val syncedLog = matchingRoom?.log.orEmpty() if (syncedLog.isNotEmpty()) { initialMessages = syncedLog.map { entry -> val isUser = entry.from?.kind != "member" val senderName = entry.from?.name.orEmpty().ifBlank { if (isUser) "user" else "bot" } - val memberProfile = allProfiles.find { it.name.equals(senderName, ignoreCase = true) } + val memberProfile = + allProfiles.find { + it.name.equals(senderName, ignoreCase = true) || + it.effectiveTitle.equals(senderName, ignoreCase = true) + } GroupChatMessage( id = entry.id ?: UUID.randomUUID().toString(), senderName = memberProfile?.name ?: senderName, @@ -446,15 +462,9 @@ class GroupChatViewModel( val allProfiles = fetchProfiles() val defaultProfile = allProfiles.find { it.is_default == true || it.name == "default" } ?: return@launch - val existingElement = defaultProfile.ui_meta?.get("hermes-bots-groups") val existingSnapshot = - existingElement?.let { - try { - json.decodeFromJsonElement(it) - } catch (_: Exception) { - null - } - } ?: GroupChatSyncSnapshot(version = 3, rooms = emptyMap()) + defaultProfile.groupChatSyncSnapshot() + ?: GroupChatSyncSnapshot(version = 3, rooms = emptyMap()) val logEntries = currentMessages.takeLast(32).map { msg -> @@ -472,8 +482,13 @@ class GroupChatViewModel( } val roomKey = "name:$groupName" + val existingRoom = + existingSnapshot.rooms?.get(roomKey) + ?: existingSnapshot.rooms?.get(groupName) + ?: existingSnapshot.rooms?.values?.find { it.name.equals(groupName, ignoreCase = true) } + val updatedRoom = - GroupChatRoomMeta( + (existingRoom ?: GroupChatRoomMeta(name = groupName, createdAt = System.currentTimeMillis())).copy( name = groupName, members = _uiState.value.members.map { JsonPrimitive(it.name) }, log = logEntries, @@ -488,7 +503,7 @@ class GroupChatViewModel( rooms = updatedRooms, ) - val uiMetaPayload = mapOf("hermes-bots-groups" to snapshotToMap(newSnapshot)) + val uiMetaPayload = mapOf("hermes-bots-groups" to newSnapshot.toMap()) HermesWsClient.request( WsMethods.PROFILES_CONFIGURE, @@ -503,59 +518,6 @@ class GroupChatViewModel( } } - private fun snapshotToMap(snapshot: GroupChatSyncSnapshot): Map { - val roomsMap = mutableMapOf() - snapshot.rooms?.forEach { (key, room) -> - val roomMap = mutableMapOf() - room.name?.let { roomMap["name"] = it } - room.roomId?.let { roomMap["roomId"] = it } - room.picture?.let { roomMap["picture"] = it } - room.image?.let { roomMap["image"] = it } - room.updatedAt?.let { roomMap["updatedAt"] = it } - room.createdAt?.let { roomMap["createdAt"] = it } - room.revision?.let { roomMap["revision"] = it } - room.members?.let { members -> - roomMap["members"] = - members.mapNotNull { - when (it) { - is JsonPrimitive -> it.content - is JsonObject -> it["name"]?.jsonPrimitive?.content - else -> null - } - } - } - room.log?.let { log -> - roomMap["log"] = - log.map { entry -> - buildMap { - entry.id?.let { put("id", it) } - entry.text?.let { put("text", it) } - entry.at?.let { put("at", it) } - entry.thread?.let { put("thread", it) } - entry.from?.let { f -> - put( - "from", - buildMap { - f.kind?.let { put("kind", it) } - f.name?.let { put("name", it) } - f.source?.let { put("source", it) } - }, - ) - } - } - } - } - roomsMap[key] = roomMap - } - - return buildMap { - put("version", snapshot.version ?: 3) - put("updatedAt", snapshot.updatedAt ?: System.currentTimeMillis()) - put("rooms", roomsMap) - snapshot.deleted?.let { put("deleted", it) } - } - } - private fun isPass(text: String): Boolean { val clean = text.trim().lowercase() return clean == "(pass)" || clean == "pass" diff --git a/app/src/test/java/com/m57/hermescontrol/ui/bots/BotsViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/bots/BotsViewModelTest.kt index 38c275d2..74777d1f 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/bots/BotsViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/bots/BotsViewModelTest.kt @@ -4,6 +4,8 @@ import com.m57.hermescontrol.data.model.ActiveProfileResponse import com.m57.hermescontrol.data.model.BotAvatarMeta import com.m57.hermescontrol.data.model.BotRosterMeta import com.m57.hermescontrol.data.model.CanonicalSessionInfo +import com.m57.hermescontrol.data.model.GroupChatRoomMeta +import com.m57.hermescontrol.data.model.GroupChatSyncSnapshot import com.m57.hermescontrol.data.model.ProfileInfo import com.m57.hermescontrol.data.model.ProfileWorkerSummary import com.m57.hermescontrol.data.model.ProfilesResponse @@ -24,6 +26,7 @@ import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.encodeToJsonElement import org.junit.After import org.junit.Assert.assertEquals @@ -273,4 +276,67 @@ class BotsViewModelTest { assertTrue(success) coVerify(exactly = 1) { mockApi.deleteProfile("scout") } } + + @Test + fun testAllGroups_includesDesktopSyncRoomsAndRespectsOrdering() = + runTest { + val syncSnapshot = + GroupChatSyncSnapshot( + version = 3, + updatedAt = 1725000000L, + rooms = + mapOf( + "name:Desktop Room" to + GroupChatRoomMeta( + name = "Desktop Room", + members = listOf(JsonPrimitive("botA"), JsonPrimitive("remoteBot")), + updatedAt = 1725000100L, + ), + "id:room-123" to + GroupChatRoomMeta( + name = "Old Archived Room", + members = listOf(JsonPrimitive("botA")), + tombstone = true, + ), + ), + deleted = mapOf("name:deleted room" to 1725000000L), + ) + + val defaultProfile = + ProfileInfo( + name = "default", + is_default = true, + ui_meta = mapOf("hermes-bots-groups" to json.encodeToJsonElement(syncSnapshot)), + ) + + val botA = + ProfileInfo( + name = "botA", + ui_meta = + mapOf( + "hermes-bots" to + json.encodeToJsonElement( + BotRosterMeta(groups = listOf("Local Only Group")), + ), + ), + ) + + coEvery { mockApi.getProfiles() } returns Response.success(ProfilesResponse(listOf(defaultProfile, botA))) + coEvery { mockApi.getActiveProfile() } returns Response.success(ActiveProfileResponse(active = "default")) + + val viewModel = BotsViewModel(ioDispatcher = testDispatcher, autoLoad = false) + viewModel.loadBots() + advanceUntilIdle() + + val groups = viewModel.uiState.value.allGroups + assertEquals(2, groups.size) + // "Desktop Room" has recent activity 1725000100L, so it should rank first + assertEquals("Desktop Room", groups[0].name) + assertEquals(2, groups[0].members.size) + assertEquals("botA", groups[0].members[0].name) + assertEquals("remoteBot", groups[0].members[1].name) + + assertEquals("Local Only Group", groups[1].name) + assertEquals(1, groups[1].members.size) + } } From 0d09f43469706e217c62d596f5248ad306e470e8 Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Sat, 29 Aug 2026 18:44:08 +0300 Subject: [PATCH 08/14] feat(bots): reactive multi-agent group chat orchestration with CAS leasing (#1007) Co-authored-by: M57 --- .../m57/hermescontrol/data/model/Profile.kt | 22 + .../data/model/SessionMessagesResponse.kt | 16 + .../ui/bots/group/GroupChatModels.kt | 95 ++- .../ui/bots/group/GroupChatScreen.kt | 218 +++++- .../ui/bots/group/GroupChatViewModel.kt | 650 ++++++++++++++---- app/src/main/res/values-ko/strings.xml | 12 + app/src/main/res/values-zh/strings.xml | 12 + app/src/main/res/values/strings.xml | 12 + .../ui/bots/group/GroupChatViewModelTest.kt | 169 +++++ 9 files changed, 1059 insertions(+), 147 deletions(-) diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/Profile.kt b/app/src/main/java/com/m57/hermescontrol/data/model/Profile.kt index 711ed2b1..f06756a7 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/Profile.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/Profile.kt @@ -157,6 +157,17 @@ data class GroupChatSyncSnapshot( room.revision?.let { roomMap["revision"] = it } room.syncRevision?.let { roomMap["syncRevision"] = it } room.tombstone?.let { roomMap["tombstone"] = it } + room.maxBotMessages?.let { roomMap["maxBotMessages"] = it } + room.maxContinuationPasses?.let { roomMap["maxContinuationPasses"] = it } + room.systemPrompt?.let { roomMap["systemPrompt"] = it } + room.lease?.let { lease -> + roomMap["lease"] = + buildMap { + lease.driverId?.let { put("driverId", it) } + lease.epoch?.let { put("epoch", it) } + lease.expiresAt?.let { put("expiresAt", it) } + } + } room.members?.let { members -> roomMap["members"] = members.mapNotNull { @@ -200,6 +211,13 @@ data class GroupChatSyncSnapshot( } } +@Serializable +data class GroupChatRoomLease( + val driverId: String? = null, + val epoch: String? = null, + val expiresAt: Long? = null, +) + @Serializable data class GroupChatRoomMeta( val id: String? = null, @@ -214,6 +232,10 @@ data class GroupChatRoomMeta( val updatedAt: Long? = null, val createdAt: Long? = null, val tombstone: Boolean? = null, + val lease: GroupChatRoomLease? = null, + val maxBotMessages: Int? = null, + val maxContinuationPasses: Int? = null, + val systemPrompt: String? = null, ) { val memberNames: List get() = diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/SessionMessagesResponse.kt b/app/src/main/java/com/m57/hermescontrol/data/model/SessionMessagesResponse.kt index d256d743..7f2ca663 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/SessionMessagesResponse.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/SessionMessagesResponse.kt @@ -3,6 +3,8 @@ package com.m57.hermescontrol.data.model import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.doubleOrNull +import kotlinx.serialization.json.longOrNull @Serializable data class SessionMessagesResponse( @@ -52,6 +54,20 @@ data class SessionMessage( val timestampText: String? get() = (timestamp as? JsonPrimitive)?.content + val timestampEpochMs: Long? + get() { + val prim = timestamp as? JsonPrimitive ?: return null + val asLong = prim.longOrNull + if (asLong != null) { + return if (asLong < 10_000_000_000L) asLong * 1000L else asLong + } + val asDouble = prim.doubleOrNull + if (asDouble != null) { + return (asDouble * 1000L).toLong() + } + return null + } + val contentText: String get() = when (content) { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatModels.kt b/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatModels.kt index 477603b0..17de3dc2 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatModels.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatModels.kt @@ -16,12 +16,15 @@ data class GroupChatMessage( val timestamp: Long = System.currentTimeMillis(), val isStreaming: Boolean = false, val thread: String? = null, + val isSystem: Boolean = false, ) /** * Pure helper for mention extraction and responder resolution matching Desktop parity. */ object GroupChatMentions { + private val MENTION_REGEX = Regex("""@([a-zA-Z0-9._-]+)""") + data class MentionResult( val isEveryone: Boolean, val mentionedBots: Set, @@ -33,16 +36,42 @@ object GroupChatMentions { fun parseMentions( text: String, members: List, + excludedSpeaker: String? = null, ): MentionResult { - val lowerText = text.lowercase() - val isEveryone = lowerText.contains("@all") || lowerText.contains("@everyone") - + var isEveryone = false val mentioned = mutableSetOf() + + val handles = mutableMapOf() for (member in members) { - val handle = member.name.lowercase() - val titleHandle = member.effectiveTitle.lowercase().replace(" ", "") - if (lowerText.contains("@$handle") || lowerText.contains("@$titleHandle")) { - mentioned.add(member.name) + val name = member.name.lowercase() + handles[name] = member.name + handles[name.replace(Regex("""[\s_-]+"""), "")] = member.name + + val title = member.effectiveTitle.lowercase().trim() + if (title.isNotEmpty()) { + handles[title.replace(" ", "")] = member.name + val firstWord = title.split(Regex("""\s+""")).firstOrNull().orEmpty() + if (firstWord.isNotEmpty()) { + handles[firstWord] = member.name + } + } + } + + for (match in MENTION_REGEX.findAll(text)) { + val rawHandle = match.groupValues[1].lowercase() + if (rawHandle == "all" || rawHandle == "everyone") { + isEveryone = true + continue + } + if (rawHandle == "user" || rawHandle == "you") { + continue + } + + val resolved = handles[rawHandle] ?: handles[rawHandle.replace(Regex("""[._-]+"""), "")] + if (resolved != null) { + if (excludedSpeaker == null || !resolved.equals(excludedSpeaker, ignoreCase = true)) { + mentioned.add(resolved) + } } } @@ -58,10 +87,15 @@ object GroupChatMentions { fun resolveResponders( text: String, members: List, + excludedSpeaker: String? = null, ): List { - val parsed = parseMentions(text, members) + val parsed = parseMentions(text, members, excludedSpeaker) return if (parsed.isEveryone || parsed.mentionedBots.isEmpty()) { - members + if (excludedSpeaker != null) { + members.filter { !it.name.equals(excludedSpeaker, ignoreCase = true) } + } else { + members + } } else { members.filter { parsed.mentionedBots.contains(it.name) } } @@ -75,11 +109,25 @@ object GroupChatMentions { viewer: ProfileInfo, peers: List, recentLog: List, + historyLimit: Int = 10, + customRoomPrompt: String? = null, ): String { - val viewerHandle = viewer.name - val peerNames = peers.joinToString(", ") { "@${it.name}" } + val viewerLabel = + if (viewer.effectiveTitle.isNotBlank() && !viewer.effectiveTitle.equals(viewer.name, ignoreCase = true)) { + "@${viewer.name} (${viewer.effectiveTitle})" + } else { + "@${viewer.name}" + } + val peerNames = + peers.joinToString(", ") { + if (it.effectiveTitle.isNotBlank() && !it.effectiveTitle.equals(it.name, ignoreCase = true)) { + "@${it.name} (${it.effectiveTitle})" + } else { + "@${it.name}" + } + } val lines = - recentLog.takeLast(10).joinToString("\n") { msg -> + recentLog.filter { !it.isSystem }.takeLast(historyLimit).joinToString("\n") { msg -> val speaker = if (msg.isUser) "User" else "@${msg.senderName}" " $speaker: ${msg.text}" } @@ -90,20 +138,33 @@ object GroupChatMentions { val actionGuidance = if (isDirectOrAll) { - "- You were explicitly addressed (via @$viewerHandle or @all). Do NOT pass — reply conversationally with your greeting, insight, answer, or status." + "- You were explicitly addressed (via @${viewer.name} or @all). Do NOT pass — reply conversationally with your greeting, insight, answer, or status." } else { "- If you have nothing new to add, reply with exactly \"(pass)\". Passing is good and lets the conversation settle." } - return """ -[Group chat: "$groupName"] You are @$viewerHandle, one participant in a group chat with $peerNames and the user. + val customInstructions = + if (!customRoomPrompt.isNullOrBlank()) { + "\nRoom Instructions:\n${customRoomPrompt.trim()}\n" + } else { + "" + } + val defaultSentenceRule = + if (customRoomPrompt.isNullOrBlank()) { + "- Reply with ONE conversational message (1-3 sentences).\n" + } else { + "" + } + + return """ +[Group chat: "$groupName"] You are $viewerLabel, one participant in a group chat with $peerNames and the user. +$customInstructions Recent messages in the room: $lines Rules for this room: -- Reply with ONE conversational message (1-3 sentences). -$actionGuidance +$defaultSentenceRule$actionGuidance - Mention a teammate as @name if you want to pull them in. - Your reply goes to the room verbatim — no preamble. """.trimIndent() diff --git a/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatScreen.kt index d39b926d..398db70a 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatScreen.kt @@ -24,13 +24,19 @@ import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Send +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Slider import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect @@ -60,6 +66,7 @@ import com.m57.hermescontrol.ui.common.BotAvatar import com.m57.hermescontrol.ui.common.EmptyState import com.m57.hermescontrol.ui.common.HermesScaffold import com.m57.hermescontrol.ui.common.NavIcon +import kotlin.math.roundToInt @Composable fun GroupChatScreen( @@ -74,6 +81,7 @@ fun GroupChatScreen( mutableStateOf(TextFieldValue("")) } var isFocused by remember { mutableStateOf(false) } + var showSettingsDialog by remember { mutableStateOf(false) } val handleSend = { val text = inputFieldValue.text @@ -111,6 +119,17 @@ fun GroupChatScreen( } }, navigationIcon = NavIcon.Back(onBack), + actions = { + IconButton( + onClick = { showSettingsDialog = true }, + modifier = Modifier.testTag("group_chat_settings_button"), + ) { + Icon( + imageVector = Icons.Filled.Settings, + contentDescription = stringResource(R.string.group_chat_action_settings), + ) + } + }, modifier = modifier, ) { Column( @@ -276,6 +295,185 @@ fun GroupChatScreen( } } } + + if (showSettingsDialog) { + GroupChatSettingsDialog( + currentMaxMessages = state.maxBotMessages, + currentMaxPasses = state.maxContinuationPasses, + currentSystemPrompt = state.systemPrompt, + onDismiss = { showSettingsDialog = false }, + onSave = { maxMsgs, maxPasses, prompt -> + viewModel.updateGroupLimits(maxMsgs, maxPasses, prompt) + showSettingsDialog = false + }, + ) + } +} + +@Composable +private fun GroupChatSettingsDialog( + currentMaxMessages: Int, + currentMaxPasses: Int, + currentSystemPrompt: String?, + onDismiss: () -> Unit, + onSave: (Int, Int, String?) -> Unit, +) { + var maxMessages by remember(currentMaxMessages) { mutableStateOf(currentMaxMessages.toFloat()) } + var maxPasses by remember(currentMaxPasses) { mutableStateOf(currentMaxPasses.toFloat()) } + var systemPrompt by remember(currentSystemPrompt) { mutableStateOf(currentSystemPrompt.orEmpty()) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text( + text = stringResource(R.string.group_chat_settings_title), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + }, + text = { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + // Max Bot Messages Slider + Column { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.group_chat_settings_max_messages), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = "${maxMessages.roundToInt()}", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold, + ) + } + Text( + text = + stringResource( + R.string.group_chat_settings_max_messages_desc, + maxMessages.roundToInt(), + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Slider( + value = maxMessages, + onValueChange = { maxMessages = it }, + valueRange = 1f..20f, + steps = 18, + modifier = Modifier.testTag("max_messages_slider"), + ) + } + + // Max Continuation Handoffs Slider + Column { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.group_chat_settings_max_handoffs), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = "${maxPasses.roundToInt()}", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold, + ) + } + Text( + text = + stringResource( + R.string.group_chat_settings_max_handoffs_desc, + maxPasses.roundToInt(), + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Slider( + value = maxPasses, + onValueChange = { maxPasses = it }, + valueRange = 0f..6f, + steps = 5, + modifier = Modifier.testTag("max_handoffs_slider"), + ) + } + + // Room Instructions / System Prompt + Column { + Text( + text = stringResource(R.string.group_chat_settings_system_prompt), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = stringResource(R.string.group_chat_settings_system_prompt_desc), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(6.dp)) + OutlinedTextField( + value = systemPrompt, + onValueChange = { systemPrompt = it }, + placeholder = { + Text( + text = stringResource(R.string.group_chat_settings_system_prompt_hint), + style = MaterialTheme.typography.bodySmall, + ) + }, + modifier = + Modifier + .fillMaxWidth() + .heightIn(min = 80.dp, max = 160.dp) + .testTag("room_system_prompt_input"), + textStyle = MaterialTheme.typography.bodyMedium, + maxLines = 4, + ) + } + } + }, + confirmButton = { + Button( + onClick = { + onSave( + maxMessages.roundToInt(), + maxPasses.roundToInt(), + systemPrompt.trim().ifBlank { null }, + ) + }, + modifier = Modifier.testTag("save_settings_button"), + ) { + Text(stringResource(R.string.group_chat_settings_save)) + } + }, + dismissButton = { + Row { + TextButton( + onClick = { + maxMessages = DEFAULT_MAX_BOT_MESSAGES.toFloat() + maxPasses = DEFAULT_MAX_CONTINUATION_PASSES.toFloat() + systemPrompt = "" + }, + ) { + Text(stringResource(R.string.group_chat_settings_reset_default)) + } + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.common_cancel)) + } + } + }, + ) } @Composable @@ -283,7 +481,25 @@ private fun GroupMessageCard( message: GroupChatMessage, modifier: Modifier = Modifier, ) { - if (message.isUser) { + if (message.isSystem) { + Box( + modifier = modifier.fillMaxWidth().padding(vertical = 4.dp), + contentAlignment = Alignment.Center, + ) { + Surface( + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)), + ) { + Text( + text = message.text, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), + ) + } + } + } else if (message.isUser) { Row( modifier = modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatViewModel.kt index ed73fb4d..d083f3bb 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatViewModel.kt @@ -3,6 +3,7 @@ package com.m57.hermescontrol.ui.bots.group import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.m57.hermescontrol.data.model.GroupChatRoomLease import com.m57.hermescontrol.data.model.GroupChatRoomMeta import com.m57.hermescontrol.data.model.GroupChatSyncFrom import com.m57.hermescontrol.data.model.GroupChatSyncLogEntry @@ -39,6 +40,9 @@ data class GroupChatUiState( val activeSpeaker: String? = null, val isLoading: Boolean = true, val errorMessage: String? = null, + val maxBotMessages: Int = DEFAULT_MAX_BOT_MESSAGES, + val maxContinuationPasses: Int = DEFAULT_MAX_CONTINUATION_PASSES, + val systemPrompt: String? = null, ) data class MemberSession( @@ -46,6 +50,13 @@ data class MemberSession( val storedSessionId: String? = null, ) +const val DEFAULT_MAX_BOT_MESSAGES = 6 +const val DEFAULT_MAX_CONTINUATION_PASSES = 2 +private const val LEASE_STEP_TTL_MS = 45_000L +private const val TURN_TIMEOUT_MS = 45_000L +private const val HISTORY_LIMIT = 10 +private const val CAPPED_SYSTEM_TEXT = "Discussion paused (turn limit reached) — reply to continue" + class GroupChatViewModel( private var groupName: String = "", private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, @@ -54,6 +65,12 @@ class GroupChatViewModel( private val _uiState = MutableStateFlow(GroupChatUiState(groupName = groupName)) val uiState: StateFlow = _uiState.asStateFlow() + private val localDriverId = "android_" + UUID.randomUUID().toString().take(8) + private var activeEpoch: String = "" + + // Map of bot.name -> seen count of non-system messages + private val memberWatermarks = mutableMapOf() + // Map of bot.name -> active session info in this group private val memberSessions = mutableMapOf() @@ -80,6 +97,8 @@ class GroupChatViewModel( fun setGroup(name: String) { if (name == groupName && _uiState.value.groupName.isNotBlank()) return groupName = name + activeEpoch = UUID.randomUUID().toString() + memberWatermarks.clear() memberSessions.clear() inFlightTurns.clear() activeStreamMsgId.clear() @@ -252,10 +271,22 @@ class GroupChatViewModel( } } + val nonSystemCount = initialMessages.filter { !it.isSystem }.size + for (member in groupMembers) { + memberWatermarks[member.name] = nonSystemCount + } + + val maxMessages = matchingRoom?.maxBotMessages ?: DEFAULT_MAX_BOT_MESSAGES + val maxPasses = matchingRoom?.maxContinuationPasses ?: DEFAULT_MAX_CONTINUATION_PASSES + val roomSystemPrompt = matchingRoom?.systemPrompt + _uiState.update { it.copy( members = groupMembers, messages = initialMessages, + maxBotMessages = maxMessages, + maxContinuationPasses = maxPasses, + systemPrompt = roomSystemPrompt, isLoading = false, ) } @@ -270,10 +301,93 @@ class GroupChatViewModel( } } - private suspend fun ensureMemberSession(bot: ProfileInfo): MemberSession? { - val cached = memberSessions[bot.name] - if (cached != null) { - return cached + private fun findRoomEntry(rooms: Map?): Pair { + if (rooms == null) return ("name:$groupName" to null) + val exact = + rooms.entries.find { + it.key == groupName || it.key == "name:$groupName" || it.key == "id:$groupName" + } + if (exact != null) return (exact.key to exact.value) + val byName = rooms.entries.find { it.value.name.equals(groupName, ignoreCase = true) } + if (byName != null) return (byName.key to byName.value) + return ("name:$groupName" to null) + } + + fun updateGroupLimits( + maxMessages: Int, + maxPasses: Int, + systemPrompt: String? = null, + ) { + val clampedMessages = maxMessages.coerceIn(1, 20) + val clampedPasses = maxPasses.coerceIn(0, 10) + val cleanPrompt = systemPrompt?.trim()?.ifBlank { null } + + _uiState.update { + it.copy( + maxBotMessages = clampedMessages, + maxContinuationPasses = clampedPasses, + systemPrompt = cleanPrompt, + ) + } + + viewModelScope.launch(ioDispatcher) { + try { + val allProfiles = fetchProfiles() + val defaultProfile = + allProfiles.find { it.is_default == true || it.name == "default" } ?: return@launch + val existingSnapshot = + defaultProfile.groupChatSyncSnapshot() + ?: GroupChatSyncSnapshot(version = 3, rooms = emptyMap()) + + val now = System.currentTimeMillis() + val (targetKey, existingRoom) = findRoomEntry(existingSnapshot.rooms) + + val updatedRoom = + (existingRoom ?: GroupChatRoomMeta(name = groupName, createdAt = now)).copy( + name = groupName, + members = _uiState.value.members.map { JsonPrimitive(it.name) }, + maxBotMessages = clampedMessages, + maxContinuationPasses = clampedPasses, + systemPrompt = cleanPrompt, + updatedAt = now, + ) + + val updatedRooms = existingSnapshot.rooms.orEmpty().toMutableMap() + updatedRooms[targetKey] = updatedRoom + + val newSnapshot = + existingSnapshot.copy( + version = 3, + updatedAt = now, + rooms = updatedRooms, + ) + + val uiMetaPayload = mapOf("hermes-bots-groups" to newSnapshot.toMap()) + + HermesWsClient.request( + WsMethods.PROFILES_CONFIGURE, + mapOf( + "name" to defaultProfile.name, + "ui_meta" to uiMetaPayload, + ), + ).await() + } catch (e: Exception) { + Log.w("GroupChatViewModel", "updateGroupLimits failed: ${e.message}") + } + } + } + + private suspend fun ensureMemberSession( + bot: ProfileInfo, + forceRefresh: Boolean = false, + ): MemberSession? { + if (!forceRefresh) { + val cached = memberSessions[bot.name] + if (cached != null) { + return cached + } + } else { + memberSessions.remove(bot.name) } val title = "Group: $groupName" @@ -298,6 +412,258 @@ class GroupChatViewModel( return null } + private suspend fun acquireOrVerifyLease( + defaultProfile: ProfileInfo, + epoch: String, + ): Boolean { + try { + val syncSnapshot = defaultProfile.groupChatSyncSnapshot() + val (targetKey, existingRoom) = findRoomEntry(syncSnapshot?.rooms) + val currentLease = existingRoom?.lease + val now = System.currentTimeMillis() + + if (currentLease?.driverId != null && + currentLease.driverId != localDriverId && + (currentLease.expiresAt ?: 0L) > now + ) { + val remainingSec = ((currentLease.expiresAt ?: 0L) - now) / 1000 + Log.i( + "GroupChatViewModel", + "Active lease held by ${currentLease.driverId} (expires in ${remainingSec}s). Passive mode.", + ) + return false + } + + val myLease = + GroupChatRoomLease( + driverId = localDriverId, + epoch = epoch, + expiresAt = now + LEASE_STEP_TTL_MS, + ) + + val existingSnapshot = syncSnapshot ?: GroupChatSyncSnapshot(version = 3, rooms = emptyMap()) + val updatedRoom = + (existingRoom ?: GroupChatRoomMeta(name = groupName, createdAt = now)).copy( + name = groupName, + members = _uiState.value.members.map { JsonPrimitive(it.name) }, + lease = myLease, + maxBotMessages = _uiState.value.maxBotMessages, + maxContinuationPasses = _uiState.value.maxContinuationPasses, + systemPrompt = _uiState.value.systemPrompt, + updatedAt = now, + ) + + val updatedRooms = existingSnapshot.rooms.orEmpty().toMutableMap() + updatedRooms[targetKey] = updatedRoom + val newSnapshot = existingSnapshot.copy(version = 3, updatedAt = now, rooms = updatedRooms) + val uiMetaPayload = mapOf("hermes-bots-groups" to newSnapshot.toMap()) + + HermesWsClient.request( + WsMethods.PROFILES_CONFIGURE, + mapOf( + "name" to defaultProfile.name, + "ui_meta" to uiMetaPayload, + ), + ).await() + + return true + } catch (e: Exception) { + Log.w("GroupChatViewModel", "acquireOrVerifyLease failed: ${e.message}") + return true + } + } + + private suspend fun executeBotTurn( + bot: ProfileInfo, + epoch: String, + members: List, + ): String? { + if (activeEpoch != epoch) return null + + val currentNonSystemMessages = _uiState.value.messages.filter { !it.isSystem } + val seen = memberWatermarks[bot.name] ?: 0 + val delta = currentNonSystemMessages.drop(seen) + + if (delta.isEmpty() && currentNonSystemMessages.isNotEmpty()) { + return null + } + + _uiState.update { it.copy(activeSpeaker = bot.effectiveTitle) } + + val prompt = + GroupChatMentions.buildTurnPrompt( + groupName = groupName, + viewer = bot, + peers = members.filter { it.name != bot.name }, + recentLog = currentNonSystemMessages, + historyLimit = HISTORY_LIMIT, + customRoomPrompt = _uiState.value.systemPrompt, + ) + + val turnStartTime = System.currentTimeMillis() + val streamMsgId = UUID.randomUUID().toString() + var activeRuntimeId: String? = null + var activeStoredId: String? = null + try { + var sessionInfo = ensureMemberSession(bot) + if (sessionInfo == null) { + Log.w("GroupChatViewModel", "Could not obtain session for ${bot.name}") + return null + } + var runtimeId = sessionInfo.runtimeSessionId + var storedId = sessionInfo.storedSessionId + activeRuntimeId = runtimeId + activeStoredId = storedId + + activeStreamMsgId[runtimeId] = streamMsgId + storedId?.let { activeStreamMsgId[it] = streamMsgId } + + sessionToBot[runtimeId] = bot + storedId?.let { sessionToBot[it] = bot } + + ActiveSessionHolder.set(runtimeId, runtimeId) + + val turnDeferred = CompletableDeferred() + inFlightTurns[runtimeId] = turnDeferred + storedId?.let { inFlightTurns[it] = turnDeferred } + + try { + HermesWsClient.request( + WsMethods.PROMPT_SUBMIT, + mapOf("session_id" to runtimeId, "text" to prompt), + ).await() + } catch (submitErr: Exception) { + Log.w( + "GroupChatViewModel", + "prompt.submit failed for ${bot.name} on $runtimeId: ${submitErr.message}, recreating session", + ) + activeStreamMsgId.remove(runtimeId) + storedId?.let { activeStreamMsgId.remove(it) } + sessionToBot.remove(runtimeId) + storedId?.let { sessionToBot.remove(it) } + inFlightTurns.remove(runtimeId) + storedId?.let { inFlightTurns.remove(it) } + + sessionInfo = ensureMemberSession(bot, forceRefresh = true) + if (sessionInfo == null) return null + + runtimeId = sessionInfo.runtimeSessionId + storedId = sessionInfo.storedSessionId + activeRuntimeId = runtimeId + activeStoredId = storedId + + activeStreamMsgId[runtimeId] = streamMsgId + storedId?.let { activeStreamMsgId[it] = streamMsgId } + sessionToBot[runtimeId] = bot + storedId?.let { sessionToBot[it] = bot } + ActiveSessionHolder.set(runtimeId, runtimeId) + inFlightTurns[runtimeId] = turnDeferred + storedId?.let { inFlightTurns[it] = turnDeferred } + + try { + HermesWsClient.request( + WsMethods.PROMPT_SUBMIT, + mapOf("session_id" to runtimeId, "text" to prompt), + ).await() + } catch (retryErr: Exception) { + Log.e("GroupChatViewModel", "Retry prompt.submit failed for ${bot.name}: ${retryErr.message}") + return null + } + } + + var replyText = + withTimeoutOrNull(TURN_TIMEOUT_MS) { + turnDeferred.await() + } + inFlightTurns.remove(runtimeId) + storedId?.let { inFlightTurns.remove(it) } + + if (replyText == null) { + val targetIds = listOfNotNull(storedId, runtimeId).distinct() + for (tid in targetIds) { + try { + val res = + safeApiCall { + ApiClient.hermesApi.getSessionMessages( + sessionId = tid, + limit = 10, + order = "latest", + ) + } + if (res is NetworkResult.Success) { + val lastAssistant = + res.data.messages.reversed().firstOrNull { msg -> + val ts = msg.timestampEpochMs + msg.role == "assistant" && + (ts == null || ts >= (turnStartTime - 5000L)) + } + val candidate = lastAssistant?.contentText?.trim() + if (!candidate.isNullOrBlank()) { + replyText = candidate + break + } + } + } catch (e: Exception) { + Log.w("GroupChatViewModel", "REST fallback failed for $tid: ${e.message}") + } + } + } + + if (activeEpoch != epoch) { + _uiState.update { state -> + state.copy(messages = state.messages.filter { it.id != streamMsgId }) + } + return null + } + + if (replyText != null && !isPass(replyText) && replyText.isNotBlank()) { + val finalMsg = + GroupChatMessage( + id = streamMsgId, + senderName = bot.name, + senderDisplayName = bot.effectiveTitle, + isUser = false, + avatarMeta = bot.botMeta()?.avatar, + text = replyText.trim(), + isStreaming = false, + ) + _uiState.update { state -> + val existing = state.messages.find { it.id == streamMsgId } + val updatedList = + if (existing != null) { + state.messages.map { if (it.id == streamMsgId) finalMsg else it } + } else { + state.messages + finalMsg + } + state.copy(messages = updatedList) + } + return replyText.trim() + } else { + _uiState.update { state -> + state.copy(messages = state.messages.filter { it.id != streamMsgId }) + } + return null + } + } catch (e: Exception) { + Log.w("GroupChatViewModel", "Turn failed for ${bot.name}: ${e.message}") + _uiState.update { state -> + state.copy(messages = state.messages.filter { it.id != streamMsgId }) + } + return null + } finally { + activeRuntimeId?.let { + activeStreamMsgId.remove(it) + sessionToBot.remove(it) + inFlightTurns.remove(it) + } + activeStoredId?.let { + activeStreamMsgId.remove(it) + sessionToBot.remove(it) + inFlightTurns.remove(it) + } + } + } + fun sendMessage(rawText: String) { val text = rawText.trim() Log.d("GroupChatViewModel", "sendMessage called with: '$text'") @@ -309,6 +675,9 @@ class GroupChatViewModel( return } + val epoch = UUID.randomUUID().toString() + activeEpoch = epoch + val userMessage = GroupChatMessage( id = UUID.randomUUID().toString(), @@ -321,142 +690,152 @@ class GroupChatViewModel( _uiState.update { it.copy(messages = it.messages + userMessage) } - persistSyncSnapshot(_uiState.value.messages) + persistSyncSnapshot(_uiState.value.messages, extendLease = true) viewModelScope.launch(ioDispatcher) { - val responders = GroupChatMentions.resolveResponders(text, members) - Log.d("GroupChatViewModel", "Responders: ${responders.map { it.name }}") + val allProfiles = fetchProfiles() + val defaultProfile = + allProfiles.find { it.is_default == true || it.name == "default" } + ?: allProfiles.firstOrNull() - for (bot in responders) { - _uiState.update { it.copy(activeSpeaker = bot.effectiveTitle) } - val prompt = - GroupChatMentions.buildTurnPrompt( - groupName = groupName, - viewer = bot, - peers = members.filter { it.name != bot.name }, - recentLog = _uiState.value.messages, - ) + if (defaultProfile != null) { + val hasLease = acquireOrVerifyLease(defaultProfile, epoch) + if (!hasLease) { + Log.i("GroupChatViewModel", "Passive mode active — skipping local turn drive") + return@launch + } + } - val streamMsgId = UUID.randomUUID().toString() - var activeRuntimeId: String? = null - var activeStoredId: String? = null - try { - val sessionInfo = ensureMemberSession(bot) - if (sessionInfo == null) { - Log.w("GroupChatViewModel", "Could not obtain session for ${bot.name}") - continue - } - val runtimeId = sessionInfo.runtimeSessionId - val storedId = sessionInfo.storedSessionId - activeRuntimeId = runtimeId - activeStoredId = storedId + var postedBotMessages = 0 + var continuationsCount = 0 - activeStreamMsgId[runtimeId] = streamMsgId - storedId?.let { activeStreamMsgId[it] = streamMsgId } + val maxBotLimit = _uiState.value.maxBotMessages + val maxPassLimit = _uiState.value.maxContinuationPasses - sessionToBot[runtimeId] = bot - storedId?.let { sessionToBot[it] = bot } + val initialResponders = GroupChatMentions.resolveResponders(text, members).toMutableList() + val remainingInitial = initialResponders.toMutableList() - // Set ActiveSessionHolder so the active WebSocket context matches this turn - ActiveSessionHolder.set(runtimeId, runtimeId) + // ── Round 1: Initial Turn ── + while (remainingInitial.isNotEmpty()) { + if (activeEpoch != epoch) return@launch + if (postedBotMessages >= maxBotLimit) break - // Register in-flight listener - val turnDeferred = CompletableDeferred() - inFlightTurns[runtimeId] = turnDeferred - storedId?.let { inFlightTurns[it] = turnDeferred } + val bot = remainingInitial.removeAt(0) + val reply = executeBotTurn(bot, epoch, members) + val nonSystemSize = _uiState.value.messages.filter { !it.isSystem }.size + memberWatermarks[bot.name] = nonSystemSize - // Submit prompt via sendMessage - HermesWsClient.sendMessage( - sessionId = runtimeId, - text = prompt, - ) + if (reply != null) { + postedBotMessages++ + persistSyncSnapshot(_uiState.value.messages, extendLease = true) + } + } - // Await event or polling fallback - var replyText = - withTimeoutOrNull(45_000L) { - turnDeferred.await() - } - inFlightTurns.remove(runtimeId) - storedId?.let { inFlightTurns.remove(it) } + // ── Reactive Continuation Passes ── + while (continuationsCount < maxPassLimit && postedBotMessages < maxBotLimit) { + if (activeEpoch != epoch) return@launch - // Polling fallback if event dropped or session was reaped - if (replyText == null) { - val targetIds = listOfNotNull(storedId, runtimeId).distinct() - for (tid in targetIds) { - try { - val res = - safeApiCall { - ApiClient.hermesApi.getSessionMessages( - sessionId = tid, - limit = 10, - order = "latest", - ) - } - if (res is NetworkResult.Success) { - val lastAssistant = - res.data.messages.reversed().firstOrNull { it.role == "assistant" } - val candidate = lastAssistant?.contentText?.trim() - if (!candidate.isNullOrBlank()) { - replyText = candidate - break - } - } - } catch (e: Exception) { - Log.w("GroupChatViewModel", "REST fallback failed for $tid: ${e.message}") + val currentLog = _uiState.value.messages.filter { !it.isSystem } + val userIndex = currentLog.indexOfLast { it.isUser } + val recentAssistantMsgs = + if (userIndex >= 0) currentLog.drop(userIndex + 1) else currentLog + + val pendingCitedBots = mutableListOf() + for (msg in recentAssistantMsgs) { + val parsed = + GroupChatMentions.parseMentions( + text = msg.text, + members = members, + excludedSpeaker = msg.senderName, + ) + for (citedName in parsed.mentionedBots) { + val bot = members.find { it.name.equals(citedName, ignoreCase = true) } + if (bot != null && !pendingCitedBots.any { it.name == bot.name }) { + val seenCount = memberWatermarks[bot.name] ?: 0 + if (seenCount < currentLog.size) { + pendingCitedBots.add(bot) } } } + } - if (replyText != null && !isPass(replyText) && replyText.isNotBlank()) { - _uiState.update { state -> - val existing = state.messages.find { it.id == streamMsgId } - val finalMsg = - GroupChatMessage( - id = streamMsgId, - senderName = bot.name, - senderDisplayName = bot.effectiveTitle, - isUser = false, - avatarMeta = bot.botMeta()?.avatar, - text = replyText.trim(), - isStreaming = false, - ) - val updatedList = - if (existing != null) { - state.messages.map { if (it.id == streamMsgId) finalMsg else it } - } else { - state.messages + finalMsg - } - state.copy(messages = updatedList) - } - persistSyncSnapshot(_uiState.value.messages) - } else { - _uiState.update { state -> - state.copy(messages = state.messages.filter { it.id != streamMsgId }) - } - } - } catch (e: Exception) { - Log.w("GroupChatViewModel", "Turn failed for ${bot.name}: ${e.message}") - _uiState.update { state -> - state.copy(messages = state.messages.filter { it.id != streamMsgId }) - } - } finally { - activeRuntimeId?.let { - activeStreamMsgId.remove(it) - sessionToBot.remove(it) - inFlightTurns.remove(it) - } - activeStoredId?.let { - activeStreamMsgId.remove(it) - sessionToBot.remove(it) - inFlightTurns.remove(it) + if (pendingCitedBots.isEmpty()) { + // Quiescence / Natural Settle reached + break + } + + continuationsCount++ + val continuationResponders = pendingCitedBots.toMutableList() + + while (continuationResponders.isNotEmpty()) { + if (activeEpoch != epoch) return@launch + if (postedBotMessages >= maxBotLimit) break + + val bot = continuationResponders.removeAt(0) + val reply = executeBotTurn(bot, epoch, members) + val nonSystemSize = _uiState.value.messages.filter { !it.isSystem }.size + memberWatermarks[bot.name] = nonSystemSize + + if (reply != null) { + postedBotMessages++ + persistSyncSnapshot(_uiState.value.messages, extendLease = true) } } } + + // ── Capped Exit Banner Check ── + val isBudgetExceeded = + postedBotMessages >= maxBotLimit || continuationsCount >= maxPassLimit + val hasUnfinishedWork = remainingInitial.isNotEmpty() || hasPendingMentions(members) + + if (isBudgetExceeded && hasUnfinishedWork && activeEpoch == epoch) { + val cappedMessage = + GroupChatMessage( + id = UUID.randomUUID().toString(), + senderName = "system", + senderDisplayName = "System", + isUser = false, + isSystem = true, + text = CAPPED_SYSTEM_TEXT, + ) + _uiState.update { it.copy(messages = it.messages + cappedMessage) } + persistSyncSnapshot(_uiState.value.messages, extendLease = false) + } + _uiState.update { it.copy(activeSpeaker = null) } } } - private fun persistSyncSnapshot(currentMessages: List) { + private fun hasPendingMentions(members: List): Boolean { + val currentLog = _uiState.value.messages.filter { !it.isSystem } + val userIndex = currentLog.indexOfLast { it.isUser } + val recentAssistantMsgs = + if (userIndex >= 0) currentLog.drop(userIndex + 1) else currentLog + + for (msg in recentAssistantMsgs) { + val parsed = + GroupChatMentions.parseMentions( + text = msg.text, + members = members, + excludedSpeaker = msg.senderName, + ) + for (citedName in parsed.mentionedBots) { + val bot = members.find { it.name.equals(citedName, ignoreCase = true) } + if (bot != null) { + val seenCount = memberWatermarks[bot.name] ?: 0 + if (seenCount < currentLog.size) { + return true + } + } + } + } + return false + } + + private fun persistSyncSnapshot( + currentMessages: List, + extendLease: Boolean = true, + ) { viewModelScope.launch(ioDispatcher) { try { val allProfiles = fetchProfiles() @@ -467,7 +846,7 @@ class GroupChatViewModel( ?: GroupChatSyncSnapshot(version = 3, rooms = emptyMap()) val logEntries = - currentMessages.takeLast(32).map { msg -> + currentMessages.filter { !it.isSystem }.takeLast(32).map { msg -> GroupChatSyncLogEntry( id = msg.id, from = @@ -481,25 +860,38 @@ class GroupChatViewModel( ) } - val roomKey = "name:$groupName" - val existingRoom = - existingSnapshot.rooms?.get(roomKey) - ?: existingSnapshot.rooms?.get(groupName) - ?: existingSnapshot.rooms?.values?.find { it.name.equals(groupName, ignoreCase = true) } + val now = System.currentTimeMillis() + val (targetKey, existingRoom) = findRoomEntry(existingSnapshot.rooms) + + val updatedLease = + if (extendLease) { + GroupChatRoomLease( + driverId = localDriverId, + epoch = activeEpoch, + expiresAt = now + LEASE_STEP_TTL_MS, + ) + } else { + existingRoom?.lease + } val updatedRoom = - (existingRoom ?: GroupChatRoomMeta(name = groupName, createdAt = System.currentTimeMillis())).copy( + (existingRoom ?: GroupChatRoomMeta(name = groupName, createdAt = now)).copy( name = groupName, members = _uiState.value.members.map { JsonPrimitive(it.name) }, log = logEntries, - updatedAt = System.currentTimeMillis(), + lease = updatedLease, + maxBotMessages = _uiState.value.maxBotMessages, + maxContinuationPasses = _uiState.value.maxContinuationPasses, + systemPrompt = _uiState.value.systemPrompt, + updatedAt = now, ) - val updatedRooms = (existingSnapshot.rooms.orEmpty() + (roomKey to updatedRoom)) + val updatedRooms = existingSnapshot.rooms.orEmpty().toMutableMap() + updatedRooms[targetKey] = updatedRoom val newSnapshot = existingSnapshot.copy( version = 3, - updatedAt = System.currentTimeMillis(), + updatedAt = now, rooms = updatedRooms, ) @@ -520,7 +912,7 @@ class GroupChatViewModel( private fun isPass(text: String): Boolean { val clean = text.trim().lowercase() - return clean == "(pass)" || clean == "pass" + return clean == "(pass)" || clean == "pass" || clean == "(pass)." || clean == "pass." } private fun extractSessionInfo(response: Any?): MemberSession? { diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index e281ef44..d43eff7c 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -822,4 +822,16 @@ 입력 중… 멤버 %1$d명 @all + 대화가 일시중지되었습니다(턴 한도 도달) — 계속하려면 메시지를 보내세요 + 그룹 채팅 설정 + 최대 봇 메시지 수 + 메시지당 최대 봇 응답 한도 (%1$d) + 최대 핸드오프 패스 + 봇이 서로를 트리거할 수 있는 최대 횟수 (%1$d) + 저장 + 기본값으로 초기화 + 방 지침 / 프롬프트 + 이 그룹을 위한 사용자 지정 규칙, 어조 또는 컨텍스트 (선택사항)… + 이 방의 모든 봇에 적용되는 사용자 지정 가이드라인 + 설정 \ No newline at end of file diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 15c51dc7..6ee20c4a 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -1121,4 +1121,16 @@ 正在输入… %1$d 个成员 @all + 讨论已暂停(达到轮次上限)— 发送消息继续 + 群聊设置 + 最大 Bot 回复数 + 单次发送的最大 Bot 回复总数 (%1$d) + 最大交接轮次 + Bot 间互相触发的最大次数 (%1$d) + 保存 + 重置为默认 + 房间指令 / 提示词 + 此群聊的自定义规则、语气或上下文(可选)… + 适用于此房间内所有 Bot 的自定义准则 + 设置 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c24ab7cf..f51bb5cd 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1252,4 +1252,16 @@ Typing… %1$d members @all + Discussion paused (turn limit reached) — reply to continue + Group Chat Settings + Max Bot Messages + Ceiling on total bot replies per message (%1$d) + Max Handoff Passes + How many times bots can trigger each other (%1$d) + Save + Reset to Default + Room Instructions / Prompt + Custom rules, tone, or context for this group (optional)… + Custom guidelines for all bots in this room + Settings diff --git a/app/src/test/java/com/m57/hermescontrol/ui/bots/group/GroupChatViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/bots/group/GroupChatViewModelTest.kt index e91babd7..ff7730ae 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/bots/group/GroupChatViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/bots/group/GroupChatViewModelTest.kt @@ -3,6 +3,7 @@ package com.m57.hermescontrol.ui.bots.group import android.util.Log import com.m57.hermescontrol.data.model.BotAvatarMeta import com.m57.hermescontrol.data.model.BotRosterMeta +import com.m57.hermescontrol.data.model.GroupChatRoomLease import com.m57.hermescontrol.data.model.GroupChatRoomMeta import com.m57.hermescontrol.data.model.GroupChatSyncFrom import com.m57.hermescontrol.data.model.GroupChatSyncLogEntry @@ -263,4 +264,172 @@ class GroupChatViewModelTest { assertEquals(1, finalState.messages.size) assertTrue(finalState.messages.first().isUser) } + + @Test + fun testSendMessage_reactiveContinuationHandoff() = + runTest(testDispatcher) { + val viewModel = GroupChatViewModel("Dev Team", ioDispatcher = testDispatcher) + testScheduler.runCurrent() + + // User prompts scout + viewModel.sendMessage("@scout please review the specs") + testScheduler.runCurrent() + + // Scout finishes turn and tags @coder + eventsFlow.emit( + WsEvent.MessageComplete( + text = "Specs look good, @coder please implement this!", + sessionId = "session-scout-1", + ), + ) + testScheduler.runCurrent() + + assertEquals(2, viewModel.uiState.value.messages.size) + assertEquals("scout", viewModel.uiState.value.messages[1].senderName) + + // Continuation pass triggers coder + eventsFlow.emit( + WsEvent.MessageComplete( + text = "On it! Writing unit tests now.", + sessionId = "session-scout-1", + ), + ) + testScheduler.runCurrent() + + val messages = viewModel.uiState.value.messages + assertEquals(3, messages.size) + assertEquals("coder", messages[2].senderName) + assertEquals("On it! Writing unit tests now.", messages[2].text) + } + + @Test + fun testSendMessage_passiveListenerWhenLeaseHeldByOtherDevice() = + runTest(testDispatcher) { + val activeLeaseSnapshot = + GroupChatSyncSnapshot( + version = 3, + rooms = + mapOf( + "name:Dev Team" to + GroupChatRoomMeta( + name = "Dev Team", + members = listOf(JsonPrimitive("scout"), JsonPrimitive("coder")), + lease = + GroupChatRoomLease( + driverId = "desktop_client_99", + expiresAt = System.currentTimeMillis() + 60_000L, + ), + ), + ), + ) + + val defaultProfileWithLease = + ProfileInfo( + name = "default", + is_default = true, + ui_meta = mapOf("hermes-bots-groups" to json.encodeToJsonElement(activeLeaseSnapshot)), + ) + + coEvery { mockApi.getProfiles() } returns + Response.success( + ProfilesResponse( + profiles = listOf(defaultProfileWithLease, botA, botB), + ), + ) + + val viewModel = GroupChatViewModel("Dev Team", ioDispatcher = testDispatcher) + advanceUntilIdle() + + viewModel.sendMessage("@scout check this") + testScheduler.runCurrent() + + // Because lease is held by desktop_client_99, mobile only posts the user message and yields + assertEquals(1, viewModel.uiState.value.messages.size) + assertTrue(viewModel.uiState.value.messages.first().isUser) + } + + @Test + fun testLoadGroup_loadsCustomPerGroupLimits() = + runTest(testDispatcher) { + val customLimitsSnapshot = + GroupChatSyncSnapshot( + version = 3, + rooms = + mapOf( + "name:Dev Team" to + GroupChatRoomMeta( + name = "Dev Team", + members = listOf(JsonPrimitive("scout"), JsonPrimitive("coder")), + maxBotMessages = 12, + maxContinuationPasses = 5, + systemPrompt = "You are elite software architects.", + ), + ), + ) + + val defaultProfileWithCustomLimits = + ProfileInfo( + name = "default", + is_default = true, + ui_meta = mapOf("hermes-bots-groups" to json.encodeToJsonElement(customLimitsSnapshot)), + ) + + coEvery { mockApi.getProfiles() } returns + Response.success( + ProfilesResponse( + profiles = listOf(defaultProfileWithCustomLimits, botA, botB), + ), + ) + + val viewModel = GroupChatViewModel("Dev Team", ioDispatcher = testDispatcher) + advanceUntilIdle() + + val state = viewModel.uiState.value + assertEquals(12, state.maxBotMessages) + assertEquals(5, state.maxContinuationPasses) + assertEquals("You are elite software architects.", state.systemPrompt) + } + + @Test + fun testUpdateGroupLimits_updatesStateAndClamps() = + runTest(testDispatcher) { + val viewModel = GroupChatViewModel("Dev Team", ioDispatcher = testDispatcher) + advanceUntilIdle() + + viewModel.updateGroupLimits( + maxMessages = 15, + maxPasses = 4, + systemPrompt = "Custom instructions for this group", + ) + testScheduler.runCurrent() + + assertEquals(15, viewModel.uiState.value.maxBotMessages) + assertEquals(4, viewModel.uiState.value.maxContinuationPasses) + assertEquals("Custom instructions for this group", viewModel.uiState.value.systemPrompt) + } + + @Test + fun testBuildTurnPrompt_includesCustomRoomInstructions() { + val prompt = + GroupChatMentions.buildTurnPrompt( + groupName = "Dev Team", + viewer = botA, + peers = listOf(botB), + recentLog = + listOf( + GroupChatMessage( + id = "1", + senderName = "user", + senderDisplayName = "User", + isUser = true, + text = "hello team", + ), + ), + customRoomPrompt = "You are sarcastic AI buddies.", + ) + + assertTrue(prompt.contains("Room Instructions:\nYou are sarcastic AI buddies.")) + assertTrue(prompt.contains("@scout (Scout Bot)")) + assertTrue(prompt.contains("@coder (Dev Bot)")) + } } From fbe602cb4fa836bca80305f42617caf71d5f8d58 Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Sat, 29 Aug 2026 19:11:14 +0300 Subject: [PATCH 09/14] feat(bots): lightweight tool execution indicators in group chat (#1008) Co-authored-by: M57 --- .../ui/bots/group/GroupChatModels.kt | 12 ++ .../ui/bots/group/GroupChatScreen.kt | 71 +++++++++- .../ui/bots/group/GroupChatViewModel.kt | 123 ++++++++++++++++-- .../ui/bots/group/GroupChatViewModelTest.kt | 54 ++++++++ 4 files changed, 249 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatModels.kt b/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatModels.kt index 17de3dc2..b6eb32da 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatModels.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatModels.kt @@ -2,6 +2,17 @@ package com.m57.hermescontrol.ui.bots.group import com.m57.hermescontrol.data.model.BotAvatarMeta import com.m57.hermescontrol.data.model.ProfileInfo +import java.util.UUID + +/** + * Lightweight tool execution indicator for group chat turns. + */ +data class GroupChatToolCall( + val id: String = UUID.randomUUID().toString(), + val name: String, + val summary: String? = null, + val isRunning: Boolean = false, +) /** * Single message entry in a multi-agent group room. @@ -17,6 +28,7 @@ data class GroupChatMessage( val isStreaming: Boolean = false, val thread: String? = null, val isSystem: Boolean = false, + val toolCalls: List = emptyList(), ) /** diff --git a/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatScreen.kt index 398db70a..fc9cb6d6 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatScreen.kt @@ -24,7 +24,11 @@ import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Send +import androidx.compose.material.icons.filled.Build +import androidx.compose.material.icons.filled.Description +import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.Terminal import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator @@ -56,6 +60,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel @@ -542,8 +547,19 @@ private fun GroupMessageCard( fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary, ) + if (message.toolCalls.isNotEmpty()) { + Spacer(modifier = Modifier.height(4.dp)) + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.padding(bottom = if (message.text.isNotBlank()) 4.dp else 0.dp), + ) { + message.toolCalls.forEach { tool -> + GroupChatToolChip(tool = tool) + } + } + } Spacer(modifier = Modifier.height(4.dp)) - if (message.isStreaming && message.text.isBlank()) { + if (message.isStreaming && message.text.isBlank() && message.toolCalls.none { it.isRunning }) { Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = 4.dp), @@ -571,3 +587,56 @@ private fun GroupMessageCard( } } } + +@Composable +private fun GroupChatToolChip( + tool: GroupChatToolCall, + modifier: Modifier = Modifier, +) { + Surface( + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.7f), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)), + modifier = modifier, + ) { + Row( + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + val icon = + when (tool.name.lowercase()) { + "terminal" -> Icons.Filled.Terminal + "web_search", "search_files" -> Icons.Filled.Search + "read_file", "write_file", "patch" -> Icons.Filled.Description + else -> Icons.Filled.Build + } + Icon( + imageVector = icon, + contentDescription = tool.name, + modifier = Modifier.size(13.dp), + tint = MaterialTheme.colorScheme.primary, + ) + val label = + if (!tool.summary.isNullOrBlank()) { + "${tool.name}: ${tool.summary}" + } else { + tool.name + } + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (tool.isRunning) { + CircularProgressIndicator( + modifier = Modifier.size(10.dp), + strokeWidth = 1.5.dp, + color = MaterialTheme.colorScheme.primary, + ) + } + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatViewModel.kt index d083f3bb..fa4d121f 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatViewModel.kt @@ -107,6 +107,19 @@ class GroupChatViewModel( loadGroup() } + private fun extractToolSummary(data: Map?): String? { + if (data == null) return null + val cmd = (data["command"] as? String)?.trim() + if (!cmd.isNullOrBlank()) return cmd.take(35) + val query = (data["query"] as? String)?.trim() + if (!query.isNullOrBlank()) return query.take(35) + val path = (data["path"] as? String)?.trim() + if (!path.isNullOrBlank()) return path.take(35) + val pattern = (data["pattern"] as? String)?.trim() + if (!pattern.isNullOrBlank()) return pattern.take(35) + return null + } + private fun observeWsEvents() { viewModelScope.launch(ioDispatcher) { HermesWsClient.events.collect { event -> @@ -146,6 +159,93 @@ class GroupChatViewModel( } } + is WsEvent.ToolStart -> { + val sid = event.sessionId?.trim('\"', ' ') + if (sid != null) { + val streamId = activeStreamMsgId[sid] + val bot = sessionToBot[sid] + val toolName = event.name ?: "tool" + val toolId = + (event.data?.get("tool_id") as? String)?.ifBlank { null } + ?: UUID.randomUUID().toString() + val summary = extractToolSummary(event.data) + val toolCall = + GroupChatToolCall( + id = toolId, + name = toolName, + summary = summary, + isRunning = true, + ) + if (streamId != null && bot != null) { + _uiState.update { state -> + val existing = state.messages.find { it.id == streamId } + val updatedMessages = + if (existing != null) { + state.messages.map { msg -> + if (msg.id == streamId) { + msg.copy(toolCalls = msg.toolCalls + toolCall) + } else { + msg + } + } + } else { + state.messages + + GroupChatMessage( + id = streamId, + senderName = bot.name, + senderDisplayName = bot.effectiveTitle, + isUser = false, + avatarMeta = bot.botMeta()?.avatar, + text = "", + isStreaming = true, + toolCalls = listOf(toolCall), + ) + } + state.copy(messages = updatedMessages) + } + } + } + } + + is WsEvent.ToolComplete -> { + val sid = event.sessionId?.trim('\"', ' ') + if (sid != null) { + val streamId = activeStreamMsgId[sid] + val toolId = event.data?.get("tool_id") as? String + val toolName = event.name + if (streamId != null) { + _uiState.update { state -> + val existing = state.messages.find { it.id == streamId } + if (existing != null) { + val updatedTools = + existing.toolCalls.map { tool -> + if ((toolId != null && tool.id == toolId) || + (toolId == null && tool.name == toolName && tool.isRunning) || + (toolId == null && toolName == null && tool.isRunning) + ) { + tool.copy(isRunning = false) + } else { + tool + } + } + state.copy( + messages = + state.messages.map { msg -> + if (msg.id == streamId) { + msg.copy(toolCalls = updatedTools) + } else { + msg + } + }, + ) + } else { + state + } + } + } + } + } + is WsEvent.MessageComplete -> { val sid = event.sessionId?.trim('\"', ' ') if (sid != null) { @@ -617,18 +717,21 @@ class GroupChatViewModel( } if (replyText != null && !isPass(replyText) && replyText.isNotBlank()) { - val finalMsg = - GroupChatMessage( - id = streamMsgId, - senderName = bot.name, - senderDisplayName = bot.effectiveTitle, - isUser = false, - avatarMeta = bot.botMeta()?.avatar, - text = replyText.trim(), - isStreaming = false, - ) _uiState.update { state -> val existing = state.messages.find { it.id == streamMsgId } + val finalTools = + existing?.toolCalls?.map { it.copy(isRunning = false) } ?: emptyList() + val finalMsg = + GroupChatMessage( + id = streamMsgId, + senderName = bot.name, + senderDisplayName = bot.effectiveTitle, + isUser = false, + avatarMeta = bot.botMeta()?.avatar, + text = replyText.trim(), + isStreaming = false, + toolCalls = finalTools, + ) val updatedList = if (existing != null) { state.messages.map { if (it.id == streamMsgId) finalMsg else it } diff --git a/app/src/test/java/com/m57/hermescontrol/ui/bots/group/GroupChatViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/bots/group/GroupChatViewModelTest.kt index ff7730ae..c223f648 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/bots/group/GroupChatViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/bots/group/GroupChatViewModelTest.kt @@ -432,4 +432,58 @@ class GroupChatViewModelTest { assertTrue(prompt.contains("@scout (Scout Bot)")) assertTrue(prompt.contains("@coder (Dev Bot)")) } + + @Test + fun testToolEvents_rendersAndCompletesToolCall() = + runTest(testDispatcher) { + val viewModel = GroupChatViewModel("Dev Team", ioDispatcher = testDispatcher) + testScheduler.runCurrent() + + viewModel.sendMessage("@scout check time") + testScheduler.runCurrent() + + // Emit ToolStart + eventsFlow.emit( + WsEvent.ToolStart( + name = "terminal", + data = mapOf("command" to "date", "tool_id" to "tool-123"), + sessionId = "session-scout-1", + ), + ) + testScheduler.runCurrent() + + val msgWithTool = viewModel.uiState.value.messages.last() + assertEquals(1, msgWithTool.toolCalls.size) + val toolCall = msgWithTool.toolCalls.first() + assertEquals("terminal", toolCall.name) + assertEquals("date", toolCall.summary) + assertTrue(toolCall.isRunning) + + // Emit ToolComplete + eventsFlow.emit( + WsEvent.ToolComplete( + name = "terminal", + data = mapOf("tool_id" to "tool-123"), + sessionId = "session-scout-1", + ), + ) + testScheduler.runCurrent() + + val msgAfterToolDone = viewModel.uiState.value.messages.last() + assertFalse(msgAfterToolDone.toolCalls.first().isRunning) + + // Complete message + eventsFlow.emit( + WsEvent.MessageComplete( + text = "Current time is Sat Aug 29", + sessionId = "session-scout-1", + ), + ) + testScheduler.runCurrent() + + val finalMsg = viewModel.uiState.value.messages.last() + assertEquals("Current time is Sat Aug 29", finalMsg.text) + assertEquals(1, finalMsg.toolCalls.size) + assertFalse(finalMsg.toolCalls.first().isRunning) + } } From 174f8dee7da5d3189ae429328361d2433e58903b Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Sat, 29 Aug 2026 19:24:51 +0300 Subject: [PATCH 10/14] feat(bots): selectable text, timestamps, and copy buttons in group chat (#1009) Co-authored-by: M57 --- .../ui/bots/group/GroupChatScreen.kt | 115 ++++++++++++++++-- 1 file changed, 106 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatScreen.kt index fc9cb6d6..6bfd25e0 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatScreen.kt @@ -1,5 +1,7 @@ package com.m57.hermescontrol.ui.bots.group +import android.content.ClipData +import android.text.format.DateFormat import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -22,9 +24,12 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Send import androidx.compose.material.icons.filled.Build +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.Description import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Settings @@ -48,12 +53,16 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.platform.ClipEntry +import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource @@ -67,10 +76,13 @@ import androidx.lifecycle.viewmodel.compose.viewModel import com.m57.hermescontrol.R import com.m57.hermescontrol.theme.LocalChatFontScale import com.m57.hermescontrol.ui.chat.MarkdownText +import com.m57.hermescontrol.ui.chat.formatTimestamp import com.m57.hermescontrol.ui.common.BotAvatar import com.m57.hermescontrol.ui.common.EmptyState import com.m57.hermescontrol.ui.common.HermesScaffold import com.m57.hermescontrol.ui.common.NavIcon +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import kotlin.math.roundToInt @Composable @@ -486,6 +498,17 @@ private fun GroupMessageCard( message: GroupChatMessage, modifier: Modifier = Modifier, ) { + val clipboard = LocalClipboard.current + val scope = rememberCoroutineScope() + var copied by remember { mutableStateOf(false) } + + LaunchedEffect(copied) { + if (copied) { + delay(1500) + copied = false + } + } + if (message.isSystem) { Box( modifier = modifier.fillMaxWidth().padding(vertical = 4.dp), @@ -515,11 +538,47 @@ private fun GroupMessageCard( modifier = Modifier.fillMaxWidth(0.85f), ) { Column(modifier = Modifier.padding(12.dp)) { - Text( - text = message.text, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onPrimary, - ) + SelectionContainer { + Text( + text = message.text, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onPrimary, + ) + } + Spacer(modifier = Modifier.height(4.dp)) + Row( + modifier = Modifier.align(Alignment.End), + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton( + onClick = { + scope.launch { + clipboard.setClipEntry( + ClipEntry(ClipData.newPlainText(null, message.text)), + ) + } + copied = true + }, + modifier = Modifier.size(20.dp), + ) { + Icon( + imageVector = if (copied) Icons.Filled.Check else Icons.Filled.ContentCopy, + contentDescription = stringResource(R.string.content_desc_copy), + modifier = Modifier.size(12.dp), + tint = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.7f), + ) + } + Spacer(modifier = Modifier.width(2.dp)) + Text( + text = + formatTimestamp( + message.timestamp, + DateFormat.is24HourFormat(LocalContext.current), + ), + color = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.7f), + style = MaterialTheme.typography.labelSmall, + ) + } } } } @@ -577,10 +636,48 @@ private fun GroupMessageCard( ) } } else if (message.text.isNotBlank()) { - MarkdownText( - text = message.text, - textColor = MaterialTheme.colorScheme.onSurfaceVariant, - ) + SelectionContainer { + MarkdownText( + text = message.text, + textColor = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + if (!message.isStreaming && message.text.isNotBlank()) { + Spacer(modifier = Modifier.height(4.dp)) + Row( + modifier = Modifier.align(Alignment.End), + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton( + onClick = { + scope.launch { + clipboard.setClipEntry( + ClipEntry(ClipData.newPlainText(null, message.text)), + ) + } + copied = true + }, + modifier = Modifier.size(20.dp), + ) { + Icon( + imageVector = if (copied) Icons.Filled.Check else Icons.Filled.ContentCopy, + contentDescription = stringResource(R.string.content_desc_copy), + modifier = Modifier.size(12.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + ) + } + Spacer(modifier = Modifier.width(2.dp)) + Text( + text = + formatTimestamp( + message.timestamp, + DateFormat.is24HourFormat(LocalContext.current), + ), + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + style = MaterialTheme.typography.labelSmall, + ) + } } } } From 2f3bf5114d5e65c5c96a94be2e1f5d7af6cb50f4 Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Sat, 29 Aug 2026 22:58:21 +0300 Subject: [PATCH 11/14] =?UTF-8?q?docs:=20PR=20template=20=E2=80=94=20add?= =?UTF-8?q?=20Description=20section=20+=20fix=20rebase=20target=20to=20dev?= =?UTF-8?q?=20(#1010)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: M57 --- .github/PULL_REQUEST_TEMPLATE.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 5b0e4a35..a7d858ca 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -4,6 +4,10 @@ Fixes # +## Description + + + ## Type of Change - [ ] 🐛 Bug fix @@ -13,10 +17,6 @@ Fixes # - [ ] ✅ Tests - [ ] 🔧 CI / chore -## What changed - - - ## How to test @@ -27,7 +27,7 @@ Fixes # ## Checklist -- [ ] Branch rebased onto `main` (`git rebase origin/main`) +- [ ] Branch rebased onto `dev` (`git rebase origin/dev`) - [ ] `./gradlew ktlintCheck` passes (ran `ktlintFormat` first) - [ ] `./gradlew testDebugUnitTest` green (or CI unit-tests job) - [ ] `checkColorLiterals` passes (no hardcoded Color outside theme/) From bd4a73dd48a4a83cd9d24c65aef60c40e6cb5ce7 Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Sat, 29 Aug 2026 23:48:08 +0300 Subject: [PATCH 12/14] feat(bots): horizontal swipe between bots and groups tabs with drawer gesture gating (#1011) Co-authored-by: M57 --- .../m57/hermescontrol/ui/bots/BotsScreen.kt | 293 +++++++++++------- 1 file changed, 185 insertions(+), 108 deletions(-) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/bots/BotsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/bots/BotsScreen.kt index 279a479f..5480b13a 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/bots/BotsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/bots/BotsScreen.kt @@ -1,6 +1,8 @@ package com.m57.hermescontrol.ui.bots import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -16,6 +18,8 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape @@ -55,6 +59,8 @@ 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.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight @@ -82,6 +88,7 @@ fun BotsScreen( viewModel: BotsViewModel = viewModel { BotsViewModel() }, ) { val state by viewModel.uiState.collectAsStateWithLifecycle() + val pagerState = rememberPagerState(initialPage = state.selectedTab.ordinal) { BotsTab.entries.size } var isSearchActive by remember { mutableStateOf(false) } var showCreateDialog by remember { mutableStateOf(false) } var showCreateGroupDialog by remember { mutableStateOf(false) } @@ -89,6 +96,19 @@ fun BotsScreen( var disbandingGroup by remember { mutableStateOf(null) } val scope = rememberCoroutineScope() + LaunchedEffect(pagerState.currentPage) { + val targetTab = BotsTab.entries.getOrNull(pagerState.currentPage) ?: BotsTab.BOTS + if (state.selectedTab != targetTab) { + viewModel.setSelectedTab(targetTab) + } + } + + LaunchedEffect(state.selectedTab) { + if (pagerState.currentPage != state.selectedTab.ordinal) { + pagerState.animateScrollToPage(state.selectedTab.ordinal) + } + } + LaunchedEffect(Unit) { viewModel.loadBots() } @@ -176,6 +196,7 @@ fun BotsScreen( HermesScaffold( modifier = modifier, + drawerGesturesEnabled = pagerState.currentPage == 0, title = { if (isSearchActive) { OutlinedTextField( @@ -270,18 +291,26 @@ fun BotsScreen( ) { Column(modifier = Modifier.fillMaxSize()) { PrimaryTabRow( - selectedTabIndex = state.selectedTab.ordinal, + selectedTabIndex = pagerState.currentPage, modifier = Modifier.fillMaxWidth(), ) { Tab( - selected = state.selectedTab == BotsTab.BOTS, - onClick = { viewModel.setSelectedTab(BotsTab.BOTS) }, + selected = pagerState.currentPage == BotsTab.BOTS.ordinal, + onClick = { + scope.launch { + pagerState.animateScrollToPage(BotsTab.BOTS.ordinal) + } + }, text = { Text(stringResource(R.string.bots_tab_all)) }, icon = { Icon(Icons.Filled.SmartToy, contentDescription = null) }, ) Tab( - selected = state.selectedTab == BotsTab.GROUPS, - onClick = { viewModel.setSelectedTab(BotsTab.GROUPS) }, + selected = pagerState.currentPage == BotsTab.GROUPS.ordinal, + onClick = { + scope.launch { + pagerState.animateScrollToPage(BotsTab.GROUPS.ordinal) + } + }, text = { Text( if (state.allGroups.isNotEmpty()) { @@ -295,120 +324,168 @@ fun BotsScreen( ) } - when (state.selectedTab) { - BotsTab.BOTS -> { - when { - state.isLoading && state.profiles.isEmpty() -> { - SkeletonListState() - } - - state.errorMessage != null && state.profiles.isEmpty() -> { - ErrorState( - message = state.errorMessage ?: "", - onRetry = { viewModel.loadBots() }, - ) - } - - state.displayProfiles.isEmpty() -> { - EmptyState( - title = stringResource(R.string.bots_empty_title), - subtitle = stringResource(R.string.bots_empty_description), - modifier = Modifier.fillMaxSize(), - ) - } - - else -> { - LazyColumn( - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 24.dp), - verticalArrangement = Arrangement.spacedBy(10.dp), - ) { - if (state.activeNowBots.isNotEmpty() && !isSearchActive) { - item(key = "active_now_strip") { - ActiveNowStrip( - activeBots = state.activeNowBots, - onSelectBot = { bot -> - scope.launch { - viewModel.selectBot(bot) - val canonicalId = - bot.canonical_session?.resolved_id - ?: bot.canonical_session?.id - if (!canonicalId.isNullOrBlank()) { - NavigationController.openChatSession(canonicalId) - } else { - NavigationController.navigateTo( - com.m57.hermescontrol.ChatScreen, - ) + HorizontalPager( + state = pagerState, + modifier = Modifier.fillMaxSize(), + ) { page -> + when (BotsTab.entries.getOrElse(page) { BotsTab.BOTS }) { + BotsTab.BOTS -> { + Box( + modifier = + Modifier + .fillMaxSize() + .pointerInput(pagerState.currentPage, onOpenDrawer) { + if (pagerState.currentPage == 0) { + awaitEachGesture { + awaitFirstDown(pass = PointerEventPass.Initial) + var totalDragX = 0f + while (true) { + val event = awaitPointerEvent(pass = PointerEventPass.Initial) + val change = event.changes.firstOrNull() ?: break + if (!change.pressed) break + val dx = change.position.x - change.previousPosition.x + if (dx > 0) { + totalDragX += dx + if (totalDragX > 75f) { + onOpenDrawer?.invoke() + break + } + } else if (dx < -10f) { + break } } - }, - ) - } + } + } + }, + ) { + when { + state.isLoading && state.profiles.isEmpty() -> { + SkeletonListState() } - items( - items = state.displayProfiles, - key = { it.name }, - ) { profile -> - BotCard( - profile = profile, - isActiveProfile = profile.name == state.activeProfileName, - onClick = { - scope.launch { - viewModel.selectBot(profile) - val canonicalId = - profile.canonical_session?.resolved_id - ?: profile.canonical_session?.id - if (!canonicalId.isNullOrBlank()) { - NavigationController.openChatSession(canonicalId) - } else { - NavigationController.navigateTo(com.m57.hermescontrol.ChatScreen) - } - } - }, - onEditClick = { - editingBot = profile - }, + state.errorMessage != null && state.profiles.isEmpty() -> { + ErrorState( + message = state.errorMessage ?: "", + onRetry = { viewModel.loadBots() }, ) } + + state.displayProfiles.isEmpty() -> { + EmptyState( + title = stringResource(R.string.bots_empty_title), + subtitle = stringResource(R.string.bots_empty_description), + modifier = Modifier.fillMaxSize(), + ) + } + + else -> { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = + PaddingValues( + start = 16.dp, + end = 16.dp, + top = 8.dp, + bottom = 24.dp, + ), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + if (state.activeNowBots.isNotEmpty() && !isSearchActive) { + item(key = "active_now_strip") { + ActiveNowStrip( + activeBots = state.activeNowBots, + onSelectBot = { bot -> + scope.launch { + viewModel.selectBot(bot) + val canonicalId = + bot.canonical_session?.resolved_id + ?: bot.canonical_session?.id + if (!canonicalId.isNullOrBlank()) { + NavigationController.openChatSession(canonicalId) + } else { + NavigationController.navigateTo( + com.m57.hermescontrol.ChatScreen, + ) + } + } + }, + ) + } + } + + items( + items = state.displayProfiles, + key = { it.name }, + ) { profile -> + BotCard( + profile = profile, + isActiveProfile = profile.name == state.activeProfileName, + onClick = { + scope.launch { + viewModel.selectBot(profile) + val canonicalId = + profile.canonical_session?.resolved_id + ?: profile.canonical_session?.id + if (!canonicalId.isNullOrBlank()) { + NavigationController.openChatSession(canonicalId) + } else { + NavigationController.navigateTo( + com.m57.hermescontrol.ChatScreen, + ) + } + } + }, + onEditClick = { + editingBot = profile + }, + ) + } + } + } } } } - } - BotsTab.GROUPS -> { - when { - state.displayGroups.isEmpty() -> { - EmptyState( - title = stringResource(R.string.bots_groups_empty_title), - subtitle = stringResource(R.string.bots_groups_empty_desc), - modifier = Modifier.fillMaxSize(), - ) - } + BotsTab.GROUPS -> { + when { + state.displayGroups.isEmpty() -> { + EmptyState( + title = stringResource(R.string.bots_groups_empty_title), + subtitle = stringResource(R.string.bots_groups_empty_desc), + modifier = Modifier.fillMaxSize(), + ) + } - else -> { - LazyColumn( - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 24.dp), - verticalArrangement = Arrangement.spacedBy(10.dp), - ) { - items( - items = state.displayGroups, - key = { it.name }, - ) { group -> - GroupCard( - group = group, - onClick = { - if (group.members.isNotEmpty()) { - NavigationController.navigateTo( - com.m57.hermescontrol.GroupChatKey(group.name), - ) - } - }, - onDisbandClick = { - disbandingGroup = group - }, - ) + else -> { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = + PaddingValues( + start = 16.dp, + end = 16.dp, + top = 8.dp, + bottom = 24.dp, + ), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + items( + items = state.displayGroups, + key = { it.name }, + ) { group -> + GroupCard( + group = group, + onClick = { + if (group.members.isNotEmpty()) { + NavigationController.navigateTo( + com.m57.hermescontrol.GroupChatKey(group.name), + ) + } + }, + onDisbandClick = { + disbandingGroup = group + }, + ) + } } } } From 97b6641d841cb10f00cfd991201641fe52afe09d Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Sun, 30 Aug 2026 00:02:16 +0300 Subject: [PATCH 13/14] feat(bots): emergency stop button and /stop slash command in group chat (#1012) Co-authored-by: M57 --- .../ui/bots/group/GroupChatScreen.kt | 62 ++++++++++++++----- .../ui/bots/group/GroupChatViewModel.kt | 55 ++++++++++++++++ app/src/main/res/values/strings.xml | 2 + .../ui/bots/group/GroupChatViewModelTest.kt | 30 +++++++++ 4 files changed, 135 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatScreen.kt index 6bfd25e0..34661c08 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatScreen.kt @@ -33,6 +33,7 @@ import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.Description import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.Stop import androidx.compose.material.icons.filled.Terminal import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button @@ -137,6 +138,18 @@ fun GroupChatScreen( }, navigationIcon = NavIcon.Back(onBack), actions = { + if (state.activeSpeaker != null) { + IconButton( + onClick = { viewModel.stopGeneration() }, + modifier = Modifier.testTag("group_chat_stop_button"), + ) { + Icon( + imageVector = Icons.Filled.Stop, + contentDescription = stringResource(R.string.group_chat_action_stop), + tint = MaterialTheme.colorScheme.error, + ) + } + } IconButton( onClick = { showSettingsDialog = true }, modifier = Modifier.testTag("group_chat_settings_button"), @@ -293,20 +306,41 @@ fun GroupChatScreen( Spacer(modifier = Modifier.width(4.dp)) - IconButton( - onClick = handleSend, - enabled = inputFieldValue.text.isNotBlank(), - colors = IconButtonDefaults.filledTonalIconButtonColors(), - modifier = - Modifier - .size(36.dp) - .testTag("group_chat_send_button"), - ) { - Icon( - imageVector = Icons.AutoMirrored.Filled.Send, - contentDescription = "Send", - modifier = Modifier.size(18.dp), - ) + if (state.activeSpeaker != null) { + IconButton( + onClick = { viewModel.stopGeneration() }, + colors = + IconButtonDefaults.filledTonalIconButtonColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer, + ), + modifier = + Modifier + .size(36.dp) + .testTag("group_chat_stop_button"), + ) { + Icon( + imageVector = Icons.Filled.Stop, + contentDescription = stringResource(R.string.group_chat_action_stop), + modifier = Modifier.size(18.dp), + ) + } + } else { + IconButton( + onClick = handleSend, + enabled = inputFieldValue.text.isNotBlank(), + colors = IconButtonDefaults.filledTonalIconButtonColors(), + modifier = + Modifier + .size(36.dp) + .testTag("group_chat_send_button"), + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.Send, + contentDescription = "Send", + modifier = Modifier.size(18.dp), + ) + } } } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatViewModel.kt index fa4d121f..67df208c 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatViewModel.kt @@ -56,6 +56,7 @@ private const val LEASE_STEP_TTL_MS = 45_000L private const val TURN_TIMEOUT_MS = 45_000L private const val HISTORY_LIMIT = 10 private const val CAPPED_SYSTEM_TEXT = "Discussion paused (turn limit reached) — reply to continue" +private const val STOPPED_SYSTEM_TEXT = "Discussion stopped" class GroupChatViewModel( private var groupName: String = "", @@ -767,11 +768,65 @@ class GroupChatViewModel( } } + fun stopGeneration() { + Log.i("GroupChatViewModel", "stopGeneration requested by user") + val interruptedEpoch = UUID.randomUUID().toString() + activeEpoch = interruptedEpoch + + // Send interrupt signal to any actively streaming or in-flight sessions + val activeSessions = activeStreamMsgId.keys.toList() + inFlightTurns.keys.toList() + for (sid in activeSessions.distinct()) { + try { + HermesWsClient.send( + WsMethods.SESSION_INTERRUPT, + mapOf("session_id" to sid), + ) + } catch (e: Exception) { + Log.w("GroupChatViewModel", "Failed to send interrupt for session $sid: ${e.message}") + } + } + + // Clean up transient in-flight tracking + inFlightTurns.values.forEach { it.complete("") } + inFlightTurns.clear() + activeStreamMsgId.clear() + sessionToBot.clear() + + _uiState.update { state -> + // Remove any pending streaming placeholder messages + val cleanedMessages = state.messages.filter { !it.isStreaming } + val stopMessage = + GroupChatMessage( + id = UUID.randomUUID().toString(), + senderName = "system", + senderDisplayName = "System", + isUser = false, + isSystem = true, + text = STOPPED_SYSTEM_TEXT, + ) + state.copy( + messages = cleanedMessages + stopMessage, + activeSpeaker = null, + ) + } + persistSyncSnapshot(_uiState.value.messages, extendLease = false) + } + + private fun isStopCommand(text: String): Boolean { + val normalized = text.trim().lowercase() + return normalized in setOf("/stop", "/interrupt", "/pause", "/cancel", "stop", "/brake") + } + fun sendMessage(rawText: String) { val text = rawText.trim() Log.d("GroupChatViewModel", "sendMessage called with: '$text'") if (text.isBlank()) return + if (isStopCommand(text)) { + stopGeneration() + return + } + val members = _uiState.value.members if (members.isEmpty()) { Log.w("GroupChatViewModel", "sendMessage: No members in group, ignoring") diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f51bb5cd..d086f4ea 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1264,4 +1264,6 @@ Custom rules, tone, or context for this group (optional)… Custom guidelines for all bots in this room Settings + Discussion stopped + Stop generation diff --git a/app/src/test/java/com/m57/hermescontrol/ui/bots/group/GroupChatViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/bots/group/GroupChatViewModelTest.kt index c223f648..a1ca7537 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/bots/group/GroupChatViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/bots/group/GroupChatViewModelTest.kt @@ -486,4 +486,34 @@ class GroupChatViewModelTest { assertEquals(1, finalMsg.toolCalls.size) assertFalse(finalMsg.toolCalls.first().isRunning) } + + @Test + fun testStopCommand_stopsGenerationAndAppendsSystemBanner() = + runTest(testDispatcher) { + val viewModel = GroupChatViewModel("Dev Team", ioDispatcher = testDispatcher) + testScheduler.runCurrent() + + // Trigger a message + viewModel.sendMessage("@scout long task") + testScheduler.runCurrent() + + // Emit a streaming fragment + eventsFlow.emit( + WsEvent.MessageToken( + token = "Thinking about...", + sessionId = "session-scout-1", + ), + ) + testScheduler.runCurrent() + + // Execute /stop command + viewModel.sendMessage("/stop") + testScheduler.runCurrent() + + val state = viewModel.uiState.value + assertNull(state.activeSpeaker) + val lastMsg = state.messages.last() + assertTrue(lastMsg.isSystem) + assertEquals("Discussion stopped", lastMsg.text) + } } From 5c8ab7ed6ca573b8b4d1c6da01128cc161a8eb60 Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Sun, 30 Aug 2026 00:21:02 +0300 Subject: [PATCH 14/14] feat(bots): smart streaming auto-scroll and scroll-to-bottom FAB in group chat (#1013) Co-authored-by: M57 --- .../ui/bots/group/GroupChatScreen.kt | 108 ++++++++++++------ 1 file changed, 73 insertions(+), 35 deletions(-) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatScreen.kt index 34661c08..fc22d011 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatScreen.kt @@ -77,6 +77,9 @@ import androidx.lifecycle.viewmodel.compose.viewModel import com.m57.hermescontrol.R import com.m57.hermescontrol.theme.LocalChatFontScale import com.m57.hermescontrol.ui.chat.MarkdownText +import com.m57.hermescontrol.ui.chat.components.ChatScrollToBottomFab +import com.m57.hermescontrol.ui.chat.components.rememberChatScrollController +import com.m57.hermescontrol.ui.chat.components.tailContentKey import com.m57.hermescontrol.ui.chat.formatTimestamp import com.m57.hermescontrol.ui.common.BotAvatar import com.m57.hermescontrol.ui.common.EmptyState @@ -95,6 +98,8 @@ fun GroupChatScreen( ) { val state by viewModel.uiState.collectAsState() val listState = rememberLazyListState() + val scrollScope = rememberCoroutineScope() + val scrollController = rememberChatScrollController(listState, scrollScope) var inputFieldValue by rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue("")) } @@ -104,6 +109,7 @@ fun GroupChatScreen( val handleSend = { val text = inputFieldValue.text if (text.isNotBlank()) { + scrollController.jumpToBottom() viewModel.sendMessage(text) inputFieldValue = TextFieldValue("") } @@ -113,10 +119,32 @@ fun GroupChatScreen( viewModel.setGroup(groupName) } - LaunchedEffect(state.messages.size) { - if (state.messages.isNotEmpty()) { - listState.animateScrollToItem(state.messages.size - 1) + LaunchedEffect(Unit) { + scrollController.observeUserScrollPosition() + } + + val streamingMessage = state.messages.find { it.isStreaming } + val tailKey = + remember( + state.messages.size, + streamingMessage?.id, + streamingMessage?.text?.length, + state.activeSpeaker, + ) { + tailContentKey( + messages = state.messages, + streamingMessage = streamingMessage?.text, + isThinking = state.activeSpeaker != null, + subagentIndicators = emptyList(), + clarifyRequest = null, + ) } + + LaunchedEffect(tailKey) { + scrollController.onTailChanged( + tailKey = tailKey, + messageCount = state.messages.size, + ) } HermesScaffold( @@ -204,43 +232,53 @@ fun GroupChatScreen( ) } - CompositionLocalProvider(LocalDensity provides chatDensity) { - LazyColumn( - state = listState, - modifier = - Modifier - .weight(1f) - .fillMaxWidth(), - contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - items( - items = state.messages, - key = { it.id }, - ) { message -> - GroupMessageCard(message = message) - } + Box( + modifier = + Modifier + .weight(1f) + .fillMaxWidth(), + ) { + CompositionLocalProvider(LocalDensity provides chatDensity) { + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + items( + items = state.messages, + key = { it.id }, + ) { message -> + GroupMessageCard(message = message) + } - state.activeSpeaker?.let { speaker -> - item(key = "active_speaker") { - Row( - modifier = Modifier.padding(start = 8.dp, top = 4.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - CircularProgressIndicator( - modifier = Modifier.size(14.dp), - strokeWidth = 2.dp, - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = stringResource(R.string.group_chat_thinking, speaker), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.primary, - ) + state.activeSpeaker?.let { speaker -> + item(key = "active_speaker") { + Row( + modifier = Modifier.padding(start = 8.dp, top = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + CircularProgressIndicator( + modifier = Modifier.size(14.dp), + strokeWidth = 2.dp, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(R.string.group_chat_thinking, speaker), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, + ) + } } } } } + + ChatScrollToBottomFab( + show = !scrollController.isFollowingBottom && state.messages.isNotEmpty(), + pendingCount = scrollController.pendingCount, + onScrollToBottom = { scrollController.resumeFollowing() }, + ) } } }