41 files changed
+4553
-535
No files matched your search
@@ -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/)
|
||||
|
||||
@@ -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
@@ -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).
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user