Merge pull request #1014 from Hy4ri/dev

release: Hermes Mobile v1.25.0
This commit is contained in:
M57 authored and GitHub committed 2026-08-30 01:54:36 +03:00
commit 0d5bcba388
41 files changed
+4553 -535

No files matched your search

+5 -5
View File
@@ -4,6 +4,10 @@
Fixes #
## Description
<!-- Explain in your own words what this PR does and how it works at a high level. -->
## Type of Change
- [ ] 🐛 Bug fix
@@ -13,10 +17,6 @@ Fixes #
- [ ] ✅ Tests
- [ ] 🔧 CI / chore
## What changed
<!-- What was wrong and what this PR does about it. Link the root cause if known. -->
## How to test
<!-- Steps to verify. For UI: which screen + which emulator/device API level. For WS: which RPC. -->
@@ -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/)
+8 -1
View File
@@ -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
+2 -1
View File
@@ -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).
+305
View File
@@ -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 `<plurals>` 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.
@@ -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(),
@@ -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<GroupChatKey> { 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()
}
@@ -31,30 +31,37 @@ object NavigationController {
private var nextChatNavigationRequestId = 0L
// Top-level primary screens (Chat, Skills, Cron, System, Settings)
private val primaryScreens: MutableSet<NavKey> =
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)
}
}
}
}
@@ -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(
@@ -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",
@@ -74,6 +74,9 @@ object AuthManager {
private val _themePresetFlow = MutableStateFlow<ThemePreset>(ThemePreset.DEFAULT)
val themePresetFlow: StateFlow<ThemePreset> = _themePresetFlow.asStateFlow()
private val _chatFontScaleFlow = MutableStateFlow<Float>(1.0f)
val chatFontScaleFlow: StateFlow<Float> = _chatFontScaleFlow.asStateFlow()
private val _tokenFlow = MutableStateFlow<String?>(null)
val tokenFlow: StateFlow<String?> = _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). */
@@ -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<GroupChatSyncSnapshot>(element)
} catch (_: Exception) {
null
}
}
val effectiveTitle: String
get() =
botMeta()?.title?.takeIf { it.isNotBlank() }
@@ -101,7 +119,15 @@ data class BotRosterMeta(
val groups: List<String>? = null,
val group: String? = null,
val created: Double? = null,
)
) {
val allGroups: List<String>
get() {
val list = mutableListOf<String>()
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<String, GroupChatRoomMeta>? = null,
val deleted: Map<String, Long>? = null,
) {
fun toMap(): Map<String, Any?> {
val roomsMap = mutableMapOf<String, Any?>()
rooms?.forEach { (key, room) ->
val roomMap = mutableMapOf<String, Any?>()
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<String, Any?> {
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<String, Any?> {
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<String, Any?> {
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<String>? = null,
val members: List<JsonElement>? = null,
val picture: String? = null,
val image: String? = null,
val log: List<GroupChatSyncLogEntry>? = 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<String>
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
@@ -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) {
@@ -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,
@@ -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<GroupInfo?>(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
},
)
}
}
}
}
@@ -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<ProfileInfo>,
val lastActivity: Long = 0L,
)
data class BotsUiState(
@@ -52,17 +56,104 @@ data class BotsUiState(
val allGroups: List<GroupInfo>
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<String, String>()
// 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<ProfileInfo> = 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<GroupInfo> { it.lastActivity }
.thenBy { it.name },
)
}
val displayGroups: List<GroupInfo>
@@ -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()
}
@@ -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(
@@ -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(
@@ -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<GroupChatToolCall> = 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<String>,
)
/**
* Parse `@botname`, `@all`, `@everyone` from text against the member roster.
*/
fun parseMentions(
text: String,
members: List<ProfileInfo>,
excludedSpeaker: String? = null,
): MentionResult {
var isEveryone = false
val mentioned = mutableSetOf<String>()
val handles = mutableMapOf<String, String>()
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<ProfileInfo>,
excludedSpeaker: String? = null,
): List<ProfileInfo> {
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<ProfileInfo>,
recentLog: List<GroupChatMessage>,
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()
}
}
@@ -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<Any>(),
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,
)
}
}
}
}
File diff suppressed because it is too large. Load diff
@@ -694,15 +694,15 @@ internal fun parseBlocks(src: String): List<MdBlock> {
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<MdBlock> {
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<String> {
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<String>,
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]) &&
@@ -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<AgentEntry.Prose>()
.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<AgentEntry.Prose>()
.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() },
)
}
}
}
}
@@ -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(
@@ -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
}
}
@@ -64,6 +64,7 @@ data class SessionsUiState(
val isLoadingMore: Boolean = false,
val sessions: List<SessionInfo> = 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<SessionSearchResult> = 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",
)
}
@@ -191,6 +191,8 @@ internal fun SettingsChatPage(
onTypingEffectEnabledChange = viewModel::onTypingEffectEnabledChange,
typingEffectDelayMs = state.typingEffectDelayMs,
onTypingEffectDelayMsChange = viewModel::onTypingEffectDelayMsChange,
chatFontScale = state.chatFontScale,
onChatFontScaleChange = viewModel::onChatFontScaleChange,
)
}
}
@@ -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<ConnectionProfile> = 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)
@@ -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,
+32
View File
@@ -49,6 +49,7 @@
<string name="billing_topup">충전</string>
<!-- Navigation & Drawer -->
<string name="press_back_again_to_exit">종료하려면 뒤로가기를 한 번 더 누르세요</string>
<string name="nav_drawer_title">헤르메스</string>
<string name="nav_drawer_subtitle">AI 에이전트 제어</string>
<string name="nav_drawer_section_converse">대화</string>
@@ -186,6 +187,17 @@
<string name="settings_item_typing_delay">지연: %1$d ms</string>
<string name="settings_delay_10ms">10 ms</string>
<string name="settings_delay_100ms">100 ms</string>
<string name="settings_item_chat_font_size">채팅 글꼴 크기</string>
<string name="settings_desc_chat_font_size">채팅 메시지의 텍스트 크기를 조절합니다</string>
<string name="settings_font_scale_value">글꼴 크기: %1$s</string>
<string name="settings_font_scale_small">작게 (0.85×)</string>
<string name="settings_font_scale_default">기본 (1.0×)</string>
<string name="settings_font_scale_large">크게 (1.15×)</string>
<string name="settings_font_scale_xlarge">아주 크게 (1.3×)</string>
<string name="settings_font_scale_huge">최대 (1.5×)</string>
<string name="settings_chat_preview_title">미리보기</string>
<string name="settings_chat_preview_user">채팅 텍스트 크기를 조절할 수 있나요?</string>
<string name="settings_chat_preview_agent">완료되었습니다! 여기에서 글꼴 크기를 조정할 수 있습니다.</string>
<string name="settings_action_test_connection">연결 테스트</string>
<string name="settings_sec_about">정보</string>
<string name="settings_about_app_name">헤르메스 모바일</string>
@@ -802,4 +814,24 @@
<item quantity="other">에이전트 %1$d개 실행 중</item>
</plurals>
<string name="task_progress_open_details">작업 세부 정보 열기</string>
<!-- Group Chat Room -->
<string name="group_chat_composer_hint">%1$s에게 메시지 보내기… (@bot으로 멘션)</string>
<string name="group_chat_empty_title">메시지 없음</string>
<string name="group_chat_empty_subtitle">봇을 멘션하거나 @all을 사용하여 협업을 시작하세요.</string>
<string name="group_chat_thinking">%1$s 생각 중…</string>
<string name="group_chat_typing">입력 중…</string>
<string name="group_chat_members_count">멤버 %1$d명</string>
<string name="group_chat_mention_all">@all</string>
<string name="group_chat_capped_limit">대화가 일시중지되었습니다(턴 한도 도달) — 계속하려면 메시지를 보내세요</string>
<string name="group_chat_settings_title">그룹 채팅 설정</string>
<string name="group_chat_settings_max_messages">최대 봇 메시지 수</string>
<string name="group_chat_settings_max_messages_desc">메시지당 최대 봇 응답 한도 (%1$d)</string>
<string name="group_chat_settings_max_handoffs">최대 핸드오프 패스</string>
<string name="group_chat_settings_max_handoffs_desc">봇이 서로를 트리거할 수 있는 최대 횟수 (%1$d)</string>
<string name="group_chat_settings_save">저장</string>
<string name="group_chat_settings_reset_default">기본값으로 초기화</string>
<string name="group_chat_settings_system_prompt">방 지침 / 프롬프트</string>
<string name="group_chat_settings_system_prompt_hint">이 그룹을 위한 사용자 지정 규칙, 어조 또는 컨텍스트 (선택사항)…</string>
<string name="group_chat_settings_system_prompt_desc">이 방의 모든 봇에 적용되는 사용자 지정 가이드라인</string>
<string name="group_chat_action_settings">설정</string>
</resources>
+32
View File
@@ -55,6 +55,7 @@
<string name="billing_credits_remaining">剩余额度:%1$s</string>
<string name="billing_plan">套餐</string>
<string name="billing_topup">充值</string>
<string name="press_back_again_to_exit">再次返回以退出应用</string>
<string name="nav_drawer_title">Hermes</string>
<string name="nav_drawer_subtitle">AI Agent 控制台</string>
<string name="nav_drawer_section_converse">对话</string>
@@ -183,6 +184,17 @@
<string name="settings_item_typing_delay">延迟:%1$d 毫秒</string>
<string name="settings_delay_10ms">10 毫秒</string>
<string name="settings_delay_100ms">100 毫秒</string>
<string name="settings_item_chat_font_size">聊天字体大小</string>
<string name="settings_desc_chat_font_size">缩放聊天消息文字大小</string>
<string name="settings_font_scale_value">字体大小:%1$s</string>
<string name="settings_font_scale_small">小 (0.85×)</string>
<string name="settings_font_scale_default">默认 (1.0×)</string>
<string name="settings_font_scale_large">大 (1.15×)</string>
<string name="settings_font_scale_xlarge">特大 (1.3×)</string>
<string name="settings_font_scale_huge">极大 (1.5×)</string>
<string name="settings_chat_preview_title">预览</string>
<string name="settings_chat_preview_user">可以调整聊天字体大小吗?</string>
<string name="settings_chat_preview_agent">已搞定!你可以在这里自定义字体缩放比例。</string>
<string name="settings_action_test_connection">测试连接</string>
<string name="settings_sec_about">关于</string>
<string name="settings_sec_language">语言</string>
@@ -1101,4 +1113,24 @@
<!-- system action toast templates -->
<string name="system_action_started">%1$s 已开始</string>
<string name="system_action_failed">%1$s 失败:%2$s</string>
<!-- Group Chat Room -->
<string name="group_chat_composer_hint">发消息给 %1$s…(使用 @bot 提及)</string>
<string name="group_chat_empty_title">暂无消息</string>
<string name="group_chat_empty_subtitle">提及某个 Bot 或使用 @all 开始协同。</string>
<string name="group_chat_thinking">%1$s 正在思考…</string>
<string name="group_chat_typing">正在输入…</string>
<string name="group_chat_members_count">%1$d 个成员</string>
<string name="group_chat_mention_all">@all</string>
<string name="group_chat_capped_limit">讨论已暂停(达到轮次上限)— 发送消息继续</string>
<string name="group_chat_settings_title">群聊设置</string>
<string name="group_chat_settings_max_messages">最大 Bot 回复数</string>
<string name="group_chat_settings_max_messages_desc">单次发送的最大 Bot 回复总数 (%1$d)</string>
<string name="group_chat_settings_max_handoffs">最大交接轮次</string>
<string name="group_chat_settings_max_handoffs_desc">Bot 间互相触发的最大次数 (%1$d)</string>
<string name="group_chat_settings_save">保存</string>
<string name="group_chat_settings_reset_default">重置为默认</string>
<string name="group_chat_settings_system_prompt">房间指令 / 提示词</string>
<string name="group_chat_settings_system_prompt_hint">此群聊的自定义规则、语气或上下文(可选)…</string>
<string name="group_chat_settings_system_prompt_desc">适用于此房间内所有 Bot 的自定义准则</string>
<string name="group_chat_action_settings">设置</string>
</resources>
+34
View File
@@ -66,6 +66,7 @@
<string name="billing_topup">Top-up</string>
<!-- Navigation & Drawer -->
<string name="press_back_again_to_exit">Press back again to exit</string>
<string name="nav_drawer_title">Hermes</string>
<string name="nav_drawer_subtitle">AI Agent Control</string>
<string name="nav_drawer_section_converse">Converse</string>
@@ -206,6 +207,17 @@
<string name="settings_item_typing_delay">Delay: %1$d ms</string>
<string name="settings_delay_10ms">10 ms</string>
<string name="settings_delay_100ms">100 ms</string>
<string name="settings_item_chat_font_size">Chat Font Size</string>
<string name="settings_desc_chat_font_size">Scale text size in chat messages</string>
<string name="settings_font_scale_value">Font Size: %1$s</string>
<string name="settings_font_scale_small">Small (0.85×)</string>
<string name="settings_font_scale_default">Default (1.0×)</string>
<string name="settings_font_scale_large">Large (1.15×)</string>
<string name="settings_font_scale_xlarge">Extra Large (1.3×)</string>
<string name="settings_font_scale_huge">Huge (1.5×)</string>
<string name="settings_chat_preview_title">Preview</string>
<string name="settings_chat_preview_user">Can you adjust the chat text size?</string>
<string name="settings_chat_preview_agent">Done! You can customize the font scale right here.</string>
<string name="settings_action_test_connection">Test Connection</string>
<string name="settings_sec_about">About</string>
<string name="settings_sec_language">Language</string>
@@ -1232,4 +1244,26 @@
<!-- system action toast templates -->
<string name="system_action_started">%1$s started</string>
<string name="system_action_failed">%1$s failed: %2$s</string>
<!-- Group Chat Room -->
<string name="group_chat_composer_hint">Message %1$s… (use @bot to mention)</string>
<string name="group_chat_empty_title">No Messages Yet</string>
<string name="group_chat_empty_subtitle">Mention a bot or use @all to kick off collaboration.</string>
<string name="group_chat_thinking">%1$s is thinking…</string>
<string name="group_chat_typing">Typing…</string>
<string name="group_chat_members_count">%1$d members</string>
<string name="group_chat_mention_all">@all</string>
<string name="group_chat_capped_limit">Discussion paused (turn limit reached) — reply to continue</string>
<string name="group_chat_settings_title">Group Chat Settings</string>
<string name="group_chat_settings_max_messages">Max Bot Messages</string>
<string name="group_chat_settings_max_messages_desc">Ceiling on total bot replies per message (%1$d)</string>
<string name="group_chat_settings_max_handoffs">Max Handoff Passes</string>
<string name="group_chat_settings_max_handoffs_desc">How many times bots can trigger each other (%1$d)</string>
<string name="group_chat_settings_save">Save</string>
<string name="group_chat_settings_reset_default">Reset to Default</string>
<string name="group_chat_settings_system_prompt">Room Instructions / Prompt</string>
<string name="group_chat_settings_system_prompt_hint">Custom rules, tone, or context for this group (optional)…</string>
<string name="group_chat_settings_system_prompt_desc">Custom guidelines for all bots in this room</string>
<string name="group_chat_action_settings">Settings</string>
<string name="group_chat_stopped_by_user">Discussion stopped</string>
<string name="group_chat_action_stop">Stop generation</string>
</resources>
@@ -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) ────────────────
@@ -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<NavKey>(SkillsScreen)
val backStack = NavBackStack<NavKey>(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<NavKey>(ProfilesScreen)
fun `navigateTo on same subscreen is a no-op`() {
val backStack = NavBackStack<NavKey>(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<NavKey>(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<NavKey>(ChatScreen)
fun `navigateTo ChatScreen clears stack to single ChatScreen root`() {
val backStack = NavBackStack<NavKey>(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<NavKey>(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<NavKey>(ChatScreen)
fun `navigateTo on a subscreen appends to the current stack`() {
val backStack = NavBackStack<NavKey>(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<NavKey>(ChatScreen)
val backStack = NavBackStack<NavKey>(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<NavKey>(ChatScreen)
val backStack = NavBackStack<NavKey>(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<NavKey>(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<NavKey>(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))
}
}
@@ -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)
}
@@ -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<ProfileInfo>(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<BotRosterMeta>(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<ProfileInfo>(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<GroupChatSyncSnapshot>(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"))
}
}
@@ -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)
}
}
@@ -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)
}
}
@@ -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<WsEvent>()
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<String>()) } 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<Any?>()
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)
}
}
@@ -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"))
}
}
@@ -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)
}
@@ -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