41 files changed
+4553
-535
No files matched your search
@@ -4,6 +4,10 @@
|
|||||||
|
|
||||||
Fixes #
|
Fixes #
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
<!-- Explain in your own words what this PR does and how it works at a high level. -->
|
||||||
|
|
||||||
## Type of Change
|
## Type of Change
|
||||||
|
|
||||||
- [ ] 🐛 Bug fix
|
- [ ] 🐛 Bug fix
|
||||||
@@ -13,10 +17,6 @@ Fixes #
|
|||||||
- [ ] ✅ Tests
|
- [ ] ✅ Tests
|
||||||
- [ ] 🔧 CI / chore
|
- [ ] 🔧 CI / chore
|
||||||
|
|
||||||
## What changed
|
|
||||||
|
|
||||||
<!-- What was wrong and what this PR does about it. Link the root cause if known. -->
|
|
||||||
|
|
||||||
## How to test
|
## How to test
|
||||||
|
|
||||||
<!-- Steps to verify. For UI: which screen + which emulator/device API level. For WS: which RPC. -->
|
<!-- Steps to verify. For UI: which screen + which emulator/device API level. For WS: which RPC. -->
|
||||||
@@ -27,7 +27,7 @@ Fixes #
|
|||||||
|
|
||||||
## Checklist
|
## Checklist
|
||||||
|
|
||||||
- [ ] Branch rebased onto `main` (`git rebase origin/main`)
|
- [ ] Branch rebased onto `dev` (`git rebase origin/dev`)
|
||||||
- [ ] `./gradlew ktlintCheck` passes (ran `ktlintFormat` first)
|
- [ ] `./gradlew ktlintCheck` passes (ran `ktlintFormat` first)
|
||||||
- [ ] `./gradlew testDebugUnitTest` green (or CI unit-tests job)
|
- [ ] `./gradlew testDebugUnitTest` green (or CI unit-tests job)
|
||||||
- [ ] `checkColorLiterals` passes (no hardcoded Color outside theme/)
|
- [ ] `checkColorLiterals` passes (no hardcoded Color outside theme/)
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
# AGENTS.md
|
# AGENTS.md
|
||||||
|
|
||||||
Guidance for AI coding agents working on this repository.
|
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
|
## Project Overview
|
||||||
|
|
||||||
@@ -311,6 +316,8 @@ com.m57.hermescontrol/
|
|||||||
## Further Reading
|
## Further Reading
|
||||||
|
|
||||||
- [README.md](README.md) — human-facing overview, features, screenshots, tech stack
|
- [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
|
- [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/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
|
- [.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:
|
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.
|
- **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.
|
- [ ] No unused imports, unused parameters, or dead code.
|
||||||
- [ ] Every `Image` and `Icon` element has a descriptive `contentDescription` for accessibility.
|
- [ ] Every `Image` and `Icon` element has a descriptive `contentDescription` for accessibility.
|
||||||
- [ ] New screens use `HermesScaffold` and implement Loading/Error/Empty states.
|
- [ ] 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).
|
- [ ] 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).
|
- [ ] 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 themePreference by AuthManager.themePreferenceFlow.collectAsState()
|
||||||
val useDynamicColors by AuthManager.useDynamicColorsFlow.collectAsState()
|
val useDynamicColors by AuthManager.useDynamicColorsFlow.collectAsState()
|
||||||
val themePreset by AuthManager.themePresetFlow.collectAsState()
|
val themePreset by AuthManager.themePresetFlow.collectAsState()
|
||||||
|
val chatFontScale by AuthManager.chatFontScaleFlow.collectAsState()
|
||||||
HermesControlTheme(
|
HermesControlTheme(
|
||||||
themePreference = themePreference,
|
themePreference = themePreference,
|
||||||
useDynamicColors = useDynamicColors,
|
useDynamicColors = useDynamicColors,
|
||||||
themePreset = themePreset,
|
themePreset = themePreset,
|
||||||
|
chatFontScale = chatFontScale,
|
||||||
) {
|
) {
|
||||||
Surface(
|
Surface(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package com.m57.hermescontrol
|
package com.m57.hermescontrol
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.widget.Toast
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
@@ -31,10 +33,13 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableLongStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -155,6 +160,14 @@ private fun appEntryProvider(
|
|||||||
onBack = { NavigationController.goBack() },
|
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
|
@Composable
|
||||||
@@ -183,7 +196,25 @@ fun MainNavigation(sessionId: String? = null) {
|
|||||||
|
|
||||||
val openDrawer: () -> Unit = { scope.launch { drawerState.open() } }
|
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) {
|
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()
|
NavigationController.goBack()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,30 +31,37 @@ object NavigationController {
|
|||||||
|
|
||||||
private var nextChatNavigationRequestId = 0L
|
private var nextChatNavigationRequestId = 0L
|
||||||
|
|
||||||
// Top-level primary screens (Chat, Skills, Cron, System, Settings)
|
/**
|
||||||
private val primaryScreens: MutableSet<NavKey> =
|
* Top-level primary screens (all drawer-accessible screens).
|
||||||
mutableSetOf(
|
* Navigating to any of these clears the stack to `[ChatScreen, key]` (or `[ChatScreen]` for Chat),
|
||||||
ChatScreen,
|
* ensuring swiping back returns to ChatScreen.
|
||||||
SkillsScreen,
|
*/
|
||||||
CronJobsScreen,
|
fun isPrimaryScreen(key: NavKey): Boolean = key == ChatScreen || ScreenRegistry.ALL_SCREENS.any { it.key == key }
|
||||||
SystemScreen,
|
|
||||||
SettingsScreen,
|
|
||||||
)
|
|
||||||
|
|
||||||
/** Returns whether the given key is a primary top-level screen. */
|
|
||||||
fun isPrimaryScreen(key: NavKey): Boolean = key in primaryScreens
|
|
||||||
|
|
||||||
fun navigateTo(key: NavKey) {
|
fun navigateTo(key: NavKey) {
|
||||||
val stack = backStack ?: return
|
val stack = backStack ?: return
|
||||||
if (stack.lastOrNull() == key) 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)) {
|
if (isPrimaryScreen(key)) {
|
||||||
stack.clear()
|
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
|
// Subscreen / drill-down navigation: append to current stack
|
||||||
// drawerGesturesEnabled=false and the controller closes the drawer
|
|
||||||
// itself via SideEffect. No synchronous closeDrawer callback here.
|
|
||||||
stack.add(key)
|
stack.add(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,15 +105,13 @@ object NavigationController {
|
|||||||
*/
|
*/
|
||||||
fun goBack(fallback: NavKey = ChatScreen) {
|
fun goBack(fallback: NavKey = ChatScreen) {
|
||||||
val stack = backStack ?: return
|
val stack = backStack ?: return
|
||||||
if (stack.lastOrNull() == ChatScreen) {
|
|
||||||
resetTo(HistoryScreen)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (stack.size > 1) {
|
if (stack.size > 1) {
|
||||||
stack.removeLastOrNull()
|
stack.removeLastOrNull()
|
||||||
} else if (stack.size == 1) {
|
} else if (stack.size == 1) {
|
||||||
stack.clear()
|
if (stack.lastOrNull() != fallback && stack.lastOrNull() != LandingScreen) {
|
||||||
stack.add(fallback)
|
stack.clear()
|
||||||
|
stack.add(fallback)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -23,6 +23,10 @@ import kotlinx.serialization.Serializable
|
|||||||
|
|
||||||
@Serializable data object BotsScreen : NavKey
|
@Serializable data object BotsScreen : NavKey
|
||||||
|
|
||||||
|
@Serializable data class GroupChatKey(
|
||||||
|
val groupName: String,
|
||||||
|
) : NavKey
|
||||||
|
|
||||||
@Serializable data object ToolsetsScreen : NavKey
|
@Serializable data object ToolsetsScreen : NavKey
|
||||||
|
|
||||||
@Serializable data class ToolsetDetailKey(
|
@Serializable data class ToolsetDetailKey(
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ data class ServerStoreState(
|
|||||||
val wsAuthParam: String = "token",
|
val wsAuthParam: String = "token",
|
||||||
val typingEffectEnabled: Boolean = true,
|
val typingEffectEnabled: Boolean = true,
|
||||||
val typingEffectDelayMs: Int = 30,
|
val typingEffectDelayMs: Int = 30,
|
||||||
|
val chatFontScale: Float = 1.0f,
|
||||||
// App display language. "system" = follow device locale; otherwise a BCP-47
|
// App display language. "system" = follow device locale; otherwise a BCP-47
|
||||||
// language code such as "en" or "ko". Applied via ContextWrapper in MainActivity.
|
// language code such as "en" or "ko". Applied via ContextWrapper in MainActivity.
|
||||||
val appLanguage: String = "system",
|
val appLanguage: String = "system",
|
||||||
|
|||||||
@@ -74,6 +74,9 @@ object AuthManager {
|
|||||||
private val _themePresetFlow = MutableStateFlow<ThemePreset>(ThemePreset.DEFAULT)
|
private val _themePresetFlow = MutableStateFlow<ThemePreset>(ThemePreset.DEFAULT)
|
||||||
val themePresetFlow: StateFlow<ThemePreset> = _themePresetFlow.asStateFlow()
|
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)
|
private val _tokenFlow = MutableStateFlow<String?>(null)
|
||||||
val tokenFlow: StateFlow<String?> = _tokenFlow.asStateFlow()
|
val tokenFlow: StateFlow<String?> = _tokenFlow.asStateFlow()
|
||||||
|
|
||||||
@@ -180,6 +183,7 @@ object AuthManager {
|
|||||||
_themePreferenceFlow.value = state.themePreference
|
_themePreferenceFlow.value = state.themePreference
|
||||||
_useDynamicColorsFlow.value = state.useDynamicColors
|
_useDynamicColorsFlow.value = state.useDynamicColors
|
||||||
_themePresetFlow.value = state.themePreset
|
_themePresetFlow.value = state.themePreset
|
||||||
|
_chatFontScaleFlow.value = state.chatFontScale
|
||||||
// B7 (Jul 08 2026, kanban t_470): keep cookie scope aligned with active profile.
|
// B7 (Jul 08 2026, kanban t_470): keep cookie scope aligned with active profile.
|
||||||
appScope?.launch { syncCookieStoreForProfile(state.selectedProfileId) }
|
appScope?.launch { syncCookieStoreForProfile(state.selectedProfileId) }
|
||||||
}
|
}
|
||||||
@@ -640,6 +644,15 @@ object AuthManager {
|
|||||||
serverStore.update { it.copy(typingEffectDelayMs = delayMs) }
|
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) ─────────────────────────────────
|
// ── In-app update check (issue #867) ─────────────────────────────────
|
||||||
|
|
||||||
/** App version the silent update check last completed for (null = never). */
|
/** 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.Serializable
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import kotlinx.serialization.json.JsonElement
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
import kotlinx.serialization.json.decodeFromJsonElement
|
import kotlinx.serialization.json.decodeFromJsonElement
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class ProfilesResponse(
|
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
|
val effectiveTitle: String
|
||||||
get() =
|
get() =
|
||||||
botMeta()?.title?.takeIf { it.isNotBlank() }
|
botMeta()?.title?.takeIf { it.isNotBlank() }
|
||||||
@@ -101,7 +119,15 @@ data class BotRosterMeta(
|
|||||||
val groups: List<String>? = null,
|
val groups: List<String>? = null,
|
||||||
val group: String? = null,
|
val group: String? = null,
|
||||||
val created: Double? = 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
|
@Serializable
|
||||||
data class BotAvatarMeta(
|
data class BotAvatarMeta(
|
||||||
@@ -117,16 +143,125 @@ data class GroupChatSyncSnapshot(
|
|||||||
val updatedAt: Long? = null,
|
val updatedAt: Long? = null,
|
||||||
val rooms: Map<String, GroupChatRoomMeta>? = null,
|
val rooms: Map<String, GroupChatRoomMeta>? = null,
|
||||||
val deleted: Map<String, Long>? = 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
|
@Serializable
|
||||||
data class GroupChatRoomMeta(
|
data class GroupChatRoomMeta(
|
||||||
val id: String,
|
val id: String? = null,
|
||||||
|
val roomId: String? = null,
|
||||||
val name: String? = null,
|
val name: String? = null,
|
||||||
val members: List<String>? = null,
|
val members: List<JsonElement>? = null,
|
||||||
val picture: String? = 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 updatedAt: Long? = null,
|
||||||
val createdAt: 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
|
@Serializable
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package com.m57.hermescontrol.data.model
|
|||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.json.JsonElement
|
import kotlinx.serialization.json.JsonElement
|
||||||
import kotlinx.serialization.json.JsonPrimitive
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import kotlinx.serialization.json.doubleOrNull
|
||||||
|
import kotlinx.serialization.json.longOrNull
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class SessionMessagesResponse(
|
data class SessionMessagesResponse(
|
||||||
@@ -52,6 +54,20 @@ data class SessionMessage(
|
|||||||
val timestampText: String?
|
val timestampText: String?
|
||||||
get() = (timestamp as? JsonPrimitive)?.content
|
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
|
val contentText: String
|
||||||
get() =
|
get() =
|
||||||
when (content) {
|
when (content) {
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ enum class ThemePreset { DEFAULT, MONOCHROME, GRUVBOX, CATPPUCCIN, AMOLED, NORD
|
|||||||
|
|
||||||
val LocalThemePreference = compositionLocalOf { ThemePreference.SYSTEM }
|
val LocalThemePreference = compositionLocalOf { ThemePreference.SYSTEM }
|
||||||
val LocalThemePreset = compositionLocalOf { ThemePreset.DEFAULT }
|
val LocalThemePreset = compositionLocalOf { ThemePreset.DEFAULT }
|
||||||
|
val LocalChatFontScale = compositionLocalOf { 1.0f }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The 6 preset themes — one file each, all built from the same
|
* The 6 preset themes — one file each, all built from the same
|
||||||
@@ -75,6 +76,7 @@ fun HermesControlTheme(
|
|||||||
themePreference: ThemePreference = LocalThemePreference.current,
|
themePreference: ThemePreference = LocalThemePreference.current,
|
||||||
useDynamicColors: Boolean = true,
|
useDynamicColors: Boolean = true,
|
||||||
themePreset: ThemePreset = ThemePreset.DEFAULT,
|
themePreset: ThemePreset = ThemePreset.DEFAULT,
|
||||||
|
chatFontScale: Float = LocalChatFontScale.current,
|
||||||
content: @Composable () -> Unit,
|
content: @Composable () -> Unit,
|
||||||
) {
|
) {
|
||||||
val darkTheme =
|
val darkTheme =
|
||||||
@@ -103,6 +105,7 @@ fun HermesControlTheme(
|
|||||||
CompositionLocalProvider(
|
CompositionLocalProvider(
|
||||||
LocalThemePreference provides themePreference,
|
LocalThemePreference provides themePreference,
|
||||||
LocalThemePreset provides themePreset,
|
LocalThemePreset provides themePreset,
|
||||||
|
LocalChatFontScale provides chatFontScale,
|
||||||
LocalHermesStatusColors provides statusColors,
|
LocalHermesStatusColors provides statusColors,
|
||||||
LocalSpacing provides SpacingDefaults,
|
LocalSpacing provides SpacingDefaults,
|
||||||
LocalMotion provides MotionDefaults,
|
LocalMotion provides MotionDefaults,
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package com.m57.hermescontrol.ui.bots
|
package com.m57.hermescontrol.ui.bots
|
||||||
|
|
||||||
import androidx.compose.foundation.clickable
|
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.horizontalScroll
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
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.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
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.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
@@ -55,6 +59,8 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
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.platform.testTag
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
@@ -82,6 +88,7 @@ fun BotsScreen(
|
|||||||
viewModel: BotsViewModel = viewModel { BotsViewModel() },
|
viewModel: BotsViewModel = viewModel { BotsViewModel() },
|
||||||
) {
|
) {
|
||||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
val pagerState = rememberPagerState(initialPage = state.selectedTab.ordinal) { BotsTab.entries.size }
|
||||||
var isSearchActive by remember { mutableStateOf(false) }
|
var isSearchActive by remember { mutableStateOf(false) }
|
||||||
var showCreateDialog by remember { mutableStateOf(false) }
|
var showCreateDialog by remember { mutableStateOf(false) }
|
||||||
var showCreateGroupDialog by remember { mutableStateOf(false) }
|
var showCreateGroupDialog by remember { mutableStateOf(false) }
|
||||||
@@ -89,6 +96,19 @@ fun BotsScreen(
|
|||||||
var disbandingGroup by remember { mutableStateOf<GroupInfo?>(null) }
|
var disbandingGroup by remember { mutableStateOf<GroupInfo?>(null) }
|
||||||
val scope = rememberCoroutineScope()
|
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) {
|
LaunchedEffect(Unit) {
|
||||||
viewModel.loadBots()
|
viewModel.loadBots()
|
||||||
}
|
}
|
||||||
@@ -176,6 +196,7 @@ fun BotsScreen(
|
|||||||
|
|
||||||
HermesScaffold(
|
HermesScaffold(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
|
drawerGesturesEnabled = pagerState.currentPage == 0,
|
||||||
title = {
|
title = {
|
||||||
if (isSearchActive) {
|
if (isSearchActive) {
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
@@ -270,18 +291,26 @@ fun BotsScreen(
|
|||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
PrimaryTabRow(
|
PrimaryTabRow(
|
||||||
selectedTabIndex = state.selectedTab.ordinal,
|
selectedTabIndex = pagerState.currentPage,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
) {
|
) {
|
||||||
Tab(
|
Tab(
|
||||||
selected = state.selectedTab == BotsTab.BOTS,
|
selected = pagerState.currentPage == BotsTab.BOTS.ordinal,
|
||||||
onClick = { viewModel.setSelectedTab(BotsTab.BOTS) },
|
onClick = {
|
||||||
|
scope.launch {
|
||||||
|
pagerState.animateScrollToPage(BotsTab.BOTS.ordinal)
|
||||||
|
}
|
||||||
|
},
|
||||||
text = { Text(stringResource(R.string.bots_tab_all)) },
|
text = { Text(stringResource(R.string.bots_tab_all)) },
|
||||||
icon = { Icon(Icons.Filled.SmartToy, contentDescription = null) },
|
icon = { Icon(Icons.Filled.SmartToy, contentDescription = null) },
|
||||||
)
|
)
|
||||||
Tab(
|
Tab(
|
||||||
selected = state.selectedTab == BotsTab.GROUPS,
|
selected = pagerState.currentPage == BotsTab.GROUPS.ordinal,
|
||||||
onClick = { viewModel.setSelectedTab(BotsTab.GROUPS) },
|
onClick = {
|
||||||
|
scope.launch {
|
||||||
|
pagerState.animateScrollToPage(BotsTab.GROUPS.ordinal)
|
||||||
|
}
|
||||||
|
},
|
||||||
text = {
|
text = {
|
||||||
Text(
|
Text(
|
||||||
if (state.allGroups.isNotEmpty()) {
|
if (state.allGroups.isNotEmpty()) {
|
||||||
@@ -295,131 +324,168 @@ fun BotsScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
when (state.selectedTab) {
|
HorizontalPager(
|
||||||
BotsTab.BOTS -> {
|
state = pagerState,
|
||||||
when {
|
modifier = Modifier.fillMaxSize(),
|
||||||
state.isLoading && state.profiles.isEmpty() -> {
|
) { page ->
|
||||||
SkeletonListState()
|
when (BotsTab.entries.getOrElse(page) { BotsTab.BOTS }) {
|
||||||
}
|
BotsTab.BOTS -> {
|
||||||
|
Box(
|
||||||
state.errorMessage != null && state.profiles.isEmpty() -> {
|
modifier =
|
||||||
ErrorState(
|
Modifier
|
||||||
message = state.errorMessage ?: "",
|
.fillMaxSize()
|
||||||
onRetry = { viewModel.loadBots() },
|
.pointerInput(pagerState.currentPage, onOpenDrawer) {
|
||||||
)
|
if (pagerState.currentPage == 0) {
|
||||||
}
|
awaitEachGesture {
|
||||||
|
awaitFirstDown(pass = PointerEventPass.Initial)
|
||||||
state.displayProfiles.isEmpty() -> {
|
var totalDragX = 0f
|
||||||
EmptyState(
|
while (true) {
|
||||||
title = stringResource(R.string.bots_empty_title),
|
val event = awaitPointerEvent(pass = PointerEventPass.Initial)
|
||||||
subtitle = stringResource(R.string.bots_empty_description),
|
val change = event.changes.firstOrNull() ?: break
|
||||||
modifier = Modifier.fillMaxSize(),
|
if (!change.pressed) break
|
||||||
)
|
val dx = change.position.x - change.previousPosition.x
|
||||||
}
|
if (dx > 0) {
|
||||||
|
totalDragX += dx
|
||||||
else -> {
|
if (totalDragX > 75f) {
|
||||||
LazyColumn(
|
onOpenDrawer?.invoke()
|
||||||
modifier = Modifier.fillMaxSize(),
|
break
|
||||||
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 24.dp),
|
}
|
||||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
} else if (dx < -10f) {
|
||||||
) {
|
break
|
||||||
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,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
)
|
}
|
||||||
}
|
},
|
||||||
|
) {
|
||||||
|
when {
|
||||||
|
state.isLoading && state.profiles.isEmpty() -> {
|
||||||
|
SkeletonListState()
|
||||||
}
|
}
|
||||||
|
|
||||||
items(
|
state.errorMessage != null && state.profiles.isEmpty() -> {
|
||||||
items = state.displayProfiles,
|
ErrorState(
|
||||||
key = { it.name },
|
message = state.errorMessage ?: "",
|
||||||
) { profile ->
|
onRetry = { viewModel.loadBots() },
|
||||||
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.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 -> {
|
BotsTab.GROUPS -> {
|
||||||
when {
|
when {
|
||||||
state.displayGroups.isEmpty() -> {
|
state.displayGroups.isEmpty() -> {
|
||||||
EmptyState(
|
EmptyState(
|
||||||
title = stringResource(R.string.bots_groups_empty_title),
|
title = stringResource(R.string.bots_groups_empty_title),
|
||||||
subtitle = stringResource(R.string.bots_groups_empty_desc),
|
subtitle = stringResource(R.string.bots_groups_empty_desc),
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 24.dp),
|
contentPadding =
|
||||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
PaddingValues(
|
||||||
) {
|
start = 16.dp,
|
||||||
items(
|
end = 16.dp,
|
||||||
items = state.displayGroups,
|
top = 8.dp,
|
||||||
key = { it.name },
|
bottom = 24.dp,
|
||||||
) { group ->
|
),
|
||||||
GroupCard(
|
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
group = group,
|
) {
|
||||||
onClick = {
|
items(
|
||||||
if (group.members.isNotEmpty()) {
|
items = state.displayGroups,
|
||||||
scope.launch {
|
key = { it.name },
|
||||||
val primary = group.members.first()
|
) { group ->
|
||||||
viewModel.selectBot(primary)
|
GroupCard(
|
||||||
val canonicalId =
|
group = group,
|
||||||
primary.canonical_session?.resolved_id
|
onClick = {
|
||||||
?: primary.canonical_session?.id
|
if (group.members.isNotEmpty()) {
|
||||||
if (!canonicalId.isNullOrBlank()) {
|
NavigationController.navigateTo(
|
||||||
NavigationController.openChatSession(canonicalId)
|
com.m57.hermescontrol.GroupChatKey(group.name),
|
||||||
} else {
|
)
|
||||||
NavigationController.navigateTo(
|
|
||||||
com.m57.hermescontrol.ChatScreen,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
},
|
onDisbandClick = {
|
||||||
onDisbandClick = {
|
disbandingGroup = group
|
||||||
disbandingGroup = group
|
},
|
||||||
},
|
)
|
||||||
)
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import androidx.lifecycle.viewModelScope
|
|||||||
import com.m57.hermescontrol.data.model.BotAvatarMeta
|
import com.m57.hermescontrol.data.model.BotAvatarMeta
|
||||||
import com.m57.hermescontrol.data.model.BotRosterMeta
|
import com.m57.hermescontrol.data.model.BotRosterMeta
|
||||||
import com.m57.hermescontrol.data.model.CreateProfileRequest
|
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.ProfileInfo
|
||||||
import com.m57.hermescontrol.data.model.ProfilesResponse
|
import com.m57.hermescontrol.data.model.ProfilesResponse
|
||||||
import com.m57.hermescontrol.data.remote.ApiClient
|
import com.m57.hermescontrol.data.remote.ApiClient
|
||||||
@@ -24,6 +26,7 @@ import kotlinx.coroutines.flow.asStateFlow
|
|||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.serialization.json.JsonElement
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
import kotlinx.serialization.json.decodeFromJsonElement
|
import kotlinx.serialization.json.decodeFromJsonElement
|
||||||
|
|
||||||
enum class BotsTab {
|
enum class BotsTab {
|
||||||
@@ -34,6 +37,7 @@ enum class BotsTab {
|
|||||||
data class GroupInfo(
|
data class GroupInfo(
|
||||||
val name: String,
|
val name: String,
|
||||||
val members: List<ProfileInfo>,
|
val members: List<ProfileInfo>,
|
||||||
|
val lastActivity: Long = 0L,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class BotsUiState(
|
data class BotsUiState(
|
||||||
@@ -52,17 +56,104 @@ data class BotsUiState(
|
|||||||
|
|
||||||
val allGroups: List<GroupInfo>
|
val allGroups: List<GroupInfo>
|
||||||
get() {
|
get() {
|
||||||
val groupNames =
|
val defaultProfile =
|
||||||
profiles
|
profiles.find { it.is_default == true || it.name == "default" }
|
||||||
.flatMap { it.botMeta()?.groups.orEmpty() }
|
?: profiles.firstOrNull()
|
||||||
.distinct()
|
val syncSnapshot = defaultProfile?.groupChatSyncSnapshot()
|
||||||
.sorted()
|
val deletedKeys = syncSnapshot?.deleted?.keys.orEmpty().map { it.lowercase() }
|
||||||
return groupNames.map { gName ->
|
|
||||||
|
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(
|
GroupInfo(
|
||||||
name = gName,
|
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>
|
val displayGroups: List<GroupInfo>
|
||||||
@@ -321,13 +412,15 @@ class BotsViewModel(
|
|||||||
) {
|
) {
|
||||||
viewModelScope.launch(ioDispatcher) {
|
viewModelScope.launch(ioDispatcher) {
|
||||||
val currentProfiles = _uiState.value.profiles
|
val currentProfiles = _uiState.value.profiles
|
||||||
|
// 1. Update bot metadata on each member bot
|
||||||
for (name in botNames) {
|
for (name in botNames) {
|
||||||
val bot = currentProfiles.find { it.name == name } ?: continue
|
val bot = currentProfiles.find { it.name == name } ?: continue
|
||||||
val existingGroups = bot.botMeta()?.groups.orEmpty()
|
val existingGroups = bot.botMeta()?.allGroups.orEmpty()
|
||||||
if (!existingGroups.contains(groupName)) {
|
if (!existingGroups.any { it.equals(groupName, ignoreCase = true) }) {
|
||||||
val updatedMeta =
|
val updatedMeta =
|
||||||
(bot.botMeta() ?: BotRosterMeta()).copy(
|
(bot.botMeta() ?: BotRosterMeta()).copy(
|
||||||
groups = existingGroups + groupName,
|
groups = existingGroups + groupName,
|
||||||
|
group = existingGroups.firstOrNull() ?: groupName,
|
||||||
)
|
)
|
||||||
try {
|
try {
|
||||||
wsClientConfigureBot(name, updatedMeta).await()
|
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()
|
loadBots()
|
||||||
onSuccess()
|
onSuccess()
|
||||||
}
|
}
|
||||||
@@ -346,12 +480,15 @@ class BotsViewModel(
|
|||||||
) {
|
) {
|
||||||
viewModelScope.launch(ioDispatcher) {
|
viewModelScope.launch(ioDispatcher) {
|
||||||
val currentProfiles = _uiState.value.profiles
|
val currentProfiles = _uiState.value.profiles
|
||||||
|
// 1. Remove from all member bots' metadata
|
||||||
for (bot in currentProfiles) {
|
for (bot in currentProfiles) {
|
||||||
val existingGroups = bot.botMeta()?.groups.orEmpty()
|
val existingGroups = bot.botMeta()?.allGroups.orEmpty()
|
||||||
if (existingGroups.contains(groupName)) {
|
if (existingGroups.any { it.equals(groupName, ignoreCase = true) }) {
|
||||||
|
val filtered = existingGroups.filterNot { it.equals(groupName, ignoreCase = true) }
|
||||||
val updatedMeta =
|
val updatedMeta =
|
||||||
(bot.botMeta() ?: BotRosterMeta()).copy(
|
(bot.botMeta() ?: BotRosterMeta()).copy(
|
||||||
groups = existingGroups - groupName,
|
groups = filtered,
|
||||||
|
group = filtered.firstOrNull(),
|
||||||
)
|
)
|
||||||
try {
|
try {
|
||||||
wsClientConfigureBot(bot.name, updatedMeta).await()
|
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()
|
loadBots()
|
||||||
onSuccess()
|
onSuccess()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package com.m57.hermescontrol.ui.bots
|
|||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.horizontalScroll
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -142,9 +143,14 @@ fun CreateBotDialog(
|
|||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier =
|
||||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.horizontalScroll(rememberScrollState()),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
AVAILABLE_SHAPES.forEach { shape ->
|
AVAILABLE_SHAPES.forEach { shape ->
|
||||||
FilterChip(
|
FilterChip(
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package com.m57.hermescontrol.ui.bots
|
|||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.horizontalScroll
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -173,9 +174,14 @@ fun EditBotBottomSheet(
|
|||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier =
|
||||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.horizontalScroll(rememberScrollState()),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
AVAILABLE_SHAPES.forEach { shape ->
|
AVAILABLE_SHAPES.forEach { shape ->
|
||||||
FilterChip(
|
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
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val codeFence = parseCodeFenceStart(line)
|
||||||
when {
|
when {
|
||||||
line.startsWith("```") -> {
|
codeFence != null -> {
|
||||||
val lang = line.removePrefix("```").trim().ifBlank { null }
|
val end = (i + 1 until lines.size).firstOrNull { isCodeFenceEnd(lines[it], codeFence) }
|
||||||
val end = (i + 1 until lines.size).firstOrNull { lines[it].startsWith("```") }
|
|
||||||
if (end != null) {
|
if (end != null) {
|
||||||
blocks.add(
|
blocks.add(
|
||||||
MdBlock.Code(
|
MdBlock.Code(
|
||||||
code = lines.subList(i + 1, end).joinToString("\n"),
|
code = lines.subList(i + 1, end).joinToString("\n"),
|
||||||
language = lang,
|
language = codeFence.language,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
i = end + 1
|
i = end + 1
|
||||||
@@ -710,7 +710,7 @@ internal fun parseBlocks(src: String): List<MdBlock> {
|
|||||||
blocks.add(
|
blocks.add(
|
||||||
MdBlock.Code(
|
MdBlock.Code(
|
||||||
code = lines.subList(i + 1, lines.size).joinToString("\n"),
|
code = lines.subList(i + 1, lines.size).joinToString("\n"),
|
||||||
language = lang,
|
language = codeFence.language,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
i = lines.size
|
i = lines.size
|
||||||
@@ -1023,12 +1023,45 @@ private fun splitRow(line: String): List<String> {
|
|||||||
return trimmed.split('|').map { it.trim() }
|
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(
|
private fun isDefListStart(
|
||||||
lines: List<String>,
|
lines: List<String>,
|
||||||
i: Int,
|
i: Int,
|
||||||
): Boolean {
|
): Boolean {
|
||||||
val l = lines[i].trim()
|
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 (LIST_ITEM_LINE_RE.matches(lines[i])) return false
|
||||||
if (i + 1 >= lines.size) return false
|
if (i + 1 >= lines.size) return false
|
||||||
return lines[i + 1].trim().startsWith(":")
|
return lines[i + 1].trim().startsWith(":")
|
||||||
@@ -1050,7 +1083,7 @@ private fun fallthroughToParagraph(
|
|||||||
while (
|
while (
|
||||||
i < lines.size &&
|
i < lines.size &&
|
||||||
lines[i].isNotBlank() &&
|
lines[i].isNotBlank() &&
|
||||||
!lines[i].startsWith("```") &&
|
parseCodeFenceStart(lines[i]) == null &&
|
||||||
!isValidHeading(lines[i]) &&
|
!isValidHeading(lines[i]) &&
|
||||||
!lines[i].startsWith(">") &&
|
!lines[i].startsWith(">") &&
|
||||||
!LIST_ITEM_LINE_RE.matches(lines[i]) &&
|
!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.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.Density
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.m57.hermescontrol.R
|
import com.m57.hermescontrol.R
|
||||||
|
import com.m57.hermescontrol.theme.LocalChatFontScale
|
||||||
import com.m57.hermescontrol.ui.chat.ChatBubble
|
import com.m57.hermescontrol.ui.chat.ChatBubble
|
||||||
import com.m57.hermescontrol.ui.chat.ChatMessage
|
import com.m57.hermescontrol.ui.chat.ChatMessage
|
||||||
import com.m57.hermescontrol.ui.chat.ChatSearchState
|
import com.m57.hermescontrol.ui.chat.ChatSearchState
|
||||||
@@ -116,179 +120,191 @@ fun FullBleedChatList(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LazyColumn(
|
val currentDensity = LocalDensity.current
|
||||||
state = listState,
|
val chatFontScale = LocalChatFontScale.current
|
||||||
modifier = Modifier.fillMaxSize(),
|
val chatDensity =
|
||||||
contentPadding = PaddingValues(vertical = 8.dp),
|
remember(currentDensity, chatFontScale) {
|
||||||
) {
|
Density(
|
||||||
if (isLoadingOlder) {
|
density = currentDensity.density,
|
||||||
item(key = "loading-older") {
|
fontScale = currentDensity.fontScale * chatFontScale,
|
||||||
Box(
|
)
|
||||||
modifier = Modifier.fillMaxWidth().padding(12.dp),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
CircularProgressIndicator(modifier = Modifier.size(24.dp))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var entryIndex = 0
|
CompositionLocalProvider(LocalDensity provides chatDensity) {
|
||||||
turns.forEach { turn ->
|
LazyColumn(
|
||||||
when (turn) {
|
state = listState,
|
||||||
is ChatTurn.User -> {
|
modifier = Modifier.fillMaxSize(),
|
||||||
// Eager captures: item lambda reads these at
|
contentPadding = PaddingValues(vertical = 8.dp),
|
||||||
// composition time (lazy), so capture now.
|
) {
|
||||||
val userMessage = turn.message
|
if (isLoadingOlder) {
|
||||||
val milestone = toolMilestones[entryIndex]
|
item(key = "loading-older") {
|
||||||
item(key = "user-${userMessage.id}") {
|
Box(
|
||||||
Column(modifier = Modifier.padding(bottom = 12.dp)) {
|
modifier = Modifier.fillMaxWidth().padding(12.dp),
|
||||||
renderChatBubble(
|
contentAlignment = Alignment.Center,
|
||||||
message = userMessage,
|
) {
|
||||||
searchQuery = if (searchState.isActive) searchState.query else "",
|
CircularProgressIndicator(modifier = Modifier.size(24.dp))
|
||||||
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++
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
is ChatTurn.Agent -> {
|
var entryIndex = 0
|
||||||
var firstProseSeen = false
|
turns.forEach { turn ->
|
||||||
// Reasoning hoist: the turn's reasoning renders at the
|
when (turn) {
|
||||||
// TOP of the turn — above tool rows — so thinking
|
is ChatTurn.User -> {
|
||||||
// leads, then the tool work, then the answer. The
|
// Eager captures: item lambda reads these at
|
||||||
// matching prose entry renders without its own card.
|
// composition time (lazy), so capture now.
|
||||||
val turnReasoning =
|
val userMessage = turn.message
|
||||||
turn.entries
|
val milestone = toolMilestones[entryIndex]
|
||||||
.filterIsInstance<AgentEntry.Prose>()
|
item(key = "user-${userMessage.id}") {
|
||||||
.firstOrNull { it.message.reasoningText.isNotBlank() }
|
Column(modifier = Modifier.padding(bottom = 12.dp)) {
|
||||||
if (turnReasoning != null) {
|
renderChatBubble(
|
||||||
val reasoning = turnReasoning.message
|
message = userMessage,
|
||||||
item(key = "reasoning-${reasoning.id}") {
|
searchQuery = if (searchState.isActive) searchState.query else "",
|
||||||
Column(modifier = Modifier.padding(bottom = 6.dp)) {
|
isCurrentMatch =
|
||||||
// Lead the turn with the assistant identity
|
searchState.currentMatchId != null &&
|
||||||
// and timestamp, then the thinking block.
|
searchState.currentMatchId == userMessage.id,
|
||||||
AssistantTurnHeader(timestamp = reasoning.timestamp)
|
onOpenAttachment = viewModel::openAttachment,
|
||||||
ReasoningCard(
|
onSaveAttachment = onSaveAttachment,
|
||||||
reasoningText = reasoning.reasoningText,
|
savingAttachmentPath = savingAttachmentPath,
|
||||||
isStreaming = reasoning.isStreaming,
|
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 -> {
|
is ChatTurn.Agent -> {
|
||||||
val toolMessage = entry.message
|
var firstProseSeen = false
|
||||||
val milestone = toolMilestones[entryIndex]
|
// Reasoning hoist: the turn's reasoning renders at the
|
||||||
item(key = "tool-${toolMessage.id}") {
|
// TOP of the turn — above tool rows — so thinking
|
||||||
Column(modifier = Modifier.padding(bottom = 6.dp)) {
|
// leads, then the tool work, then the answer. The
|
||||||
FullBleedToolRow(toolMessage)
|
// matching prose entry renders without its own card.
|
||||||
milestone?.let { count ->
|
val turnReasoning =
|
||||||
ToolCallDivider(count = count, maxPerTurn = maxToolCallsPerTurn)
|
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 -> {
|
turn.entries.forEach { entry ->
|
||||||
val sysMessage = entry.message
|
when (entry) {
|
||||||
item(key = "sys-${sysMessage.id}") {
|
is AgentEntry.Prose -> {
|
||||||
Column(modifier = Modifier.padding(bottom = 6.dp)) {
|
val proseMessage = entry.message
|
||||||
if (sysMessage.displayKind != null) {
|
val showTurnHeader = !firstProseSeen && turnReasoning == null
|
||||||
// Timeline marker (issue #904):
|
val hoistedReasoning =
|
||||||
// model/personality switches and
|
turnReasoning != null &&
|
||||||
// auto-continues render as a chip.
|
proseMessage.id == turnReasoning.message.id
|
||||||
TimelineMarkerChip(message = sysMessage)
|
val milestone = toolMilestones[entryIndex]
|
||||||
} else {
|
item(key = "prose-${proseMessage.id}") {
|
||||||
FullBleedSystemEvent(
|
Column(modifier = Modifier.padding(bottom = 12.dp)) {
|
||||||
message = sysMessage,
|
if (proseMessage.isStreaming && typingEffectEnabled) {
|
||||||
onRespondApproval = viewModel::respondToApproval,
|
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
|
// Clarify bubble — rendered at the very bottom
|
||||||
if (clarifyRequest != null) {
|
if (clarifyRequest != null) {
|
||||||
item(key = "clarify_bubble") {
|
item(key = "clarify_bubble") {
|
||||||
ClarifyBubble(
|
ClarifyBubble(
|
||||||
text = clarifyRequest.text,
|
text = clarifyRequest.text,
|
||||||
options = clarifyRequest.options,
|
options = clarifyRequest.options,
|
||||||
onOptionSelected = { option -> onRespondClarify?.invoke(option) },
|
onOptionSelected = { option -> onRespondClarify?.invoke(option) },
|
||||||
onDismiss = { onDismissClarify?.invoke() },
|
onDismiss = { onDismissClarify?.invoke() },
|
||||||
)
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.WindowInsets
|
|||||||
import androidx.compose.foundation.layout.calculateEndPadding
|
import androidx.compose.foundation.layout.calculateEndPadding
|
||||||
import androidx.compose.foundation.layout.calculateStartPadding
|
import androidx.compose.foundation.layout.calculateStartPadding
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.navigationBars
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
@@ -160,7 +161,7 @@ fun HermesScaffold(
|
|||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||||
contentWindowInsets = WindowInsets(0, 0, 0, 0),
|
contentWindowInsets = WindowInsets.navigationBars,
|
||||||
snackbarHost = { snackbarHost() },
|
snackbarHost = { snackbarHost() },
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
|
|||||||
@@ -768,6 +768,7 @@ fun SessionsScreen(
|
|||||||
val info = listState.layoutInfo
|
val info = listState.layoutInfo
|
||||||
val lastVisible = info.visibleItemsInfo.lastOrNull()?.index ?: -1
|
val lastVisible = info.visibleItemsInfo.lastOrNull()?.index ?: -1
|
||||||
lastVisible >= 0 &&
|
lastVisible >= 0 &&
|
||||||
|
info.totalItemsCount >= AUTO_LOAD_THRESHOLD &&
|
||||||
lastVisible >= info.totalItemsCount - AUTO_LOAD_THRESHOLD
|
lastVisible >= info.totalItemsCount - AUTO_LOAD_THRESHOLD
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ data class SessionsUiState(
|
|||||||
val isLoadingMore: Boolean = false,
|
val isLoadingMore: Boolean = false,
|
||||||
val sessions: List<SessionInfo> = emptyList(),
|
val sessions: List<SessionInfo> = emptyList(),
|
||||||
val total: Int = 0,
|
val total: Int = 0,
|
||||||
|
val hasMore: Boolean = false,
|
||||||
val errorMessage: String? = null,
|
val errorMessage: String? = null,
|
||||||
val stats: SessionStats = SessionStats(),
|
val stats: SessionStats = SessionStats(),
|
||||||
val isLoadingStats: Boolean = false,
|
val isLoadingStats: Boolean = false,
|
||||||
@@ -89,7 +90,6 @@ data class SessionsUiState(
|
|||||||
val searchResults: List<SessionSearchResult> = emptyList(),
|
val searchResults: List<SessionSearchResult> = emptyList(),
|
||||||
val searchError: String? = null,
|
val searchError: String? = null,
|
||||||
) {
|
) {
|
||||||
val hasMore: Boolean get() = total > sessions.size
|
|
||||||
val isSearchMode: Boolean get() = searchQuery.isNotBlank()
|
val isSearchMode: Boolean get() = searchQuery.isNotBlank()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,9 +118,12 @@ class SessionsViewModel :
|
|||||||
},
|
},
|
||||||
onSuccess = { data ->
|
onSuccess = { data ->
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
|
val newSessions = data.sessions.orEmpty().pinnedFirst()
|
||||||
|
val hasMore = data.sessions.size >= PAGE_SIZE && data.total > newSessions.size
|
||||||
it.copy(
|
it.copy(
|
||||||
sessions = data.sessions.orEmpty().pinnedFirst(),
|
sessions = newSessions,
|
||||||
total = data.total,
|
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) } },
|
onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } },
|
||||||
onSuccess = { data ->
|
onSuccess = { data ->
|
||||||
|
val sessionsList = data.sessions.orEmpty().pinnedFirst()
|
||||||
|
val hasMore = data.sessions.size >= PAGE_SIZE && data.total > sessionsList.size
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
isLoadingMore = false,
|
isLoadingMore = false,
|
||||||
sessions = data.sessions.orEmpty().pinnedFirst(),
|
sessions = sessionsList,
|
||||||
total = data.total,
|
total = if (hasMore) data.total else sessionsList.size,
|
||||||
|
hasMore = hasMore,
|
||||||
selectedIds = emptySet(),
|
selectedIds = emptySet(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -194,6 +200,13 @@ class SessionsViewModel :
|
|||||||
is NetworkResult.Success -> {
|
is NetworkResult.Success -> {
|
||||||
val data = result.data
|
val data = result.data
|
||||||
_uiState.update {
|
_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(
|
it.copy(
|
||||||
isLoadingMore = false,
|
isLoadingMore = false,
|
||||||
// distinctBy guards offset-pagination churn: if a new
|
// 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
|
// shift and the next page can repeat an id we already
|
||||||
// have — LazyColumn would crash on the duplicate key.
|
// have — LazyColumn would crash on the duplicate key.
|
||||||
// pinnedFirst keeps pinned sessions above the rest.
|
// pinnedFirst keeps pinned sessions above the rest.
|
||||||
sessions =
|
sessions = newSessions,
|
||||||
(it.sessions + data.sessions)
|
total = if (hasMore) data.total else newSessions.size,
|
||||||
.distinctBy { s -> s.id }
|
hasMore = hasMore,
|
||||||
.pinnedFirst(),
|
|
||||||
total = data.total,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -470,11 +481,14 @@ class SessionsViewModel :
|
|||||||
when (result) {
|
when (result) {
|
||||||
is NetworkResult.Success -> {
|
is NetworkResult.Success -> {
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
|
val updatedSessions = it.sessions.filter { s -> s.id != sessionId }
|
||||||
|
val newTotal = (it.total - 1).coerceAtLeast(0)
|
||||||
it.copy(
|
it.copy(
|
||||||
deletingSessionIds = it.deletingSessionIds - sessionId,
|
deletingSessionIds = it.deletingSessionIds - sessionId,
|
||||||
sessions = it.sessions.filter { s -> s.id != sessionId },
|
sessions = updatedSessions,
|
||||||
searchResults = it.searchResults.filter { it.session_id != sessionId },
|
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",
|
toastMessage = "Session deleted",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -191,6 +191,8 @@ internal fun SettingsChatPage(
|
|||||||
onTypingEffectEnabledChange = viewModel::onTypingEffectEnabledChange,
|
onTypingEffectEnabledChange = viewModel::onTypingEffectEnabledChange,
|
||||||
typingEffectDelayMs = state.typingEffectDelayMs,
|
typingEffectDelayMs = state.typingEffectDelayMs,
|
||||||
onTypingEffectDelayMsChange = viewModel::onTypingEffectDelayMsChange,
|
onTypingEffectDelayMsChange = viewModel::onTypingEffectDelayMsChange,
|
||||||
|
chatFontScale = state.chatFontScale,
|
||||||
|
onChatFontScaleChange = viewModel::onChatFontScaleChange,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ data class SettingsUiState(
|
|||||||
val isSaved: Boolean = false,
|
val isSaved: Boolean = false,
|
||||||
val typingEffectEnabled: Boolean = false,
|
val typingEffectEnabled: Boolean = false,
|
||||||
val typingEffectDelayMs: Int = 30,
|
val typingEffectDelayMs: Int = 30,
|
||||||
|
val chatFontScale: Float = 1.0f,
|
||||||
val profiles: List<ConnectionProfile> = emptyList(),
|
val profiles: List<ConnectionProfile> = emptyList(),
|
||||||
val selectedProfileId: String? = null,
|
val selectedProfileId: String? = null,
|
||||||
val renameProfileName: String = "",
|
val renameProfileName: String = "",
|
||||||
@@ -76,6 +77,7 @@ class SettingsViewModel(
|
|||||||
val themePreset = AuthManager.getThemePreset()
|
val themePreset = AuthManager.getThemePreset()
|
||||||
val typingEffectEnabled = AuthManager.isTypingEffectEnabled()
|
val typingEffectEnabled = AuthManager.isTypingEffectEnabled()
|
||||||
val typingEffectDelayMs = AuthManager.getTypingEffectDelayMs()
|
val typingEffectDelayMs = AuthManager.getTypingEffectDelayMs()
|
||||||
|
val chatFontScale = AuthManager.getChatFontScale()
|
||||||
val profiles = AuthManager.getConnectionProfiles()
|
val profiles = AuthManager.getConnectionProfiles()
|
||||||
val appLanguage = AuthManager.getAppLanguage()
|
val appLanguage = AuthManager.getAppLanguage()
|
||||||
val renameProfileName =
|
val renameProfileName =
|
||||||
@@ -95,6 +97,7 @@ class SettingsViewModel(
|
|||||||
themePreset = themePreset,
|
themePreset = themePreset,
|
||||||
typingEffectEnabled = typingEffectEnabled,
|
typingEffectEnabled = typingEffectEnabled,
|
||||||
typingEffectDelayMs = typingEffectDelayMs,
|
typingEffectDelayMs = typingEffectDelayMs,
|
||||||
|
chatFontScale = chatFontScale,
|
||||||
profiles = profiles,
|
profiles = profiles,
|
||||||
selectedProfileId = selectedId,
|
selectedProfileId = selectedId,
|
||||||
renameProfileName = renameProfileName,
|
renameProfileName = renameProfileName,
|
||||||
@@ -310,6 +313,11 @@ class SettingsViewModel(
|
|||||||
AuthManager.setTypingEffectDelayMs(delayMs)
|
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. */
|
/** Clear all auth credentials — logs out and returns to landing screen. */
|
||||||
fun logout() {
|
fun logout() {
|
||||||
AuthManager.setToken(null)
|
AuthManager.setToken(null)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.DropdownMenu
|
import androidx.compose.material3.DropdownMenu
|
||||||
import androidx.compose.material3.DropdownMenuItem
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -18,22 +19,29 @@ import androidx.compose.material3.SegmentedButton
|
|||||||
import androidx.compose.material3.SegmentedButtonDefaults
|
import androidx.compose.material3.SegmentedButtonDefaults
|
||||||
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
||||||
import androidx.compose.material3.Slider
|
import androidx.compose.material3.Slider
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Switch
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.SwitchDefaults
|
import androidx.compose.material3.SwitchDefaults
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.res.stringResource
|
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 androidx.compose.ui.unit.dp
|
||||||
import com.m57.hermescontrol.R
|
import com.m57.hermescontrol.R
|
||||||
import com.m57.hermescontrol.theme.ThemePreference
|
import com.m57.hermescontrol.theme.ThemePreference
|
||||||
import com.m57.hermescontrol.theme.ThemePreset
|
import com.m57.hermescontrol.theme.ThemePreset
|
||||||
import com.m57.hermescontrol.ui.settings.SectionCard
|
import com.m57.hermescontrol.ui.settings.SectionCard
|
||||||
|
import kotlin.math.abs
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun AppearanceSection(
|
internal fun AppearanceSection(
|
||||||
@@ -194,10 +202,172 @@ internal fun ChatSection(
|
|||||||
onTypingEffectEnabledChange: (Boolean) -> Unit,
|
onTypingEffectEnabledChange: (Boolean) -> Unit,
|
||||||
typingEffectDelayMs: Int,
|
typingEffectDelayMs: Int,
|
||||||
onTypingEffectDelayMsChange: (Int) -> Unit,
|
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 {
|
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))
|
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(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
|||||||
@@ -49,6 +49,7 @@
|
|||||||
<string name="billing_topup">충전</string>
|
<string name="billing_topup">충전</string>
|
||||||
|
|
||||||
<!-- Navigation & Drawer -->
|
<!-- Navigation & Drawer -->
|
||||||
|
<string name="press_back_again_to_exit">종료하려면 뒤로가기를 한 번 더 누르세요</string>
|
||||||
<string name="nav_drawer_title">헤르메스</string>
|
<string name="nav_drawer_title">헤르메스</string>
|
||||||
<string name="nav_drawer_subtitle">AI 에이전트 제어</string>
|
<string name="nav_drawer_subtitle">AI 에이전트 제어</string>
|
||||||
<string name="nav_drawer_section_converse">대화</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_item_typing_delay">지연: %1$d ms</string>
|
||||||
<string name="settings_delay_10ms">10 ms</string>
|
<string name="settings_delay_10ms">10 ms</string>
|
||||||
<string name="settings_delay_100ms">100 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_action_test_connection">연결 테스트</string>
|
||||||
<string name="settings_sec_about">정보</string>
|
<string name="settings_sec_about">정보</string>
|
||||||
<string name="settings_about_app_name">헤르메스 모바일</string>
|
<string name="settings_about_app_name">헤르메스 모바일</string>
|
||||||
@@ -802,4 +814,24 @@
|
|||||||
<item quantity="other">에이전트 %1$d개 실행 중</item>
|
<item quantity="other">에이전트 %1$d개 실행 중</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
<string name="task_progress_open_details">작업 세부 정보 열기</string>
|
<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>
|
</resources>
|
||||||
@@ -55,6 +55,7 @@
|
|||||||
<string name="billing_credits_remaining">剩余额度:%1$s</string>
|
<string name="billing_credits_remaining">剩余额度:%1$s</string>
|
||||||
<string name="billing_plan">套餐</string>
|
<string name="billing_plan">套餐</string>
|
||||||
<string name="billing_topup">充值</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_title">Hermes</string>
|
||||||
<string name="nav_drawer_subtitle">AI Agent 控制台</string>
|
<string name="nav_drawer_subtitle">AI Agent 控制台</string>
|
||||||
<string name="nav_drawer_section_converse">对话</string>
|
<string name="nav_drawer_section_converse">对话</string>
|
||||||
@@ -183,6 +184,17 @@
|
|||||||
<string name="settings_item_typing_delay">延迟:%1$d 毫秒</string>
|
<string name="settings_item_typing_delay">延迟:%1$d 毫秒</string>
|
||||||
<string name="settings_delay_10ms">10 毫秒</string>
|
<string name="settings_delay_10ms">10 毫秒</string>
|
||||||
<string name="settings_delay_100ms">100 毫秒</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_action_test_connection">测试连接</string>
|
||||||
<string name="settings_sec_about">关于</string>
|
<string name="settings_sec_about">关于</string>
|
||||||
<string name="settings_sec_language">语言</string>
|
<string name="settings_sec_language">语言</string>
|
||||||
@@ -1101,4 +1113,24 @@
|
|||||||
<!-- system action toast templates -->
|
<!-- system action toast templates -->
|
||||||
<string name="system_action_started">%1$s 已开始</string>
|
<string name="system_action_started">%1$s 已开始</string>
|
||||||
<string name="system_action_failed">%1$s 失败:%2$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>
|
</resources>
|
||||||
@@ -66,6 +66,7 @@
|
|||||||
<string name="billing_topup">Top-up</string>
|
<string name="billing_topup">Top-up</string>
|
||||||
|
|
||||||
<!-- Navigation & Drawer -->
|
<!-- 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_title">Hermes</string>
|
||||||
<string name="nav_drawer_subtitle">AI Agent Control</string>
|
<string name="nav_drawer_subtitle">AI Agent Control</string>
|
||||||
<string name="nav_drawer_section_converse">Converse</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_item_typing_delay">Delay: %1$d ms</string>
|
||||||
<string name="settings_delay_10ms">10 ms</string>
|
<string name="settings_delay_10ms">10 ms</string>
|
||||||
<string name="settings_delay_100ms">100 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_action_test_connection">Test Connection</string>
|
||||||
<string name="settings_sec_about">About</string>
|
<string name="settings_sec_about">About</string>
|
||||||
<string name="settings_sec_language">Language</string>
|
<string name="settings_sec_language">Language</string>
|
||||||
@@ -1232,4 +1244,26 @@
|
|||||||
<!-- system action toast templates -->
|
<!-- system action toast templates -->
|
||||||
<string name="system_action_started">%1$s started</string>
|
<string name="system_action_started">%1$s started</string>
|
||||||
<string name="system_action_failed">%1$s failed: %2$s</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>
|
</resources>
|
||||||
@@ -718,17 +718,19 @@ class E2eIntegrationTest {
|
|||||||
|
|
||||||
NavigationController.navigateTo(SkillsScreen)
|
NavigationController.navigateTo(SkillsScreen)
|
||||||
assertEquals(SkillsScreen, backStack.lastOrNull())
|
assertEquals(SkillsScreen, backStack.lastOrNull())
|
||||||
assertEquals(1, backStack.size)
|
assertEquals(2, backStack.size)
|
||||||
|
assertEquals(ChatScreen, backStack[0])
|
||||||
|
|
||||||
NavigationController.navigateTo(CronJobsScreen)
|
NavigationController.navigateTo(CronJobsScreen)
|
||||||
assertEquals(CronJobsScreen, backStack.lastOrNull())
|
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)
|
NavigationController.navigateTo(ProfilesScreen)
|
||||||
assertEquals(ProfilesScreen, backStack.lastOrNull())
|
assertEquals(ProfilesScreen, backStack.lastOrNull())
|
||||||
assertEquals(2, backStack.size)
|
assertEquals(2, backStack.size)
|
||||||
assertEquals(CronJobsScreen, backStack[0])
|
assertEquals(ChatScreen, backStack[0])
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Tier 2: Boundary & Corner Cases (>=5 per feature) ────────────────
|
// ── 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
|
* Unit tests for [NavigationController] — the central navigation guard that
|
||||||
* prevents duplicate screen entries on the back stack.
|
* prevents duplicate screen entries on the back stack and enforces
|
||||||
*
|
* swipe-back-to-chat hierarchy.
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
class NavigationControllerTest {
|
class NavigationControllerTest {
|
||||||
@Before
|
@Before
|
||||||
@@ -47,7 +40,7 @@ class NavigationControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `navigateTo on same primary screen is a no-op`() {
|
fun `navigateTo on same primary screen is a no-op`() {
|
||||||
val backStack = NavBackStack<NavKey>(SkillsScreen)
|
val backStack = NavBackStack<NavKey>(ChatScreen, SkillsScreen)
|
||||||
NavigationController.backStack = backStack
|
NavigationController.backStack = backStack
|
||||||
val sizeBefore = backStack.size
|
val sizeBefore = backStack.size
|
||||||
|
|
||||||
@@ -58,69 +51,60 @@ class NavigationControllerTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `navigateTo on same non-primary screen is a no-op`() {
|
fun `navigateTo on same subscreen is a no-op`() {
|
||||||
val backStack = NavBackStack<NavKey>(ProfilesScreen)
|
val backStack = NavBackStack<NavKey>(ChatScreen, SettingsScreen, SettingsAppearance)
|
||||||
NavigationController.backStack = backStack
|
NavigationController.backStack = backStack
|
||||||
|
|
||||||
// ProfilesScreen is NOT in the default primaryScreens so it stays on the stack
|
NavigationController.navigateTo(SettingsAppearance)
|
||||||
NavigationController.navigateTo(ProfilesScreen)
|
|
||||||
|
|
||||||
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())
|
assertEquals(ProfilesScreen, backStack.lastOrNull())
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Primary-screen behaviour: stack clearing ──────────────────────────
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `navigateTo on a different primary screen clears the stack`() {
|
fun `navigateTo ChatScreen clears stack to single ChatScreen root`() {
|
||||||
val backStack = NavBackStack<NavKey>(ChatScreen)
|
val backStack = NavBackStack<NavKey>(ChatScreen, ProfilesScreen)
|
||||||
NavigationController.backStack = backStack
|
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)
|
NavigationController.navigateTo(ChatScreen)
|
||||||
|
|
||||||
// Primary screen navigation clears the stack
|
assertEquals("chat navigation should result in single root entry", 1, backStack.size)
|
||||||
assertEquals("primary navigation clears stack", 1, backStack.size)
|
|
||||||
assertEquals(ChatScreen, backStack.lastOrNull())
|
assertEquals(ChatScreen, backStack.lastOrNull())
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Non-primary screen behaviour: stack appending ─────────────────────
|
// ── Subscreen behaviour: appends to stack ───────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `navigateTo on a non-primary screen appends to the stack`() {
|
fun `navigateTo on a subscreen appends to the current stack`() {
|
||||||
val backStack = NavBackStack<NavKey>(ChatScreen)
|
val backStack = NavBackStack<NavKey>(ChatScreen, SettingsScreen)
|
||||||
NavigationController.backStack = backStack
|
NavigationController.backStack = backStack
|
||||||
|
|
||||||
NavigationController.navigateTo(ProfilesScreen)
|
NavigationController.navigateTo(SettingsAppearance)
|
||||||
assertEquals(2, backStack.size)
|
|
||||||
assertEquals(ProfilesScreen, backStack.lastOrNull())
|
|
||||||
|
|
||||||
NavigationController.navigateTo(KeysScreen)
|
|
||||||
assertEquals(3, backStack.size)
|
assertEquals(3, backStack.size)
|
||||||
assertEquals(KeysScreen, backStack.lastOrNull())
|
assertEquals(SettingsAppearance, backStack.lastOrNull())
|
||||||
|
|
||||||
|
NavigationController.navigateTo(SettingsAbout)
|
||||||
|
assertEquals(4, backStack.size)
|
||||||
|
assertEquals(SettingsAbout, backStack.lastOrNull())
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -176,13 +160,9 @@ class NavigationControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `resetTo clears the stack and sets the target screen`() {
|
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.backStack = backStack
|
||||||
|
|
||||||
NavigationController.navigateTo(ProfilesScreen)
|
|
||||||
NavigationController.navigateTo(KeysScreen)
|
|
||||||
assertEquals(3, backStack.size)
|
|
||||||
|
|
||||||
NavigationController.resetTo(SettingsScreen)
|
NavigationController.resetTo(SettingsScreen)
|
||||||
|
|
||||||
assertEquals(1, backStack.size)
|
assertEquals(1, backStack.size)
|
||||||
@@ -197,14 +177,17 @@ class NavigationControllerTest {
|
|||||||
assertNull(NavigationController.backStack)
|
assertNull(NavigationController.backStack)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── goBack: never leave the stack empty ───────────────────────────────
|
// ── goBack: pops stack, preserves chat root ───────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `goBack removes the top screen when stack has more than one`() {
|
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.backStack = backStack
|
||||||
NavigationController.navigateTo(ProfilesScreen)
|
|
||||||
|
NavigationController.goBack()
|
||||||
|
|
||||||
assertEquals(2, backStack.size)
|
assertEquals(2, backStack.size)
|
||||||
|
assertEquals(SettingsScreen, backStack.lastOrNull())
|
||||||
|
|
||||||
NavigationController.goBack()
|
NavigationController.goBack()
|
||||||
|
|
||||||
@@ -213,18 +196,18 @@ class NavigationControllerTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `goBack from chat opens history`() {
|
fun `goBack on root ChatScreen keeps ChatScreen`() {
|
||||||
val backStack = NavBackStack<NavKey>(ChatScreen)
|
val backStack = NavBackStack<NavKey>(ChatScreen)
|
||||||
NavigationController.backStack = backStack
|
NavigationController.backStack = backStack
|
||||||
|
|
||||||
NavigationController.goBack()
|
NavigationController.goBack()
|
||||||
|
|
||||||
assertEquals(1, backStack.size)
|
assertEquals(1, backStack.size)
|
||||||
assertEquals(HistoryScreen, backStack.lastOrNull())
|
assertEquals(ChatScreen, backStack.lastOrNull())
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@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)
|
val backStack = NavBackStack<NavKey>(ProfilesScreen)
|
||||||
NavigationController.backStack = backStack
|
NavigationController.backStack = backStack
|
||||||
|
|
||||||
@@ -256,18 +239,42 @@ class NavigationControllerTest {
|
|||||||
// ── primaryScreens ────────────────────────────────────────────────────
|
// ── primaryScreens ────────────────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `isPrimaryScreen returns true for default screens`() {
|
fun `isPrimaryScreen returns true for all drawer screens`() {
|
||||||
assertTrue("ChatScreen should be primary by default", NavigationController.isPrimaryScreen(ChatScreen))
|
assertTrue("ChatScreen should be primary", NavigationController.isPrimaryScreen(ChatScreen))
|
||||||
assertTrue("SkillsScreen should be primary by default", NavigationController.isPrimaryScreen(SkillsScreen))
|
assertTrue("SkillsScreen should be primary", NavigationController.isPrimaryScreen(SkillsScreen))
|
||||||
assertTrue("CronJobsScreen should be primary by default", NavigationController.isPrimaryScreen(CronJobsScreen))
|
assertTrue("CronJobsScreen should be primary", NavigationController.isPrimaryScreen(CronJobsScreen))
|
||||||
assertTrue("SystemScreen should be primary by default", NavigationController.isPrimaryScreen(SystemScreen))
|
assertTrue("SystemScreen should be primary", NavigationController.isPrimaryScreen(SystemScreen))
|
||||||
assertTrue("SettingsScreen should be primary by default", NavigationController.isPrimaryScreen(SettingsScreen))
|
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
|
@Test
|
||||||
fun `isPrimaryScreen returns false for non-default screens`() {
|
fun `isPrimaryScreen returns false for subscreen detail keys`() {
|
||||||
assertFalse("ProfilesScreen should NOT be primary", NavigationController.isPrimaryScreen(ProfilesScreen))
|
assertFalse(
|
||||||
assertFalse("LogsScreen should NOT be primary", NavigationController.isPrimaryScreen(LogsScreen))
|
"SettingsAppearance should NOT be primary",
|
||||||
assertFalse("ConfigScreen should NOT be primary", NavigationController.isPrimaryScreen(ConfigScreen))
|
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)
|
assertEquals(9119, state.port)
|
||||||
assertTrue(state.autoReconnect)
|
assertTrue(state.autoReconnect)
|
||||||
assertEquals("token", state.wsAuthParam)
|
assertEquals("token", state.wsAuthParam)
|
||||||
|
assertEquals(1.0f, state.chatFontScale)
|
||||||
assertTrue(state.connectionProfiles.isEmpty())
|
assertTrue(state.connectionProfiles.isEmpty())
|
||||||
assertNull(state.selectedProfileId)
|
assertNull(state.selectedProfileId)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
package com.m57.hermescontrol.data.model
|
package com.m57.hermescontrol.data.model
|
||||||
|
|
||||||
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
|
||||||
import kotlinx.serialization.decodeFromString
|
|
||||||
import kotlinx.serialization.encodeToString
|
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.assertEquals
|
||||||
import org.junit.Assert.assertFalse
|
import org.junit.Assert.assertFalse
|
||||||
import org.junit.Assert.assertNotNull
|
import org.junit.Assert.assertNotNull
|
||||||
@@ -11,120 +12,132 @@ import org.junit.Assert.assertTrue
|
|||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
class ProfileSerializationTest {
|
class ProfileSerializationTest {
|
||||||
private val json = OkHttpProvider.json
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testProfileInfoDeserializationWithBotMetadata() {
|
fun testBotRosterMetaSerialization() {
|
||||||
val jsonStr =
|
val meta =
|
||||||
"""
|
BotRosterMeta(
|
||||||
{
|
title = "Literature Scout",
|
||||||
"name": "researcher",
|
description = "Scrapes arXiv and summarizes papers",
|
||||||
"path": "/home/user/.hermes/profiles/researcher",
|
avatar =
|
||||||
"is_default": false,
|
BotAvatarMeta(
|
||||||
"model": "anthropic/claude-3-7-sonnet",
|
shape = "circle",
|
||||||
"provider": "anthropic",
|
color = "#4A90E2",
|
||||||
"skill_count": 5,
|
icon = "science",
|
||||||
"description": "Base description",
|
),
|
||||||
"display_name": "Researcher Agent",
|
hidden = false,
|
||||||
"has_avatar": true,
|
groups = listOf("research", "ai-lab"),
|
||||||
"canonical_session": {
|
group = "research",
|
||||||
"id": "sess-canon-123",
|
created = 1718000000.0,
|
||||||
"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()
|
|
||||||
|
|
||||||
val profile = json.decodeFromString<ProfileInfo>(jsonStr)
|
val encoded = json.encodeToString(meta)
|
||||||
assertEquals("researcher", profile.name)
|
val decoded = json.decodeFromString<BotRosterMeta>(encoded)
|
||||||
assertEquals("Researcher Agent", profile.display_name)
|
|
||||||
assertTrue(profile.has_avatar == true)
|
|
||||||
|
|
||||||
// Canonical session
|
assertEquals("Literature Scout", decoded.title)
|
||||||
val canon = profile.canonical_session
|
assertEquals("Scrapes arXiv and summarizes papers", decoded.description)
|
||||||
assertNotNull(canon)
|
assertEquals("circle", decoded.avatar?.shape)
|
||||||
assertEquals("sess-canon-123", canon?.id)
|
assertEquals("#4A90E2", decoded.avatar?.color)
|
||||||
assertEquals("sess-canon-tip", canon?.resolved_id)
|
assertEquals("science", decoded.avatar?.icon)
|
||||||
assertEquals("Latest research report ready.", canon?.preview)
|
assertEquals(false, decoded.hidden)
|
||||||
assertEquals(42, canon?.message_count)
|
assertEquals(listOf("research", "ai-lab"), decoded.groups)
|
||||||
|
assertEquals("research", decoded.group)
|
||||||
|
assertEquals(1718000000.0, decoded.created)
|
||||||
|
}
|
||||||
|
|
||||||
// Worker session
|
@Test
|
||||||
val worker = profile.worker_session
|
fun testProfileInfoWithBotMetaExtraction() {
|
||||||
assertNotNull(worker)
|
val botMeta =
|
||||||
assertEquals("worker-456", worker?.id)
|
BotRosterMeta(
|
||||||
assertEquals("kanban", worker?.source)
|
title = "Custom Bot Title",
|
||||||
|
description = "Custom Bot Description",
|
||||||
|
avatar = BotAvatarMeta(shape = "hexagon", color = "#E91E63", icon = "robot"),
|
||||||
|
hidden = false,
|
||||||
|
)
|
||||||
|
|
||||||
// Bot metadata decoding
|
val profile =
|
||||||
val botMeta = profile.botMeta(json)
|
ProfileInfo(
|
||||||
assertNotNull(botMeta)
|
name = "custom_bot",
|
||||||
assertEquals("Dr. Researcher", botMeta?.title)
|
display_name = "Original Name",
|
||||||
assertEquals("Deep literature scout", botMeta?.description)
|
description = "Original Description",
|
||||||
assertEquals("hexagon", botMeta?.avatar?.shape)
|
ui_meta = mapOf("hermes-bots" to json.encodeToJsonElement(botMeta)),
|
||||||
assertEquals("#4A90E2", botMeta?.avatar?.color)
|
canonical_session =
|
||||||
assertEquals(listOf("science-team", "daily-sync"), botMeta?.groups)
|
CanonicalSessionInfo(
|
||||||
|
id = "sess-123",
|
||||||
|
last_active = 1720000000.0,
|
||||||
|
),
|
||||||
|
worker_session =
|
||||||
|
ProfileWorkerSummary(
|
||||||
|
id = "worker-456",
|
||||||
|
source = "kanban",
|
||||||
|
title = "Task #42",
|
||||||
|
last_active = 1720000100.0,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
// Helpers
|
val extractedMeta = profile.botMeta(json)
|
||||||
assertEquals("Dr. Researcher", profile.effectiveTitle)
|
assertNotNull(extractedMeta)
|
||||||
assertEquals("Deep literature scout", profile.effectiveDescription)
|
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)
|
assertFalse(profile.isHidden)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testProfileInfoBackwardCompatibility() {
|
fun testProfileInfoHidden_derivedFromBotMeta() {
|
||||||
val legacyJson =
|
val hiddenBot =
|
||||||
"""
|
ProfileInfo(
|
||||||
{
|
name = "hidden_bot",
|
||||||
"name": "default",
|
ui_meta =
|
||||||
"is_default": true,
|
mapOf(
|
||||||
"model": "openai/gpt-4o",
|
"hermes-bots" to
|
||||||
"provider": "openai",
|
json.encodeToJsonElement(
|
||||||
"description": "Default Assistant"
|
BotRosterMeta(title = "Hidden", hidden = true),
|
||||||
}
|
),
|
||||||
""".trimIndent()
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
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))
|
assertNull(profile.botMeta(json))
|
||||||
assertEquals("default", profile.effectiveTitle)
|
assertEquals("default", profile.effectiveTitle)
|
||||||
assertEquals("Default Assistant", profile.effectiveDescription)
|
assertEquals("", profile.effectiveDescription)
|
||||||
assertFalse(profile.isHidden)
|
assertFalse(profile.isHidden)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,8 +153,17 @@ class ProfileSerializationTest {
|
|||||||
GroupChatRoomMeta(
|
GroupChatRoomMeta(
|
||||||
id = "room-1",
|
id = "room-1",
|
||||||
name = "War Room",
|
name = "War Room",
|
||||||
members = listOf("default", "researcher"),
|
members = listOf(JsonPrimitive("default"), JsonPrimitive("researcher")),
|
||||||
picture = null,
|
picture = null,
|
||||||
|
log =
|
||||||
|
listOf(
|
||||||
|
GroupChatSyncLogEntry(
|
||||||
|
id = "msg-1",
|
||||||
|
from = GroupChatSyncFrom(kind = "user", name = "You"),
|
||||||
|
text = "Let's review the code",
|
||||||
|
at = 1724000000L,
|
||||||
|
),
|
||||||
|
),
|
||||||
updatedAt = 1724000000L,
|
updatedAt = 1724000000L,
|
||||||
createdAt = 1723000000L,
|
createdAt = 1723000000L,
|
||||||
),
|
),
|
||||||
@@ -153,8 +175,11 @@ class ProfileSerializationTest {
|
|||||||
val decoded = json.decodeFromString<GroupChatSyncSnapshot>(encoded)
|
val decoded = json.decodeFromString<GroupChatSyncSnapshot>(encoded)
|
||||||
assertEquals(3, decoded.version)
|
assertEquals(3, decoded.version)
|
||||||
assertEquals(1, decoded.rooms?.size)
|
assertEquals(1, decoded.rooms?.size)
|
||||||
assertEquals("War Room", decoded.rooms?.get("room-1")?.name)
|
val room = decoded.rooms?.get("room-1")
|
||||||
assertEquals(listOf("default", "researcher"), decoded.rooms?.get("room-1")?.members)
|
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"))
|
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.BotAvatarMeta
|
||||||
import com.m57.hermescontrol.data.model.BotRosterMeta
|
import com.m57.hermescontrol.data.model.BotRosterMeta
|
||||||
import com.m57.hermescontrol.data.model.CanonicalSessionInfo
|
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.ProfileInfo
|
||||||
import com.m57.hermescontrol.data.model.ProfileWorkerSummary
|
import com.m57.hermescontrol.data.model.ProfileWorkerSummary
|
||||||
import com.m57.hermescontrol.data.model.ProfilesResponse
|
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.runTest
|
||||||
import kotlinx.coroutines.test.setMain
|
import kotlinx.coroutines.test.setMain
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
import kotlinx.serialization.json.encodeToJsonElement
|
import kotlinx.serialization.json.encodeToJsonElement
|
||||||
import org.junit.After
|
import org.junit.After
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
@@ -273,4 +276,67 @@ class BotsViewModelTest {
|
|||||||
assertTrue(success)
|
assertTrue(success)
|
||||||
coVerify(exactly = 1) { mockApi.deleteProfile("scout") }
|
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 com.m57.hermescontrol.theme.searchHighlightColors
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertFalse
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
@@ -452,4 +453,89 @@ class MarkdownTextFeatureTest {
|
|||||||
assertEquals(2, o16.index)
|
assertEquals(2, o16.index)
|
||||||
assertEquals(0, o16.level)
|
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) ──────────────────────────────────────
|
// ── Pagination (fluid load-more) ──────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@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()
|
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
|
coEvery { mockApi.getSessions(any(), any(), any()) } returns
|
||||||
Response.success(
|
Response.success(
|
||||||
SessionListResponse(
|
SessionListResponse(
|
||||||
sessions = listOf(SessionInfo("s-1"), SessionInfo("s-2")),
|
sessions = listOf(SessionInfo("s-1"), SessionInfo("s-2")),
|
||||||
total = 3,
|
total = 10,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
vm.loadSessions()
|
vm.loadSessions()
|
||||||
testDispatcher.scheduler.advanceUntilIdle()
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
assertEquals(listOf("s-1", "s-2"), vm.uiState.value.sessions.map { it.id })
|
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)
|
assertTrue(vm.uiState.value.hasMore)
|
||||||
assertFalse(vm.uiState.value.isLoadingMore)
|
assertFalse(vm.uiState.value.isLoadingMore)
|
||||||
|
|
||||||
// Page 2 overlaps page 1 (offset churn: a new session landed on top
|
// Page 2 overlaps page 1 (offset churn: a new session landed on top
|
||||||
// between loads) — the duplicate id must not double-append.
|
// 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(
|
Response.success(
|
||||||
SessionListResponse(
|
SessionListResponse(
|
||||||
sessions = listOf(SessionInfo("s-3"), SessionInfo("s-2")),
|
sessions = listOf(SessionInfo("s-51"), SessionInfo("s-50")),
|
||||||
total = 3,
|
total = 51,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
vm.loadMore()
|
vm.loadMore()
|
||||||
testDispatcher.scheduler.advanceUntilIdle()
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
assertEquals(listOf("s-1", "s-2", "s-3"), vm.uiState.value.sessions.map { it.id })
|
assertEquals(51, vm.uiState.value.sessions.size)
|
||||||
assertEquals(3, vm.uiState.value.total)
|
assertEquals(51, vm.uiState.value.total)
|
||||||
assertFalse(vm.uiState.value.hasMore)
|
assertFalse(vm.uiState.value.hasMore)
|
||||||
assertFalse(vm.uiState.value.isLoadingMore)
|
assertFalse(vm.uiState.value.isLoadingMore)
|
||||||
}
|
}
|
||||||
@@ -140,11 +163,12 @@ class SessionsViewModelTest {
|
|||||||
fun `loadMore is a no-op while a load is already running`() {
|
fun `loadMore is a no-op while a load is already running`() {
|
||||||
val vm = createViewModel()
|
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(
|
Response.success(
|
||||||
SessionListResponse(
|
SessionListResponse(
|
||||||
sessions = listOf(SessionInfo("s-1")),
|
sessions = page1Sessions,
|
||||||
total = 2,
|
total = 52,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
vm.loadSessions()
|
vm.loadSessions()
|
||||||
@@ -158,7 +182,6 @@ class SessionsViewModelTest {
|
|||||||
|
|
||||||
// loadSessions (1) + exactly one loadMore (1) = 2 API hits total.
|
// loadSessions (1) + exactly one loadMore (1) = 2 API hits total.
|
||||||
coVerify(exactly = 2) { mockApi.getSessions(any(), any(), any()) }
|
coVerify(exactly = 2) { mockApi.getSessions(any(), any(), any()) }
|
||||||
assertEquals(listOf("s-1"), vm.uiState.value.sessions.map { it.id })
|
|
||||||
assertFalse(vm.uiState.value.isLoadingMore)
|
assertFalse(vm.uiState.value.isLoadingMore)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ class SettingsViewModelTest {
|
|||||||
every { AuthManager.getThemePreset() } returns ThemePreset.DEFAULT
|
every { AuthManager.getThemePreset() } returns ThemePreset.DEFAULT
|
||||||
every { AuthManager.isTypingEffectEnabled() } returns true
|
every { AuthManager.isTypingEffectEnabled() } returns true
|
||||||
every { AuthManager.getTypingEffectDelayMs() } returns 30
|
every { AuthManager.getTypingEffectDelayMs() } returns 30
|
||||||
|
every { AuthManager.getChatFontScale() } returns 1.0f
|
||||||
every { AuthManager.getConnectionProfiles() } returns emptyList()
|
every { AuthManager.getConnectionProfiles() } returns emptyList()
|
||||||
every { AuthManager.getSelectedProfileId() } answers { storedSelectedProfileId }
|
every { AuthManager.getSelectedProfileId() } answers { storedSelectedProfileId }
|
||||||
every { AuthManager.baseUrl() } returns "http://127.0.0.1:9119/"
|
every { AuthManager.baseUrl() } returns "http://127.0.0.1:9119/"
|
||||||
@@ -79,6 +80,7 @@ class SettingsViewModelTest {
|
|||||||
every { AuthManager.setThemePreset(any()) } returns Unit
|
every { AuthManager.setThemePreset(any()) } returns Unit
|
||||||
every { AuthManager.setTypingEffectEnabled(any()) } returns Unit
|
every { AuthManager.setTypingEffectEnabled(any()) } returns Unit
|
||||||
every { AuthManager.setTypingEffectDelayMs(any()) } returns Unit
|
every { AuthManager.setTypingEffectDelayMs(any()) } returns Unit
|
||||||
|
every { AuthManager.setChatFontScale(any()) } returns Unit
|
||||||
every { AuthManager.setSelectedProfileId(any()) } answers {
|
every { AuthManager.setSelectedProfileId(any()) } answers {
|
||||||
storedSelectedProfileId = firstArg()
|
storedSelectedProfileId = firstArg()
|
||||||
}
|
}
|
||||||
@@ -130,6 +132,7 @@ class SettingsViewModelTest {
|
|||||||
every { AuthManager.getThemePreset() } returns ThemePreset.CATPPUCCIN
|
every { AuthManager.getThemePreset() } returns ThemePreset.CATPPUCCIN
|
||||||
every { AuthManager.isTypingEffectEnabled() } returns false
|
every { AuthManager.isTypingEffectEnabled() } returns false
|
||||||
every { AuthManager.getTypingEffectDelayMs() } returns 15
|
every { AuthManager.getTypingEffectDelayMs() } returns 15
|
||||||
|
every { AuthManager.getChatFontScale() } returns 1.30f
|
||||||
every { AuthManager.getConnectionProfiles() } returns testProfiles
|
every { AuthManager.getConnectionProfiles() } returns testProfiles
|
||||||
every { AuthManager.getAppLanguage() } returns "fr"
|
every { AuthManager.getAppLanguage() } returns "fr"
|
||||||
|
|
||||||
@@ -146,12 +149,23 @@ class SettingsViewModelTest {
|
|||||||
assertEquals(ThemePreset.CATPPUCCIN, state.themePreset)
|
assertEquals(ThemePreset.CATPPUCCIN, state.themePreset)
|
||||||
assertEquals(false, state.typingEffectEnabled)
|
assertEquals(false, state.typingEffectEnabled)
|
||||||
assertEquals(15, state.typingEffectDelayMs)
|
assertEquals(15, state.typingEffectDelayMs)
|
||||||
|
assertEquals(1.30f, state.chatFontScale)
|
||||||
assertEquals(testProfiles, state.profiles)
|
assertEquals(testProfiles, state.profiles)
|
||||||
assertEquals("prof-2", state.selectedProfileId)
|
assertEquals("prof-2", state.selectedProfileId)
|
||||||
assertEquals("Home", state.renameProfileName)
|
assertEquals("Home", state.renameProfileName)
|
||||||
assertEquals("fr", state.appLanguage)
|
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
|
@Test
|
||||||
fun testLoadSettings_noSelectedProfile_renameEmpty() {
|
fun testLoadSettings_noSelectedProfile_renameEmpty() {
|
||||||
every { AuthManager.getConnectionProfiles() } returns testProfiles
|
every { AuthManager.getConnectionProfiles() } returns testProfiles
|
||||||
|
|||||||
Reference in new issue
Block a user