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/)
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.
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/Navigation.kt b/app/src/main/java/com/m57/hermescontrol/Navigation.kt
index 5f8d5530..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
@@ -155,6 +160,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
@@ -183,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/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/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/data/model/Profile.kt b/app/src/main/java/com/m57/hermescontrol/data/model/Profile.kt
index c2fcdff2..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
@@ -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(
@@ -46,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() }
@@ -101,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(
@@ -117,16 +143,125 @@ 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.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 {
+ 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 GroupChatRoomLease(
+ val driverId: String? = null,
+ val epoch: String? = null,
+ val expiresAt: Long? = null,
)
@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 syncRevision: Long? = null,
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() =
+ members?.mapNotNull { el ->
+ when (el) {
+ is JsonPrimitive -> el.content
+ is JsonObject -> el["name"]?.jsonPrimitive?.content ?: el["handle"]?.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/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/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/BotsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/bots/BotsScreen.kt
index 8a18a0ce..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,131 +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()) {
- 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,
- )
- }
+ 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
- },
- )
+ },
+ onDisbandClick = {
+ disbandingGroup = group
+ },
+ )
+ }
}
}
}
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/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..b6eb32da
--- /dev/null
+++ b/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatModels.kt
@@ -0,0 +1,184 @@
+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.
+ */
+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,
+ val isSystem: Boolean = false,
+ val toolCalls: List = emptyList(),
+)
+
+/**
+ * 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,
+ )
+
+ /**
+ * Parse `@botname`, `@all`, `@everyone` from text against the member roster.
+ */
+ fun parseMentions(
+ text: String,
+ members: List,
+ excludedSpeaker: String? = null,
+ ): MentionResult {
+ var isEveryone = false
+ val mentioned = mutableSetOf()
+
+ val handles = mutableMapOf()
+ for (member in members) {
+ 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)
+ }
+ }
+ }
+
+ 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,
+ excludedSpeaker: String? = null,
+ ): List {
+ val parsed = parseMentions(text, members, excludedSpeaker)
+ return if (parsed.isEveryone || parsed.mentionedBots.isEmpty()) {
+ if (excludedSpeaker != null) {
+ members.filter { !it.name.equals(excludedSpeaker, ignoreCase = true) }
+ } else {
+ 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,
+ historyLimit: Int = 10,
+ customRoomPrompt: String? = null,
+ ): String {
+ 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.filter { !it.isSystem }.takeLast(historyLimit).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 @${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."
+ }
+
+ 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:
+$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
new file mode 100644
index 00000000..fc22d011
--- /dev/null
+++ b/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatScreen.kt
@@ -0,0 +1,811 @@
+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
+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.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
+import androidx.compose.material.icons.filled.Stop
+import androidx.compose.material.icons.filled.Terminal
+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
+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
+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
+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
+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
+fun GroupChatScreen(
+ groupName: String,
+ onBack: () -> Unit,
+ modifier: Modifier = Modifier,
+ viewModel: GroupChatViewModel = viewModel { GroupChatViewModel() },
+) {
+ 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(""))
+ }
+ var isFocused by remember { mutableStateOf(false) }
+ var showSettingsDialog by remember { mutableStateOf(false) }
+
+ val handleSend = {
+ val text = inputFieldValue.text
+ if (text.isNotBlank()) {
+ scrollController.jumpToBottom()
+ viewModel.sendMessage(text)
+ inputFieldValue = TextFieldValue("")
+ }
+ }
+
+ LaunchedEffect(groupName) {
+ viewModel.setGroup(groupName)
+ }
+
+ 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(
+ 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),
+ 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"),
+ ) {
+ Icon(
+ imageVector = Icons.Filled.Settings,
+ contentDescription = stringResource(R.string.group_chat_action_settings),
+ )
+ }
+ },
+ 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 -> {
+ val currentDensity = LocalDensity.current
+ val chatFontScale = LocalChatFontScale.current
+ val chatDensity =
+ remember(currentDensity, chatFontScale) {
+ Density(
+ density = currentDensity.density,
+ fontScale = currentDensity.fontScale * chatFontScale,
+ )
+ }
+
+ 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,
+ )
+ }
+ }
+ }
+ }
+ }
+
+ ChatScrollToBottomFab(
+ show = !scrollController.isFollowingBottom && state.messages.isNotEmpty(),
+ pendingCount = scrollController.pendingCount,
+ onScrollToBottom = { scrollController.resumeFollowing() },
+ )
+ }
+ }
+ }
+
+ // ── 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))
+
+ 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),
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+
+ 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
+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),
+ 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,
+ ) {
+ Surface(
+ shape = RoundedCornerShape(16.dp),
+ color = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.fillMaxWidth(0.85f),
+ ) {
+ Column(modifier = Modifier.padding(12.dp)) {
+ 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,
+ )
+ }
+ }
+ }
+ }
+ } 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,
+ )
+ 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() && message.toolCalls.none { it.isRunning }) {
+ 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()) {
+ 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,
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+@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
new file mode 100644
index 00000000..67df208c
--- /dev/null
+++ b/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatViewModel.kt
@@ -0,0 +1,1090 @@
+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
+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.JsonPrimitive
+import kotlinx.serialization.json.decodeFromJsonElement
+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,
+ val maxBotMessages: Int = DEFAULT_MAX_BOT_MESSAGES,
+ val maxContinuationPasses: Int = DEFAULT_MAX_CONTINUATION_PASSES,
+ val systemPrompt: String? = null,
+)
+
+data class MemberSession(
+ val runtimeSessionId: String,
+ 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"
+private const val STOPPED_SYSTEM_TEXT = "Discussion stopped"
+
+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()
+
+ 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()
+
+ // 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
+ activeEpoch = UUID.randomUUID().toString()
+ memberWatermarks.clear()
+ memberSessions.clear()
+ inFlightTurns.clear()
+ activeStreamMsgId.clear()
+ sessionToBot.clear()
+ _uiState.value = GroupChatUiState(groupName = name)
+ 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 ->
+ 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.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) {
+ 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()) {
+ // 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()?.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(",")) {
+ 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 }}",
+ )
+
+ var initialMessages = _uiState.value.messages
+ if (initialMessages.isEmpty() && syncSnapshot?.rooms != null) {
+ 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) ||
+ it.effectiveTitle.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")
+ }
+ }
+
+ 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,
+ )
+ }
+ } else {
+ _uiState.update {
+ it.copy(
+ isLoading = false,
+ errorMessage = "Failed to load group members",
+ )
+ }
+ }
+ }
+ }
+
+ 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"
+ 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
+ }
+
+ 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()) {
+ _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 }
+ } 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 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")
+ return
+ }
+
+ val epoch = UUID.randomUUID().toString()
+ activeEpoch = epoch
+
+ 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, extendLease = true)
+
+ viewModelScope.launch(ioDispatcher) {
+ val allProfiles = fetchProfiles()
+ val defaultProfile =
+ allProfiles.find { it.is_default == true || it.name == "default" }
+ ?: allProfiles.firstOrNull()
+
+ if (defaultProfile != null) {
+ val hasLease = acquireOrVerifyLease(defaultProfile, epoch)
+ if (!hasLease) {
+ Log.i("GroupChatViewModel", "Passive mode active — skipping local turn drive")
+ return@launch
+ }
+ }
+
+ var postedBotMessages = 0
+ var continuationsCount = 0
+
+ val maxBotLimit = _uiState.value.maxBotMessages
+ val maxPassLimit = _uiState.value.maxContinuationPasses
+
+ val initialResponders = GroupChatMentions.resolveResponders(text, members).toMutableList()
+ val remainingInitial = initialResponders.toMutableList()
+
+ // ── Round 1: Initial Turn ──
+ while (remainingInitial.isNotEmpty()) {
+ if (activeEpoch != epoch) return@launch
+ if (postedBotMessages >= maxBotLimit) break
+
+ val bot = remainingInitial.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)
+ }
+ }
+
+ // ── Reactive Continuation Passes ──
+ while (continuationsCount < maxPassLimit && postedBotMessages < maxBotLimit) {
+ if (activeEpoch != epoch) return@launch
+
+ 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 (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 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()
+ val defaultProfile =
+ allProfiles.find { it.is_default == true || it.name == "default" } ?: return@launch
+ val existingSnapshot =
+ defaultProfile.groupChatSyncSnapshot()
+ ?: GroupChatSyncSnapshot(version = 3, rooms = emptyMap())
+
+ val logEntries =
+ currentMessages.filter { !it.isSystem }.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 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 = now)).copy(
+ name = groupName,
+ members = _uiState.value.members.map { JsonPrimitive(it.name) },
+ log = logEntries,
+ lease = updatedLease,
+ 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()
+ } catch (e: Exception) {
+ Log.w("GroupChatViewModel", "persistSyncSnapshot failed: ${e.message}")
+ }
+ }
+ }
+
+ private fun isPass(text: String): Boolean {
+ val clean = text.trim().lowercase()
+ return clean == "(pass)" || clean == "pass" || 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/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/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/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/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/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 b7afce43..d43eff7c 100644
--- a/app/src/main/res/values-ko/strings.xml
+++ b/app/src/main/res/values-ko/strings.xml
@@ -49,6 +49,7 @@
충전
+ 종료하려면 뒤로가기를 한 번 더 누르세요
헤르메스
AI 에이전트 제어
대화
@@ -186,6 +187,17 @@
지연: %1$d ms
10 ms
100 ms
+ 채팅 글꼴 크기
+ 채팅 메시지의 텍스트 크기를 조절합니다
+ 글꼴 크기: %1$s
+ 작게 (0.85×)
+ 기본 (1.0×)
+ 크게 (1.15×)
+ 아주 크게 (1.3×)
+ 최대 (1.5×)
+ 미리보기
+ 채팅 텍스트 크기를 조절할 수 있나요?
+ 완료되었습니다! 여기에서 글꼴 크기를 조정할 수 있습니다.
연결 테스트
정보
헤르메스 모바일
@@ -802,4 +814,24 @@
- 에이전트 %1$d개 실행 중
작업 세부 정보 열기
+
+ %1$s에게 메시지 보내기… (@bot으로 멘션)
+ 메시지 없음
+ 봇을 멘션하거나 @all을 사용하여 협업을 시작하세요.
+ %1$s 생각 중…
+ 입력 중…
+ 멤버 %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 cee14cbc..6ee20c4a 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 控制台
对话
@@ -183,6 +184,17 @@
延迟:%1$d 毫秒
10 毫秒
100 毫秒
+ 聊天字体大小
+ 缩放聊天消息文字大小
+ 字体大小:%1$s
+ 小 (0.85×)
+ 默认 (1.0×)
+ 大 (1.15×)
+ 特大 (1.3×)
+ 极大 (1.5×)
+ 预览
+ 可以调整聊天字体大小吗?
+ 已搞定!你可以在这里自定义字体缩放比例。
测试连接
关于
语言
@@ -1101,4 +1113,24 @@
%1$s 已开始
%1$s 失败:%2$s
+
+ 发消息给 %1$s…(使用 @bot 提及)
+ 暂无消息
+ 提及某个 Bot 或使用 @all 开始协同。
+ %1$s 正在思考…
+ 正在输入…
+ %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 b4466c30..d086f4ea 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
@@ -206,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
@@ -1232,4 +1244,26 @@
%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
+ 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
+ Discussion stopped
+ Stop generation
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))
}
}
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/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/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)
+ }
}
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..a1ca7537
--- /dev/null
+++ b/app/src/test/java/com/m57/hermescontrol/ui/bots/group/GroupChatViewModelTest.kt
@@ -0,0 +1,519 @@
+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
+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)
+ }
+
+ @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)"))
+ }
+
+ @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)
+ }
+
+ @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)
+ }
+}
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"))
+ }
}
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)
}
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