Merge branch 'dev' into chore/sync-dev-to-main-1.29.6

This commit is contained in:
M57 committed 2026-09-24 02:51:46 +03:00
commit 15a0abc7a7
14 files changed
+468 -302

No files matched your search

+3 -2
View File
@@ -19,7 +19,7 @@ Fixes #
## How to test
<!-- Steps to verify. For UI: which screen + which emulator/device API level. For WS: which RPC. -->
<!-- Record steps AND observed results. For UI: screen, device/emulator, API level, and behavior exercised. For WS: RPC and observed response. Name any verification that could not be run. Building/downloading a CI APK is not a device test. -->
1.
2.
@@ -32,4 +32,5 @@ Fixes #
- [ ] `./gradlew testDebugUnitTest` green (or CI unit-tests job)
- [ ] `checkColorLiterals` passes (no hardcoded Color outside theme/)
- [ ] Navigation goes through `NavigationController.navigateTo()` (not `backStack.add`)
- [ ] Tested on device/emulator or verified via CI APK
- [ ] For UI changes, exercised the changed behavior on a device/emulator and recorded the result above (or explicitly documented the unverified gate)
- [ ] Documentation matches changed behavior; UI changes follow DESIGN.md
+24 -31
View File
@@ -2,11 +2,12 @@
Guidance for AI coding agents working on this repository.
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.
(visual and interaction requirements) with agent-focused operational and
architecture context. Before changing navigation, state ownership, or module
boundaries, read the relevant implementation and the conventions below. For UI
changes, also read `DESIGN.md`; for theme implementation, read
[`THEMES.md`](app/src/main/java/com/m57/hermescontrol/theme/THEMES.md).
Flag conflicts between requirements and implementation instead of silently diverging.
## Project Overview
@@ -16,9 +17,9 @@ dashboard's REST API and WebSocket TUI Gateway (JSON-RPC 2.0) over a trusted LAN
- **Package:** `com.m57.hermescontrol`
- **Min SDK 26 / Target SDK 37 / Compile SDK 37**
- **Kotlin 2.4.10**, KSP 2.3.10 (standalone versioning, NOT `kotlinVersion-kspVersion`)
- **Jetpack Compose**, Room 2.7.x, Navigation3, OkHttp WebSocket, Retrofit, Kotlinx Serialization
- **Auth:** `EncryptedSharedPreferences` (AES256-GCM), Bearer token (REST) + `?token=` (WS)
- **Kotlin and KSP:** versions are pinned in [`gradle/libs.versions.toml`](gradle/libs.versions.toml). KSP uses standalone versioning, NOT `kotlinVersion-kspVersion`.
- **Jetpack Compose**, Room, Navigation3, OkHttp WebSocket, Retrofit, Kotlinx Serialization. See the version catalog and [`app/build.gradle.kts`](app/build.gradle.kts) for versions and dependency scopes.
- **Auth:** `EncryptedSharedPreferences` (AES256-GCM); connection/authentication flows are documented in [README.md](README.md#authentication). Trace the active REST and WebSocket auth paths before changing them.
## Build & Test Commands
@@ -32,7 +33,7 @@ If you have a local Android SDK (`ANDROID_HOME` set), these work:
./gradlew ktlintCheck # style check
```
**ktlint standalone** (no SDK needed):
**ktlint standalone fallback** (no SDK needed; not the authoritative CI gate):
```bash
# Download the matching binary
@@ -68,7 +69,7 @@ Requires `permissions: contents: write` on the `build-release` job.
## Code Style
- **ktlint 1.8.0** is enforced in CI. Run `./ktlint --format` before pushing.
- **ktlint 1.8.0** is enforced in CI. With an Android SDK, run `./gradlew ktlintFormat` followed by `./gradlew ktlintCheck`. Standalone formatting is only a fallback; it does not replace the Gradle CI check.
- Import ordering is the #1 CI failure: ktlint enforces ASCII-lexicographic order
(uppercase before lowercase: `LaunchedEffect` before `collectAsState`).
- `const val` must use SCREAMING_SNAKE_CASE.
@@ -147,12 +148,9 @@ call unconditionally.
### ⚠ HermesScaffold Padding Foot-Gun
**This is the #1 recurring bug in this codebase.** It has been re-introduced on 4+ screens
across 3+ PRs (Settings, Achievements, Webhooks, Config — PRs #445, #454, #455).
**Root cause:** `HermesScaffold` wraps content in an internal `Box(Modifier.padding(paddingValues))`
that already offsets for the top bar. But it also passes `paddingValues` into the content lambda,
which looks like it should be applied — and every new screen does exactly that:
`HermesScaffold` handles system insets and the dynamic top-bar offset in its
internal content wrapper. Although it passes `paddingValues` to the content
lambda, applying those values again double-stacks the padding:
```kotlin
HermesScaffold(...) { paddingValues -> // ← scaffold already handles top bar offset via Box
@@ -165,11 +163,6 @@ HermesScaffold(...) { paddingValues -> // ← scaffold already handles top
}
```
**The deeper issue:** Passing `paddingValues` into the lambda implies "you need to use this,"
when the scaffold has ALREADY pre-applied it in its own outer `Box`. This API design creates
a natural foot-gun: every developer instinctively adds `.padding(paddingValues)` on inner
content because it seems correct.
**Correct pattern — do NOT apply `paddingValues` on inner content:**
```kotlin
@@ -185,21 +178,21 @@ Column(
modifier = Modifier.fillMaxSize(), // no .padding(paddingValues)!!
) { ... }
// ✅ Loading/Error/Empty states — these DO need it:
LoadingState(modifier = Modifier.padding(paddingValues))
// ✅ Loading/Error/Empty states inside HermesScaffold content follow the same rule:
LoadingState(modifier = Modifier.fillMaxSize())
```
**Quick test:** If your screen's top gap is wider than CronJobsScreen's, you've double-stacked.
**See also:** `references/hermes-scaffold-padding.md` in the skill doc for the full
breakdown, edge cases, and timeline of previous occurrences.
This applies to every branch inside the content lambda. A state view outside
`HermesScaffold` must follow its own parent layout's inset contract instead.
See [`HermesScaffold.kt`](app/src/main/java/com/m57/hermescontrol/ui/common/HermesScaffold.kt)
for the current inset implementation.
### Room Persistence
- `ChatMessageEntity` / `ChatMessageDao` / `HermesDatabase` — chat messages survive
app kills. `getMessagesForSession()` returns `suspend fun ...: List<ChatMessageEntity>`
(not `Flow` — the caller controls the coroutine scope).
- Room 2.7.x requires `room { schemaDirectory("$projectDir/schemas") }` DSL.
- Room schema configuration is defined in `app/build.gradle.kts`; preserve schema export and migrations when updating Room.
- `ChatViewModel` extends `AndroidViewModel` (needs Application for DB access).
### Theme
@@ -230,7 +223,7 @@ Access status colors via `LocalHermesStatusColors.current.success`, not
```bash
git checkout dev && git pull origin dev
git checkout -b fix/issue-N-description # or feat/...
# make changes, run ./ktlint --format
# make changes, run ./gradlew ktlintFormat ktlintCheck (requires Android SDK)
git commit -m "fix(#N): description"
git push -u origin HEAD
gh pr create --base dev --title "fix(#N): description" --body "Closes #N"
@@ -316,8 +309,8 @@ com.m57.hermescontrol/
## Further Reading
- [README.md](README.md) — human-facing overview, features, screenshots, tech stack
- [DESIGN.md](DESIGN.md) — architecture and design decisions, rationale for the
patterns in this doc (Navigation3, HermesScaffold, theme presets, etc.)
- [DESIGN.md](DESIGN.md) — visual and interaction requirements, accessibility, and token source map
- [THEMES.md](app/src/main/java/com/m57/hermescontrol/theme/THEMES.md) — theme template, dispatcher, and mode fallback implementation
- [CONTRIBUTING.md](CONTRIBUTING.md) — contributor workflow, PR checklist, code style
- [.github/workflows/android.yml](.github/workflows/android.yml) — CI pipeline source of truth
- [.github/workflows/merge-conflict-detector.yml](.github/workflows/merge-conflict-detector.yml) — auto-labels conflicting PRs
+12 -11
View File
@@ -20,7 +20,7 @@ If an open PR already covers it, review or improve that one instead of opening a
## PR Workflow
All code changes must go through a pull request (PR) targeting the `dev` staging branch. Directly pushing to `main` or `dev` is not allowed **except for trivial changes the maintainer explicitly okays** — when in doubt, open a PR.
Every change must go through a pull request (PR) targeting the `dev` staging branch. Do not push directly to `main` or `dev`.
1. **Pick or open an issue** to discuss the changes you want to make.
2. **Create a branch** off `dev` using the following naming convention:
@@ -30,7 +30,7 @@ All code changes must go through a pull request (PR) targeting the `dev` staging
3. **Implement your changes** and format them locally (see Code Style).
4. **Submit a PR** targeting the `dev` branch, filling the PR template.
5. **Ensure all CI checks pass** (ktlint, Android Lint, unit tests, build).
6. **Rebase onto `dev` before merge.** Maintainers squash-merge; a stale branch's version of an unrelated file can silently overwrite recent fixes on `dev` when squashed. `git fetch origin dev && git rebase origin/dev` first.
6. **Rebase onto `dev` before merge.** Run `git fetch origin dev && git rebase origin/dev`, resolve conflicts carefully, and re-run verification on the rebased result. Maintainers squash-merge; green CI does not by itself authorize a merge.
---
@@ -51,9 +51,8 @@ Keep commits **atomic**: one subject line (≤72 chars) + max 2 lines of body. I
```
fix(#431): resume last session from Room cache on cold start
Reorders init to show cached messages before WS connects,
and resumes the last session on GatewayReady instead of
always creating a blank new one.
Shows cached messages before WS connects and resumes the last
session on GatewayReady instead of creating a blank one.
```
---
@@ -63,7 +62,7 @@ always creating a blank new one.
If you use AI coding tools (including agents) to contribute:
- **Never** add the AI tool as author, co-author, or `Co-Authored-By` in commit metadata.
- Direct AI agents to [`AGENTS.md`](AGENTS.md) 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.
- Direct AI agents to [`AGENTS.md`](AGENTS.md) and [`DESIGN.md`](DESIGN.md) in the repo root — `AGENTS.md` contains operational and architecture conventions, while `DESIGN.md` defines visual, interaction, and accessibility requirements and links to the token sources. Theme implementation is documented in [`THEMES.md`](app/src/main/java/com/m57/hermescontrol/theme/THEMES.md).
---
@@ -94,7 +93,7 @@ We enforce Kotlin coding conventions and Jetpack Compose best practices.
### Compose Guidelines
- Standard screen structures must use `HermesScaffold` rather than raw Material3 `Scaffold`.
- Composable parameters must follow the standard order: `modifier` first, then event callbacks, and finally children content.
- Follow existing Compose APIs: required parameters first, `modifier` as the first optional parameter, then other optional parameters; keep trailing composable content last.
- Do **not** apply `paddingValues` on inner content inside `HermesScaffold` — the scaffold already handles top bar padding. See `AGENTS.md` for the full breakdown of this recurring bug.
- Every data screen must implement `LoadingState`, `ErrorState`, and `EmptyState` branches in its `when { }` block.
@@ -108,7 +107,8 @@ We enforce Kotlin coding conventions and Jetpack Compose best practices.
- Unit tests: `./gradlew testDebugUnitTest` (MockK). Instrumented Compose UI tests run in CI on an emulator.
- **Tests must not touch real on-device storage.** Use `@get:Rule TemporaryFolder` (or Room `inMemoryDatabaseBuilder`) — never write to real app data or external storage. CI/emulator environments are ephemeral; leaking state breaks the next run.
- Mock time/dispatchers explicitly (`StandardTestDispatcher` + `Dispatchers.setMain`) — see `AGENTS.md` test references.
- Mock time/dispatchers explicitly (`StandardTestDispatcher` + `Dispatchers.setMain`), restore `Dispatchers.Main` in teardown, and cancel test-owned scopes. Follow neighboring tests for the affected component.
- For UI changes, exercise the changed flow on a device or emulator and record the behavior observed. A built or downloaded CI APK alone is not behavioral verification. If device verification is unavailable, state that gate explicitly rather than marking it passed.
---
@@ -129,8 +129,9 @@ Before submitting your PR, please verify:
- [ ] `checkColorLiterals` passes (no hardcoded Color literals outside theme/).
- [ ] `./gradlew testDebugUnitTest` passes locally (or CI unit-tests job is green).
- [ ] No unused imports, unused parameters, or dead code.
- [ ] Every `Image` and `Icon` element has a descriptive `contentDescription` for accessibility.
- [ ] Meaningful images and icon-only actions have localized accessible labels; decorative images/icons use `contentDescription = null` to avoid redundant announcements.
- [ ] 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).
- [ ] UI changes follow [`DESIGN.md`](DESIGN.md), including applicable contrast, touch-target, font-scaling, and RTL requirements.
- [ ] Changed UI behavior was exercised on a device/emulator; the PR records the result or explicitly names the unverified gate.
- [ ] New components match the UI/UX style of similar existing screens.
- [ ] Commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) and are atomic (subject + ≤2 lines body).
+74 -228
View File
@@ -1,236 +1,63 @@
---
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"
# Hermes Mobile Design System
# 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"
## Scope and sources of truth
# 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"
This document defines visual and interaction requirements for new or changed UI.
It is not an architecture manual or a claim that every existing screen already
meets every requirement. Flag existing deviations when touching the relevant UI;
do not silently redesign unrelated screens.
# 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"
- [README.md](README.md): installation, connection, and product overview.
- [CONTRIBUTING.md](CONTRIBUTING.md): contribution workflow and verification.
- [AGENTS.md](AGENTS.md): operational rules and architecture conventions.
- [THEMES.md](app/src/main/java/com/m57/hermescontrol/theme/THEMES.md): theme implementation and extension guide.
# 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"
Kotlin sources are authoritative for implemented token values and component APIs.
Use this document for intent; investigate and report conflicts rather than assuming
that either stale prose or an existing deviation grants permission to change behavior.
# 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"
## Token source map
typography:
font-sans: "Roboto, 'Segoe UI', system-ui, sans-serif"
font-mono: "'JetBrains Mono', 'Roboto Mono', monospace"
Use semantic Kotlin tokens, not copied hex values or a parallel web/CSS token set.
Android layout dimensions use `dp`; text sizes, line heights, and tracking use `sp`.
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"
---
| Concern | Authoritative source |
| --- | --- |
| Presets and mode selection | [Theme.kt](app/src/main/java/com/m57/hermescontrol/theme/Theme.kt) |
| Material slot mapping | [PaletteTemplate.kt](app/src/main/java/com/m57/hermescontrol/theme/PaletteTemplate.kt) |
| Palette values | [presets/](app/src/main/java/com/m57/hermescontrol/theme/presets/) |
| Semantic status model | [HermesStatusColors.kt](app/src/main/java/com/m57/hermescontrol/theme/HermesStatusColors.kt) |
| Terminal, code, and diff colors | [Color.kt](app/src/main/java/com/m57/hermescontrol/theme/Color.kt) |
| Typography | [Type.kt](app/src/main/java/com/m57/hermescontrol/theme/Type.kt) |
| Spacing and shapes | [Spacing.kt](app/src/main/java/com/m57/hermescontrol/theme/Spacing.kt), [Shapes.kt](app/src/main/java/com/m57/hermescontrol/theme/Shapes.kt) |
| Motion | [Motion.kt](app/src/main/java/com/m57/hermescontrol/theme/Motion.kt) |
## 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.
Hermes Mobile (`com.m57.hermescontrol`) is a native Android client for the Hermes Agent platform. Prioritize useful information density, clear touch interactions, and timely, honest feedback over decorative visual noise. Density must not compromise accessibility.
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.
- **Form Factors**: Verify changed layouts at narrow phone widths and larger window sizes; Navigation 3 alone does not establish adaptive-layout support.
- **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:
The app supports 6 built-in presets (`Default/Slate`, `Monochrome`, `Gruvbox`, `Catppuccin`, `AMOLED`, `Nord`), plus optional Android 12+ wallpaper theming. `Theme.kt` selects the preset; `PaletteTemplate.kt` maps its colors to Material slots. AMOLED is dark-only; light mode falls back to Default.
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`).
- `MaterialTheme.colorScheme.background`: Main canvas and full-bleed chat background.
- `surface` and `surfaceContainer*`: Use the existing component's semantic surface tier for bars, panels, cards, and sheets.
- Pair surfaces with their corresponding foreground tokens; do not assume Default Dark values apply to light or dynamic themes.
2. **Accents & Tints**:
- Primary accent (`#80D5D2` Slate Bright Teal) is reserved for interactive controls, user bubbles, selected drawer icons, and active tabs.
- Primary accent 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.
- Access semantic status colors through `LocalHermesStatusColors.current`, including when dynamic Material colors are active. Do not substitute Material accent colors for success/warning/error/info.
- Pair status colors with icons or labels; color alone is insufficient, especially in Monochrome and AMOLED.
- Every preset must explicitly define `onErrorContainer`. Apply the accessibility contrast requirements below to the actual foreground/background pair.
## 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.
- **UI Fonts**: `FontFamily.Default`, using Material typography roles. Do not assume a particular system font on every device.
- **Code & Logs**: `FontFamily.Monospace`; do not promise JetBrains Mono or another named face without a bundled font and explicit wiring.
- **Scale Discipline (`Type.kt`)**:
- Screen titles: `20sp` (`title-large`) with 0.1sp tracking.
- Card headings: `16sp` (`title-medium`) with 0.15sp tracking.
@@ -240,9 +67,9 @@ The app supports 6 dynamic presets managed by `PaletteTemplate.kt` (`Default/Sla
## Layout
- **Scaffolding (`HermesScaffold`)**:
- Global `TopAppBar` containing screen title, connection indicator, profile switch dropdown, and quick action icon buttons.
- Use the shared scaffold's top bar and supported action slots; do not duplicate its insets or hardcode a parallel top-bar height.
- **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.
- **Scaffold Padding Rule**: Do not reapply the supplied `paddingValues` inside the content lambda — the scaffold already handles insets. This includes loading, error, and empty branches. Content-specific spacing is separate from scaffold insets.
- **Spacing Scale (`Spacing.kt`)**:
- `xs: 2dp`: Hairline gaps, micro dividers.
- `sm: 6dp`: Default inner padding, icon-to-text gap.
@@ -255,7 +82,7 @@ The app supports 6 dynamic presets managed by `PaletteTemplate.kt` (`Default/Sla
- 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.
- **Elevation Language**: Surface luminance layering (`surfaceContainer` tiers) and restrained `dp` 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).
@@ -263,25 +90,24 @@ The app supports 6 dynamic presets managed by `PaletteTemplate.kt` (`Default/Sla
## 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.
- `MaterialTheme.shapes.extraSmall`: `4dp`.
- `MaterialTheme.shapes.small`: `8dp`.
- `MaterialTheme.shapes.medium`: `12dp`.
- `MaterialTheme.shapes.large`: `16dp`.
- `MaterialTheme.shapes.extraLarge`: `28dp`.
- Use the existing component's shape contract. Square and circular shapes are component choices, not additional `HermesShapes` tokens.
## Components
- **Top App Bar**: Flat surface, 56dp height, contains title marquee, WS connection status pill, and action buttons.
- **Top App Bar**: Flat surface; reuse `HermesScaffold` and its supported slots instead of duplicating dimensions or controls.
- **Chat Timeline**:
- **User Bubbles**: Right-aligned, solid primary fill, high-contrast text (`onPrimary`).
- **Assistant Bubbles**: Left-aligned, `surfaceContainer` fill (`#1B2022`), full Markdown renderer support.
- **Assistant Content**: Preserve the active renderer's layout. Full-bleed prose renders on the chat background; do not force every assistant message into a filled card. Reuse the existing Markdown renderer.
- **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`.
- Inactive chips: Use the component's Material surface and foreground tokens.
- Disabled controls: Use the component's disabled styling and semantics; do not treat a single alpha as a universal contract.
- **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.
@@ -289,17 +115,37 @@ The app supports 6 dynamic presets managed by `PaletteTemplate.kt` (`Default/Sla
## Do's and Don'ts
### Do's
- **DO** use official `androidx.compose.material.icons` vectors for every UI element and status indicator.
- **DO** use vector icons for app-owned controls and status indicators, reusing existing Material icons or project vector assets.
- **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** verify contrast for the actual foreground/background pairs under the accessibility requirements below.
- **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 emoji glyphs as app-owned status icons or controls; use vectors. This does not prohibit emoji in user/assistant messages or other user-authored content.
- **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.
## Accessibility and verification requirements
These are requirements for UI work, not a declaration that the entire app has
passed an accessibility audit.
- **Touch targets**: Interactive targets must be at least `48dp` in each dimension. A smaller visible icon is fine when its actual hit target meets this requirement.
- **Text scaling**: Use `sp` and semantic typography. Exercise larger system font/display sizes; essential labels, values, and actions must remain readable and reachable rather than clipped into fixed-height containers.
- **TalkBack**: Give meaningful images and icon-only actions localized labels. Decorative icons use `contentDescription = null`; avoid announcing the same label twice. Verify focus order and state announcements in the changed flow.
- **RTL**: Use start/end spacing and auto-mirrored directional icons where appropriate. Check Arabic layout, mixed-direction text, and action order; do not mirror non-directional icons arbitrarily.
- **Contrast**: Target at least `4.5:1` for normal text, `3:1` for large text, and `3:1` for meaningful non-text indicators/control boundaries against adjacent colors. This is not a blanket requirement that every decorative container contrast with every other container by `3:1`.
- **Themes**: Check relevant light/dark presets and dynamic colors. Do not rely on color alone to convey selection, failure, or success.
- **Narrow layouts**: Check long localized labels, keyboard-open states, and scrolling at small widths; density must not hide essential actions.
[ThemePaletteTest.kt](app/src/test/java/com/m57/hermescontrol/theme/ThemePaletteTest.kt)
currently guards error slot pairs, full-bleed prose/header contrast, and theme-mode
invariants. Those tests are useful but do not establish contrast or accessibility
compliance for every rendered component, alpha blend, or dynamic palette.
Record device/emulator steps and observed results for changed UI; explicitly name
any gate that could not be verified.
+20 -12
View File
@@ -30,7 +30,7 @@
## Overview
**Hermes Mobile** is the native Android client for [Hermes Agent](https://hermes-agent.nousresearch.com). It connects securely to your local Hermes gateway (REST API and WebSocket TUI Gateway) over LAN, giving you pocket control over your AI assistant.
**Hermes Mobile** is the native Android client for [Hermes Agent](https://hermes-agent.nousresearch.com). It connects to your Hermes gateway (REST API and WebSocket TUI Gateway), giving you pocket control over your AI assistant. Plain HTTP/WS connections are intended for trusted networks only; authentication does not encrypt the transport.
---
@@ -67,17 +67,23 @@ If Hermes Mobile is useful to you, consider supporting its development on [Ko-fi
- **Gateway Status:** Monitor WebSocket connection, MCP servers, messaging channels, and OAuth providers.
- **Productivity:** View and manage tasks via integrated Kanban boards, track agent milestones, and browse session history.
- **Analytics & Billing:** Usage analytics dashboard and billing/subscription management.
- **Theming:** 6 built-in color presets (Default, Monochrome, Gruvbox, Catppuccin, AMOLED, Neon Noir) plus Material You dynamic colors on supported devices.
- **Theming:** 6 built-in color presets (Default, Monochrome, Gruvbox, Catppuccin, AMOLED, Nord) plus Material You dynamic colors on supported devices.
- **Modern UX:** Native Material 3 design with pull-to-refresh, scroll-aware TopBar, and customizable bottom navigation.
---
## Quick Start
1. Install Hermes Mobile from [F-Droid](https://f-droid.org/packages/com.m57.hermescontrol/) or download the APK from the [latest GitHub release](https://github.com/Hy4ri/hermes-mobile/releases/latest).
2. Start your Hermes dashboard on a host reachable from your phone.
3. Follow [Authentication](#authentication) below to connect. Use a trusted network for plain HTTP/WS connections.
## Build from source
### Prerequisites
- **JDK 21+** (required for Kotlin compilation and the Gradle toolchain)
- **Android Studio** (Ladybug+) or a **Nix** development environment
- An **Android SDK** matching the compile SDK in [`app/build.gradle.kts`](app/build.gradle.kts), available through Android Studio or the **Nix** development environment. Set `ANDROID_HOME` or configure `sdk.dir` in your local `local.properties`.
### Build & Deploy
@@ -160,7 +166,7 @@ app/src/main/java/com/m57/hermescontrol/
├── data/ # Local (Room, AuthManager), Remote (Retrofit, OkHttp), WS (WebSocket), Models
├── notification/ # Foreground service + inline reply for chat notifications
├── theme/ # Preset-based design system (6 themes), status colors, spacing, typography
├── ui/ # 28 Compose feature screens + common components (HermesScaffold, StateViews)
├── ui/ # Compose feature screens + common components (HermesScaffold, StateViews)
├── util/ # CronExpressionFormatter, LocaleContextWrapper
└── Navigation*.kt # Navigation3 wiring, keys, screen registry, controller
```
@@ -169,17 +175,19 @@ app/src/main/java/com/m57/hermescontrol/
## Tech Stack
- **Language:** Kotlin 2.4.10 with KSP 2.3.10 compiler plugin
- **UI & Layout:** Jetpack Compose (BOM 2026.03.01) & Material 3 / Material You
- **Language:** Kotlin with KSP
- **UI & Layout:** Jetpack Compose & Material 3 / Material You
- **Navigation:** Navigation3 (Compose-first Routing)
- **Networking:** Retrofit 3.0.0, OkHttp 5.4.0, Kotlinx Serialization 1.11.0
- **Database:** Room 2.7.1 with SQLCipher 4.17.0 encryption
- **Networking:** Retrofit, OkHttp, Kotlinx Serialization
- **Database:** Room with SQLCipher encryption
- **Security:** `EncryptedSharedPreferences` (AES256-GCM), DataStore
- **Theming:** 6 built-in presets (Default, Monochrome, Gruvbox, Catppuccin, AMOLED, Neon Noir) + Material You dynamic colors
- **Image Loading:** Coil 2.7.0
- **Testing:** JUnit 5, MockK, Turbine, Espresso, Compose UI testing
- **Theming:** Built-in presets + Material You dynamic colors; see [Themes](app/src/main/java/com/m57/hermescontrol/theme/THEMES.md)
- **Image Loading:** Coil
- **Testing:** JUnit, MockK, Turbine, Espresso, Compose UI testing
- **Formatting:** `ktlint` 1.8.0 style rules (checked automatically in CI)
Dependency versions are maintained in [`gradle/libs.versions.toml`](gradle/libs.versions.toml); build configuration and dependency scopes live in [`app/build.gradle.kts`](app/build.gradle.kts).
---
## Contributing
@@ -190,7 +198,7 @@ Contributions are welcome! Please read [CONTRIBUTING.md](CONTRIBUTING.md) for ou
Help translate Hermes Mobile into your language on [Hosted Weblate](https://hosted.weblate.org/projects/hermes-mobile/hermes-mobile/)!
For developer-specific details, code conventions, and project architecture notes, refer to [AGENTS.md](AGENTS.md).
For operational conventions and architecture notes, refer to [AGENTS.md](AGENTS.md). [DESIGN.md](DESIGN.md) defines visual and interaction requirements; [THEMES.md](app/src/main/java/com/m57/hermescontrol/theme/THEMES.md) explains theme implementation.
---
@@ -1,6 +1,10 @@
# Themes
How theming works in Hermes Control, and how to add or edit a theme.
How theming works in Hermes Mobile, and how to add or edit a theme.
This is the implementation guide. [DESIGN.md](../../../../../../../../DESIGN.md)
defines visual and interaction requirements; the Kotlin sources define the actual
tokens. When changing tokens or behavior, update the relevant documentation too.
## Where themes live
@@ -120,4 +124,4 @@ active preset via `LocalHermesStatusColors`.
- ktlint 1.8.0 is enforced in CI. Run `./gradlew ktlintCheck` before committing
(the gradle task is the gate, not the standalone `ktlint` binary).
- Import order is ASCII-lexicographic (uppercase before lowercase).
- Every change goes through a PR — never push directly to `main`.
- Every change goes through a PR targeting `dev` — never push directly to `main` or `dev`.
@@ -69,6 +69,7 @@ class ChatClarifyDelegate(
ChatMessage(
role = MessageRole.USER,
content = displayContent,
displayKind = "clarify_response",
)
uiState.update { state ->
@@ -36,7 +36,7 @@ private const val MAX_ITERATIONS_SYSTEM_MARKER =
*/
internal fun ChatMessage.isUserTurnBoundary(): Boolean =
role == MessageRole.USER &&
(displayKind == null || displayKind == "steer") &&
(displayKind == null || displayKind == "steer" || displayKind == "clarify_response") &&
!content.startsWith(MAX_ITERATIONS_SYSTEM_MARKER)
/**
@@ -169,8 +169,21 @@ internal class TranscriptComparison(
val key = toolKey(a.content)
return key != null && key == toolKey(b.content)
}
// Rows generated only by Mobile never have a canonical REST counterpart.
// Do not let coincidentally identical server text claim their identity.
if (a.role == MessageRole.ASSISTANT &&
(a.displayKind == "local_feedback" || b.displayKind == "local_feedback")
) {
return false
}
if (a.role == MessageRole.USER &&
(a.displayKind == "clarify_response" || b.displayKind == "clarify_response")
) {
return false
}
val ta = trimmed.getOrPut(a.content) { a.content.trim() }
val tb = trimmed.getOrPut(b.content) { b.content.trim() }
if (a.role == MessageRole.USER && (ta.startsWith("/") || tb.startsWith("/"))) return false
if (ta == tb) return true
if (a.role == MessageRole.USER &&
captions.getOrPut(ta) { stripAttachmentRefLines(ta) } ==
@@ -345,7 +358,7 @@ internal fun mergeCachedTranscriptPage(
return (
current.map { replacements[it.id] ?: it } +
incoming.filterIndexed { index, _ -> matches[index] == null }
).dedupeById().inTranscriptOrder(current, resolvedOrders).reconcileReasoningRows()
).dedupeById().inTranscriptOrder(incoming + current, resolvedOrders).reconcileReasoningRows()
}
/** Merge one page with the current snapshot without consuming repeated results more than once. */
@@ -412,7 +425,7 @@ internal fun mergeTranscriptWithLive(
.reconcileReasoningRows()
}
/** Keep server order; place local notices after their last preceding confirmed message, not at the transcript tail. */
/** Keep server order; place local notices and commands after their last preceding confirmed message, not at the transcript tail. */
private fun List<ChatMessage>.inTranscriptOrder(
previous: List<ChatMessage>,
resolvedOrders: Map<String, Long>,
@@ -421,39 +434,44 @@ private fun List<ChatMessage>.inTranscriptOrder(
var precedingCanonical: Long? = null
var hasPendingPredecessor = false
var pendingLocalOrder: Long? = null
val noticeAnchors = mutableMapOf<String, Long>()
val pendingOrderByNotice = mutableMapOf<String, Long>()
val localAnchors = mutableMapOf<String, Long>()
val pendingOrderByLocal = mutableMapOf<String, Long>()
previous.forEach { message ->
val order = resolvedOrders[message.id] ?: message.canonicalOrder
if (order != null) {
precedingCanonical = order
hasPendingPredecessor = false
pendingLocalOrder = null
} else if (message.role != MessageRole.SYSTEM) {
hasPendingPredecessor = true
pendingLocalOrder = message.localOrder
} else {
noticeAnchors[message.id] =
} else if (message.isPermanentlyLocal()) {
localAnchors[message.id] =
if (hasPendingPredecessor) {
Long.MAX_VALUE
} else {
precedingCanonical?.takeIf { it >= 0L }
?: latestCanonical
}
if (hasPendingPredecessor) pendingOrderByNotice[message.id] = pendingLocalOrder ?: Long.MAX_VALUE
if (hasPendingPredecessor) pendingOrderByLocal[message.id] = pendingLocalOrder ?: Long.MAX_VALUE
} else {
hasPendingPredecessor = true
pendingLocalOrder = message.localOrder
}
}
val previousIndices = previous.withIndex().associate { it.value.id to it.index }
return withIndex()
.sortedWith(
compareBy<IndexedValue<ChatMessage>> {
it.value.canonicalOrder ?: noticeAnchors[it.value.id] ?: Long.MAX_VALUE
}.thenBy { if (noticeAnchors[it.value.id]?.let { anchor -> anchor != Long.MAX_VALUE } == true) 1 else 0 }
.thenBy { it.value.localOrder ?: pendingOrderByNotice[it.value.id] ?: Long.MAX_VALUE }
it.value.canonicalOrder ?: localAnchors[it.value.id] ?: Long.MAX_VALUE
}.thenBy { if (localAnchors[it.value.id]?.let { anchor -> anchor != Long.MAX_VALUE } == true) 1 else 0 }
.thenBy { it.value.localOrder ?: pendingOrderByLocal[it.value.id] ?: Long.MAX_VALUE }
.thenBy { previousIndices[it.value.id] ?: it.index },
).map { it.value }
}
internal fun ChatMessage.isPermanentlyLocal(): Boolean =
role == MessageRole.SYSTEM ||
(role == MessageRole.USER && (content.startsWith("/") || displayKind == "clarify_response")) ||
(role == MessageRole.ASSISTANT && displayKind == "local_feedback")
internal fun ChatMessage.isSessionStartMarker(): Boolean =
role == MessageRole.SYSTEM && (content == "Session created" || content == "Session branched")
@@ -2655,7 +2655,12 @@ class ChatViewModel(
}
private fun addAssistantMessage(text: String) {
val msg = ChatMessage(role = MessageRole.ASSISTANT, content = text)
val msg =
ChatMessage(
role = MessageRole.ASSISTANT,
content = text,
displayKind = "local_feedback",
)
_uiState.update { it.copy(messages = it.messages + msg) }
// Persist — OUTSIDE update{}
@@ -57,7 +57,11 @@ private fun ChatMessage.isSyntheticSystemRow(): Boolean =
displayKind == null &&
content.startsWith(MAX_ITERATIONS_SYSTEM_MARKER)
internal fun ChatMessage.isTimelineMarker(): Boolean = displayKind != null && displayKind != "steer"
internal fun ChatMessage.isTimelineMarker(): Boolean =
displayKind != null &&
displayKind != "steer" &&
displayKind != "clarify_response" &&
displayKind != "local_feedback"
/**
* Split a flat message list into turns for the full-bleed renderer.
@@ -581,6 +581,51 @@ class ChatPagingMergeTest {
assertTrue(sameLogicalMessage(canonical.copy(reasoningText = "new trace"), live))
}
@Test
fun assistantReplyBeginningWithSlashStillMatchesCanonicalEcho() {
val live =
ChatMessage(
id = "uuid-answer",
role = MessageRole.ASSISTANT,
content = "/help is the command you're looking for.",
)
val canonical =
live.copy(
id = "rest-session-10",
restId = null,
)
assertTrue(sameLogicalMessage(canonical, live))
}
@Test
fun permanentlyLocalRowsDoNotMatchCanonicalRowsByContent() {
val localFeedback =
ChatMessage(
id = "uuid-feedback",
role = MessageRole.ASSISTANT,
content = "Command completed successfully",
displayKind = "local_feedback",
)
val canonicalAssistant =
ChatMessage(
id = "rest-session-10",
role = MessageRole.ASSISTANT,
content = "Command completed successfully",
)
val clarify =
ChatMessage(
id = "uuid-clarify",
role = MessageRole.USER,
content = "yes",
displayKind = "clarify_response",
)
val canonicalUser = ChatMessage(id = "rest-session-11", role = MessageRole.USER, content = "yes")
assertTrue(!sameLogicalMessage(canonicalAssistant, localFeedback))
assertTrue(!sameLogicalMessage(canonicalUser, clarify))
}
@Test
fun stripGatewaySteerWrapperRemovesEnvelopesSafely() {
val standard =
@@ -748,6 +793,156 @@ class ChatPagingMergeTest {
assertEquals(listOf("rest-s-1", "live-reply", "review", "rest-s-3"), synced.map { it.id })
}
@Test
fun stopCommandAndSessionInterruptedStayInPlaceAcrossFutureSyncs() {
val prompt = ChatMessage(id = "rest-session-0", role = MessageRole.USER, content = "long running task")
val partialAssistant =
ChatMessage(id = "rest-session-1", role = MessageRole.ASSISTANT, content = "Working on it...")
val stopCommand = ChatMessage(id = "uuid-stop", role = MessageRole.USER, content = "/stop")
val interrupted =
ChatMessage(id = "uuid-interrupted", role = MessageRole.SYSTEM, content = "Session interrupted")
val current = listOf(prompt, partialAssistant, stopCommand, interrupted)
// Now later turn arrives from server: user asks something else, assistant answers
val serverRows =
listOf(
SessionMessage(
id = 0,
role = "user",
content = JsonPrimitive("long running task"),
timestamp = JsonPrimitive(1),
),
SessionMessage(
id = 1,
role = "assistant",
content = JsonPrimitive("Working on it..."),
timestamp = JsonPrimitive(2),
),
SessionMessage(
id = 2,
role = "user",
content = JsonPrimitive("next question"),
timestamp = JsonPrimitive(3),
),
SessionMessage(
id = 3,
role = "assistant",
content = JsonPrimitive("next answer"),
timestamp = JsonPrimitive(4),
),
)
val merged = applyServerPage(current, serverRows)
assertEquals(
listOf(
"rest-session-0",
"rest-session-1",
"uuid-stop",
"uuid-interrupted",
"rest-session-2",
"rest-session-3",
),
merged.map { it.canonicalRestId ?: it.id },
)
}
@Test
fun commandEchoAndOutputStayInPlaceAcrossFutureSyncs() {
val prompt = ChatMessage(id = "rest-session-0", role = MessageRole.USER, content = "what model are you?")
val answer = ChatMessage(id = "rest-session-1", role = MessageRole.ASSISTANT, content = "I am Hermes.")
val command = ChatMessage(id = "uuid-cmd", role = MessageRole.USER, content = "/reasoning")
val output =
ChatMessage(
id = "uuid-output",
role = MessageRole.ASSISTANT,
content = "Command completed successfully",
displayKind = "local_feedback",
)
val current = listOf(prompt, answer, command, output)
val serverRows =
listOf(
SessionMessage(
id = 0,
role = "user",
content = JsonPrimitive("what model are you?"),
timestamp = JsonPrimitive(1),
),
SessionMessage(
id = 1,
role = "assistant",
content = JsonPrimitive("I am Hermes."),
timestamp = JsonPrimitive(2),
),
SessionMessage(
id = 2,
role = "user",
content = JsonPrimitive("next question"),
timestamp = JsonPrimitive(3),
),
SessionMessage(
id = 3,
role = "assistant",
content = JsonPrimitive("next answer"),
timestamp = JsonPrimitive(4),
),
)
val merged = applyServerPage(current, serverRows)
assertEquals(
listOf("rest-session-0", "rest-session-1", "uuid-cmd", "uuid-output", "rest-session-2", "rest-session-3"),
merged.map { it.canonicalRestId ?: it.id },
)
}
@Test
fun clarifyAnswerStaysInPlaceAcrossFutureSyncs() {
val prompt = ChatMessage(id = "rest-session-0", role = MessageRole.USER, content = "deploy this")
val assistant = ChatMessage(id = "rest-session-1", role = MessageRole.ASSISTANT, content = "Where?")
val clarifyAnswer =
ChatMessage(
id = "uuid-clarify",
role = MessageRole.USER,
content = "production",
displayKind = "clarify_response",
)
val current = listOf(prompt, assistant, clarifyAnswer)
val serverRows =
listOf(
SessionMessage(
id = 0,
role = "user",
content = JsonPrimitive("deploy this"),
timestamp = JsonPrimitive(1),
),
SessionMessage(
id = 1,
role = "assistant",
content = JsonPrimitive("Where?"),
timestamp = JsonPrimitive(2),
),
SessionMessage(
id = 2,
role = "assistant",
content = JsonPrimitive("Deploying to production now..."),
timestamp = JsonPrimitive(3),
),
)
val merged = applyServerPage(current, serverRows)
assertEquals(
listOf("rest-session-0", "rest-session-1", "uuid-clarify", "rest-session-2"),
merged.map { it.canonicalRestId ?: it.id },
)
}
@Test
fun sessionCreatedMarkerStaysAtStartOfTranscript() {
val sessionCreated =
@@ -700,6 +700,86 @@ class ChatViewModelTest {
verify { HermesWsClient.send(WsMethods.SESSION_INTERRUPT, any(), any()) }
}
@Test
fun testSlashCommand_stop_andInterruptedNoticeStayInPlaceAcrossSync() =
runTest {
val (viewModel, sessionId) = createViewModelWithSession()
// 1. Initial messages in UI state
val m0 = ChatMessage(id = "rest-$sessionId-0", role = MessageRole.USER, content = "start task")
val m1 = ChatMessage(id = "rest-$sessionId-1", role = MessageRole.ASSISTANT, content = "running...")
val stateField =
ChatViewModel::class.java
.getDeclaredField("_uiState")
.apply { isAccessible = true }
@Suppress("UNCHECKED_CAST")
val state = stateField.get(viewModel) as MutableStateFlow<ChatUiState>
state.value = viewModel.uiState.value.copy(messages = listOf(m0, m1))
every {
HermesWsClient.send(WsMethods.SESSION_INTERRUPT, any(), any())
} answers {
val onSent = thirdArg<((String) -> Unit)?>()
onSent?.invoke("interrupt-req-1")
"interrupt-req-1"
}
// 2. User sends /stop
viewModel.sendMessage("/stop")
advanceUntilIdle()
// 3. WS returns interrupt success -> adds "Session interrupted"
mockEventsFlow.emit(WsEvent.RpcResult("interrupt-req-1", mapOf("ok" to true)))
advanceUntilIdle()
// 4. Server transcript sync arrives with newer turns
val serverRows =
listOf(
com.m57.hermescontrol.data.model.SessionMessage(
id = 0,
role = "user",
content = kotlinx.serialization.json.JsonPrimitive("start task"),
timestamp = kotlinx.serialization.json.JsonPrimitive(1),
),
com.m57.hermescontrol.data.model.SessionMessage(
id = 1,
role = "assistant",
content = kotlinx.serialization.json.JsonPrimitive("running..."),
timestamp = kotlinx.serialization.json.JsonPrimitive(2),
),
com.m57.hermescontrol.data.model.SessionMessage(
id = 2,
role = "user",
content = kotlinx.serialization.json.JsonPrimitive("next prompt"),
timestamp = kotlinx.serialization.json.JsonPrimitive(3),
),
com.m57.hermescontrol.data.model.SessionMessage(
id = 3,
role = "assistant",
content = kotlinx.serialization.json.JsonPrimitive("next reply"),
timestamp = kotlinx.serialization.json.JsonPrimitive(4),
),
)
// Simulate sync/merge with serverRows
val current = viewModel.uiState.value.messages
val merged =
com.m57.hermescontrol.ui.chat.mergeTranscriptWithLive(
com.m57.hermescontrol.ui.chat
.mapServerMessages(sessionId, serverRows, 0, true, current),
current,
chronological = true,
preserveLiveIds = true,
)
val contents = merged.map { it.content }
assertEquals(
listOf("start task", "running...", "/stop", "Session interrupted", "next prompt", "next reply"),
contents,
)
}
@Test
fun testSlashCommand_interrupt_sendsInterrupt() =
runTest {
@@ -103,6 +103,16 @@ class FullBleedTurnsTest {
assertEquals(listOf<ChatTurn>(ChatTurn.User(steer)), result)
}
@Test
fun `local feedback display kind remains assistant prose`() {
val feedback =
msg("a1", MessageRole.ASSISTANT, content = "Command completed successfully")
.copy(displayKind = "local_feedback")
val result = groupIntoTurns(listOf(feedback))
assertEquals(listOf<ChatTurn>(entries(AgentEntry.Prose(feedback))), result)
}
@Test
fun `max-iterations nudge is a SystemEvent not a user bubble`() {
// Backend persists the max-iterations notice as a plain role=user row