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 ## 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. 1.
2. 2.
@@ -32,4 +32,5 @@ Fixes #
- [ ] `./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/)
- [ ] Navigation goes through `NavigationController.navigateTo()` (not `backStack.add`) - [ ] 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. Guidance for AI coding agents working on this repository.
This complements [README.md](README.md) (for humans) and [DESIGN.md](DESIGN.md) This complements [README.md](README.md) (for humans) and [DESIGN.md](DESIGN.md)
(architecture & design rationale) with agent-focused context. Before making any (visual and interaction requirements) with agent-focused operational and
non-trivial architectural change — new screens, navigation changes, theme/state architecture context. Before changing navigation, state ownership, or module
patterns, module boundaries — read `DESIGN.md` first and keep changes consistent boundaries, read the relevant implementation and the conventions below. For UI
with the decisions recorded there. If a change would contradict `DESIGN.md`, changes, also read `DESIGN.md`; for theme implementation, read
flag it explicitly rather than silently diverging. [`THEMES.md`](app/src/main/java/com/m57/hermescontrol/theme/THEMES.md).
Flag conflicts between requirements and implementation instead of silently diverging.
## Project Overview ## 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` - **Package:** `com.m57.hermescontrol`
- **Min SDK 26 / Target SDK 37 / Compile SDK 37** - **Min SDK 26 / Target SDK 37 / Compile SDK 37**
- **Kotlin 2.4.10**, KSP 2.3.10 (standalone versioning, NOT `kotlinVersion-kspVersion`) - **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 2.7.x, Navigation3, OkHttp WebSocket, Retrofit, Kotlinx Serialization - **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), Bearer token (REST) + `?token=` (WS) - **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 ## Build & Test Commands
@@ -32,7 +33,7 @@ If you have a local Android SDK (`ANDROID_HOME` set), these work:
./gradlew ktlintCheck # style check ./gradlew ktlintCheck # style check
``` ```
**ktlint standalone** (no SDK needed): **ktlint standalone fallback** (no SDK needed; not the authoritative CI gate):
```bash ```bash
# Download the matching binary # Download the matching binary
@@ -68,7 +69,7 @@ Requires `permissions: contents: write` on the `build-release` job.
## Code Style ## 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 - Import ordering is the #1 CI failure: ktlint enforces ASCII-lexicographic order
(uppercase before lowercase: `LaunchedEffect` before `collectAsState`). (uppercase before lowercase: `LaunchedEffect` before `collectAsState`).
- `const val` must use SCREAMING_SNAKE_CASE. - `const val` must use SCREAMING_SNAKE_CASE.
@@ -147,12 +148,9 @@ call unconditionally.
### ⚠ HermesScaffold Padding Foot-Gun ### ⚠ HermesScaffold Padding Foot-Gun
**This is the #1 recurring bug in this codebase.** It has been re-introduced on 4+ screens `HermesScaffold` handles system insets and the dynamic top-bar offset in its
across 3+ PRs (Settings, Achievements, Webhooks, Config — PRs #445, #454, #455). internal content wrapper. Although it passes `paddingValues` to the content
lambda, applying those values again double-stacks the padding:
**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:
```kotlin ```kotlin
HermesScaffold(...) { paddingValues -> // ← scaffold already handles top bar offset via Box 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:** **Correct pattern — do NOT apply `paddingValues` on inner content:**
```kotlin ```kotlin
@@ -185,21 +178,21 @@ Column(
modifier = Modifier.fillMaxSize(), // no .padding(paddingValues)!! modifier = Modifier.fillMaxSize(), // no .padding(paddingValues)!!
) { ... } ) { ... }
// ✅ Loading/Error/Empty states — these DO need it: // ✅ Loading/Error/Empty states inside HermesScaffold content follow the same rule:
LoadingState(modifier = Modifier.padding(paddingValues)) LoadingState(modifier = Modifier.fillMaxSize())
``` ```
**Quick test:** If your screen's top gap is wider than CronJobsScreen's, you've double-stacked. 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 also:** `references/hermes-scaffold-padding.md` in the skill doc for the full See [`HermesScaffold.kt`](app/src/main/java/com/m57/hermescontrol/ui/common/HermesScaffold.kt)
breakdown, edge cases, and timeline of previous occurrences. for the current inset implementation.
### Room Persistence ### Room Persistence
- `ChatMessageEntity` / `ChatMessageDao` / `HermesDatabase` — chat messages survive - `ChatMessageEntity` / `ChatMessageDao` / `HermesDatabase` — chat messages survive
app kills. `getMessagesForSession()` returns `suspend fun ...: List<ChatMessageEntity>` app kills. `getMessagesForSession()` returns `suspend fun ...: List<ChatMessageEntity>`
(not `Flow` — the caller controls the coroutine scope). (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). - `ChatViewModel` extends `AndroidViewModel` (needs Application for DB access).
### Theme ### Theme
@@ -230,7 +223,7 @@ Access status colors via `LocalHermesStatusColors.current.success`, not
```bash ```bash
git checkout dev && git pull origin dev git checkout dev && git pull origin dev
git checkout -b fix/issue-N-description # or feat/... 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 commit -m "fix(#N): description"
git push -u origin HEAD git push -u origin HEAD
gh pr create --base dev --title "fix(#N): description" --body "Closes #N" gh pr create --base dev --title "fix(#N): description" --body "Closes #N"
@@ -316,8 +309,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 - [DESIGN.md](DESIGN.md) — visual and interaction requirements, accessibility, and token source map
patterns in this doc (Navigation3, HermesScaffold, theme presets, etc.) - [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 - [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
+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 ## 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. 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: 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). 3. **Implement your changes** and format them locally (see Code Style).
4. **Submit a PR** targeting the `dev` branch, filling the PR template. 4. **Submit a PR** targeting the `dev` branch, filling the PR template.
5. **Ensure all CI checks pass** (ktlint, Android Lint, unit tests, build). 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 fix(#431): resume last session from Room cache on cold start
Reorders init to show cached messages before WS connects, Shows cached messages before WS connects and resumes the last
and resumes the last session on GatewayReady instead of session on GatewayReady instead of creating a blank one.
always creating a blank new one.
``` ```
--- ---
@@ -63,7 +62,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) 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 ### Compose Guidelines
- Standard screen structures must use `HermesScaffold` rather than raw Material3 `Scaffold`. - 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. - 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. - 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. - 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. - **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/). - [ ] `checkColorLiterals` passes (no hardcoded Color literals outside theme/).
- [ ] `./gradlew testDebugUnitTest` passes locally (or CI unit-tests job is green). - [ ] `./gradlew testDebugUnitTest` passes locally (or CI unit-tests job is green).
- [ ] 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. - [ ] 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. - [ ] 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). - [ ] UI changes follow [`DESIGN.md`](DESIGN.md), including applicable contrast, touch-target, font-scaling, and RTL requirements.
- [ ] New components match the UI/UX style of similar existing screens (28+ screens for reference). - [ ] 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). - [ ] Commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) and are atomic (subject + ≤2 lines body).
+74 -228
View File
@@ -1,236 +1,63 @@
--- # Hermes Mobile Design System
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 ## Scope and sources of truth
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) This document defines visual and interaction requirements for new or changed UI.
primary: "#80D5D2" It is not an architecture manual or a claim that every existing screen already
primary-container: "#303638" meets every requirement. Flag existing deviations when touching the relevant UI;
on-primary-container: "#DEE3E5" do not silently redesign unrelated screens.
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) - [README.md](README.md): installation, connection, and product overview.
status-success: "#81D692" - [CONTRIBUTING.md](CONTRIBUTING.md): contribution workflow and verification.
status-success-container: "#252B2D" - [AGENTS.md](AGENTS.md): operational rules and architecture conventions.
status-on-success: "#0F1416" - [THEMES.md](app/src/main/java/com/m57/hermescontrol/theme/THEMES.md): theme implementation and extension guide.
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) Kotlin sources are authoritative for implemented token values and component APIs.
terminal-bg: "#1E1E1E" Use this document for intent; investigate and report conflicts rather than assuming
terminal-border: "#333333" that either stale prose or an existing deviation grants permission to change behavior.
terminal-text: "#D4D4D4"
terminal-muted: "#808080"
code-keyword: "#569CD6"
code-string: "#CE9178"
code-comment: "#6A9955"
code-number: "#B5CEA8"
# Diff Viewer Tokens ## Token source map
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: Use semantic Kotlin tokens, not copied hex values or a parallel web/CSS token set.
font-sans: "Roboto, 'Segoe UI', system-ui, sans-serif" Android layout dimensions use `dp`; text sizes, line heights, and tracking use `sp`.
font-mono: "'JetBrains Mono', 'Roboto Mono', monospace"
display-large: | Concern | Authoritative source |
fontFamily: "{typography.font-sans}" | --- | --- |
fontSize: "44px" | Presets and mode selection | [Theme.kt](app/src/main/java/com/m57/hermescontrol/theme/Theme.kt) |
fontWeight: 700 | Material slot mapping | [PaletteTemplate.kt](app/src/main/java/com/m57/hermescontrol/theme/PaletteTemplate.kt) |
lineHeight: 52px | Palette values | [presets/](app/src/main/java/com/m57/hermescontrol/theme/presets/) |
letterSpacing: "-0.5px" | Semantic status model | [HermesStatusColors.kt](app/src/main/java/com/m57/hermescontrol/theme/HermesStatusColors.kt) |
display-medium: | Terminal, code, and diff colors | [Color.kt](app/src/main/java/com/m57/hermescontrol/theme/Color.kt) |
fontFamily: "{typography.font-sans}" | Typography | [Type.kt](app/src/main/java/com/m57/hermescontrol/theme/Type.kt) |
fontSize: "36px" | 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) |
fontWeight: 700 | Motion | [Motion.kt](app/src/main/java/com/m57/hermescontrol/theme/Motion.kt) |
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 ## 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: The visual language follows Material 3 guidelines strictly adapted for developer utilities:
- **Tone**: Technical, focused, utilitarian, crisp. - **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. - **Core Principle**: Zero fluff. Fast render cycles, explicit states, flat surfaces, and zero visual ambiguity.
## Colors ## 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**: 1. **Surfaces**:
- `bg-root`: Deep canvas background (`#0F1416` in Default Dark, `#000000` in AMOLED). - `MaterialTheme.colorScheme.background`: Main canvas and full-bleed chat background.
- `bg-surface`: Top app bars, bottom sheets, navigation drawer background. - `surface` and `surfaceContainer*`: Use the existing component's semantic surface tier for bars, panels, cards, and sheets.
- `bg-surface-container`: Assistant message bubbles, session list cards, settings group panels (`#1B2022`). - Pair surfaces with their corresponding foreground tokens; do not assume Default Dark values apply to light or dynamic themes.
- `bg-surface-container-high`: Dialogs, interactive chips, elevated tool surfaces (`#252B2D`).
- `bg-surface-container-highest`: Selected containers and active pills (`#303638`).
2. **Accents & Tints**: 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. - Selected navigation drawer items MUST tint icons directly to `primary` to prevent low-contrast grey-on-grey visual loss.
3. **Semantic Status Colors (`HermesStatusColors`)**: 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`). - 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.
- Every theme preset must explicitly define `onErrorContainer` (`#FFB4AB` in Default Dark) to prevent invisible text bugs. - 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 ## Typography
- **UI Fonts**: System `Roboto` / sans-serif with strict semantic scaling to prevent layout shifts. - **UI Fonts**: `FontFamily.Default`, using Material typography roles. Do not assume a particular system font on every device.
- **Code & Logs**: Tabular `JetBrains Mono` for terminal traces, tool payloads, JSON viewers, and memory tokens. - **Code & Logs**: `FontFamily.Monospace`; do not promise JetBrains Mono or another named face without a bundled font and explicit wiring.
- **Scale Discipline (`Type.kt`)**: - **Scale Discipline (`Type.kt`)**:
- Screen titles: `20sp` (`title-large`) with 0.1sp tracking. - Screen titles: `20sp` (`title-large`) with 0.1sp tracking.
- Card headings: `16sp` (`title-medium`) with 0.15sp 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 ## Layout
- **Scaffolding (`HermesScaffold`)**: - **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. - **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`)**: - **Spacing Scale (`Spacing.kt`)**:
- `xs: 2dp`: Hairline gaps, micro dividers. - `xs: 2dp`: Hairline gaps, micro dividers.
- `sm: 6dp`: Default inner padding, icon-to-text gap. - `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. - Dropdown pickers take precedence over cycle buttons to ensure all options are discoverable in one tap.
## Elevation & Depth ## 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**: - **Flat Surface Rule**:
- Chat background is flat `colorScheme.background`. - Chat background is flat `colorScheme.background`.
- User message bubbles use solid `colorScheme.primary` (no multi-color gradients). - 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 ## Shapes
- **Corner Radii Hierarchy (`Shapes.kt`)**: - **Corner Radii Hierarchy (`Shapes.kt`)**:
- `0dp` (`rounded.none`): Terminal panels, full-bleed code blocks. - `MaterialTheme.shapes.extraSmall`: `4dp`.
- `4dp` (`rounded.extra-small`): Chips, badges, inline toggles. - `MaterialTheme.shapes.small`: `8dp`.
- `8dp` (`rounded.small`): Text fields, small cards, code cards. - `MaterialTheme.shapes.medium`: `12dp`.
- `12dp` (`rounded.medium`): Standard cards, list rows, message bubbles, dialogs. - `MaterialTheme.shapes.large`: `16dp`.
- `16dp` (`rounded.large`): Large cards, bottom sheets. - `MaterialTheme.shapes.extraLarge`: `28dp`.
- `28dp` (`rounded.extra-large`): Feature cards, full-bleed surfaces. - Use the existing component's shape contract. Square and circular shapes are component choices, not additional `HermesShapes` tokens.
- `9999dp` (`rounded.full`): Interactive filter chips, timeline markers, circular icon buttons.
## Components ## 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**: - **Chat Timeline**:
- **User Bubbles**: Right-aligned, solid primary fill, high-contrast text (`onPrimary`). - **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. - **Tool Bubbles**: Expandable rows with tool vector icon, execution state indicator, parameter summary, and full payload bottom sheet.
- **Action & Filter Chips**: - **Action & Filter Chips**:
- Must represent real system state (e.g., active model, selected profile, filter criteria). - Must represent real system state (e.g., active model, selected profile, filter criteria).
- Active chips: `primaryContainer` fill with `onPrimaryContainer` text/icon. - Active chips: `primaryContainer` fill with `onPrimaryContainer` text/icon.
- Inactive chips: `surfaceContainerHigh` fill with `text-secondary` text. - Inactive chips: Use the component's Material surface and foreground tokens.
- Disabled controls: Visually greyed out with `alpha = 0.38f`. - Disabled controls: Use the component's disabled styling and semantics; do not treat a single alpha as a universal contract.
- **Dialogs & Bottom Sheets**: - **Dialogs & Bottom Sheets**:
- Standard CRUD flows must provide all 4 operations (Create, Read, Update, Delete) with confirmation gates for destructive actions. - 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. - 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 and Don'ts
### Do's ### 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** 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** use Android string `<plurals>` for count labels (`1 agent` vs `2 agents`).
- **DO** grey out disabled controls (`enabled = false`) visibly. - **DO** grey out disabled controls (`enabled = false`) visibly.
- **DO** ensure every created resource has a discoverable UI list or navigation tab. - **DO** ensure every created resource has a discoverable UI list or navigation tab.
### Don'ts ### 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** 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** apply `Modifier.padding(paddingValues)` inside `HermesScaffold` content lambdas.
- **DON'T** use gradient backgrounds on chat surfaces or message bubbles. - **DON'T** use gradient backgrounds on chat surfaces or message bubbles.
- **DON'T** create multi-layer nested cards or unnecessary decorative borders. - **DON'T** create multi-layer nested cards or unnecessary decorative borders.
- **DON'T** use cycle buttons where a dropdown menu provides immediate option visibility. - **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 ## 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. - **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. - **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. - **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. - **Modern UX:** Native Material 3 design with pull-to-refresh, scroll-aware TopBar, and customizable bottom navigation.
--- ---
## Quick Start ## 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 ### Prerequisites
- **JDK 21+** (required for Kotlin compilation and the Gradle toolchain) - **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 ### Build & Deploy
@@ -160,7 +166,7 @@ app/src/main/java/com/m57/hermescontrol/
├── data/ # Local (Room, AuthManager), Remote (Retrofit, OkHttp), WS (WebSocket), Models ├── data/ # Local (Room, AuthManager), Remote (Retrofit, OkHttp), WS (WebSocket), Models
├── notification/ # Foreground service + inline reply for chat notifications ├── notification/ # Foreground service + inline reply for chat notifications
├── theme/ # Preset-based design system (6 themes), status colors, spacing, typography ├── 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 ├── util/ # CronExpressionFormatter, LocaleContextWrapper
└── Navigation*.kt # Navigation3 wiring, keys, screen registry, controller └── Navigation*.kt # Navigation3 wiring, keys, screen registry, controller
``` ```
@@ -169,17 +175,19 @@ app/src/main/java/com/m57/hermescontrol/
## Tech Stack ## Tech Stack
- **Language:** Kotlin 2.4.10 with KSP 2.3.10 compiler plugin - **Language:** Kotlin with KSP
- **UI & Layout:** Jetpack Compose (BOM 2026.03.01) & Material 3 / Material You - **UI & Layout:** Jetpack Compose & Material 3 / Material You
- **Navigation:** Navigation3 (Compose-first Routing) - **Navigation:** Navigation3 (Compose-first Routing)
- **Networking:** Retrofit 3.0.0, OkHttp 5.4.0, Kotlinx Serialization 1.11.0 - **Networking:** Retrofit, OkHttp, Kotlinx Serialization
- **Database:** Room 2.7.1 with SQLCipher 4.17.0 encryption - **Database:** Room with SQLCipher encryption
- **Security:** `EncryptedSharedPreferences` (AES256-GCM), DataStore - **Security:** `EncryptedSharedPreferences` (AES256-GCM), DataStore
- **Theming:** 6 built-in presets (Default, Monochrome, Gruvbox, Catppuccin, AMOLED, Neon Noir) + Material You dynamic colors - **Theming:** Built-in presets + Material You dynamic colors; see [Themes](app/src/main/java/com/m57/hermescontrol/theme/THEMES.md)
- **Image Loading:** Coil 2.7.0 - **Image Loading:** Coil
- **Testing:** JUnit 5, MockK, Turbine, Espresso, Compose UI testing - **Testing:** JUnit, MockK, Turbine, Espresso, Compose UI testing
- **Formatting:** `ktlint` 1.8.0 style rules (checked automatically in CI) - **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 ## 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/)! 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 # 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 ## Where themes live
@@ -120,4 +124,4 @@ active preset via `LocalHermesStatusColors`.
- ktlint 1.8.0 is enforced in CI. Run `./gradlew ktlintCheck` before committing - ktlint 1.8.0 is enforced in CI. Run `./gradlew ktlintCheck` before committing
(the gradle task is the gate, not the standalone `ktlint` binary). (the gradle task is the gate, not the standalone `ktlint` binary).
- Import order is ASCII-lexicographic (uppercase before lowercase). - 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( ChatMessage(
role = MessageRole.USER, role = MessageRole.USER,
content = displayContent, content = displayContent,
displayKind = "clarify_response",
) )
uiState.update { state -> uiState.update { state ->
@@ -36,7 +36,7 @@ private const val MAX_ITERATIONS_SYSTEM_MARKER =
*/ */
internal fun ChatMessage.isUserTurnBoundary(): Boolean = internal fun ChatMessage.isUserTurnBoundary(): Boolean =
role == MessageRole.USER && role == MessageRole.USER &&
(displayKind == null || displayKind == "steer") && (displayKind == null || displayKind == "steer" || displayKind == "clarify_response") &&
!content.startsWith(MAX_ITERATIONS_SYSTEM_MARKER) !content.startsWith(MAX_ITERATIONS_SYSTEM_MARKER)
/** /**
@@ -169,8 +169,21 @@ internal class TranscriptComparison(
val key = toolKey(a.content) val key = toolKey(a.content)
return key != null && key == toolKey(b.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 ta = trimmed.getOrPut(a.content) { a.content.trim() }
val tb = trimmed.getOrPut(b.content) { b.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 (ta == tb) return true
if (a.role == MessageRole.USER && if (a.role == MessageRole.USER &&
captions.getOrPut(ta) { stripAttachmentRefLines(ta) } == captions.getOrPut(ta) { stripAttachmentRefLines(ta) } ==
@@ -345,7 +358,7 @@ internal fun mergeCachedTranscriptPage(
return ( return (
current.map { replacements[it.id] ?: it } + current.map { replacements[it.id] ?: it } +
incoming.filterIndexed { index, _ -> matches[index] == null } 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. */ /** Merge one page with the current snapshot without consuming repeated results more than once. */
@@ -412,7 +425,7 @@ internal fun mergeTranscriptWithLive(
.reconcileReasoningRows() .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( private fun List<ChatMessage>.inTranscriptOrder(
previous: List<ChatMessage>, previous: List<ChatMessage>,
resolvedOrders: Map<String, Long>, resolvedOrders: Map<String, Long>,
@@ -421,39 +434,44 @@ private fun List<ChatMessage>.inTranscriptOrder(
var precedingCanonical: Long? = null var precedingCanonical: Long? = null
var hasPendingPredecessor = false var hasPendingPredecessor = false
var pendingLocalOrder: Long? = null var pendingLocalOrder: Long? = null
val noticeAnchors = mutableMapOf<String, Long>() val localAnchors = mutableMapOf<String, Long>()
val pendingOrderByNotice = mutableMapOf<String, Long>() val pendingOrderByLocal = mutableMapOf<String, Long>()
previous.forEach { message -> previous.forEach { message ->
val order = resolvedOrders[message.id] ?: message.canonicalOrder val order = resolvedOrders[message.id] ?: message.canonicalOrder
if (order != null) { if (order != null) {
precedingCanonical = order precedingCanonical = order
hasPendingPredecessor = false hasPendingPredecessor = false
pendingLocalOrder = null pendingLocalOrder = null
} else if (message.role != MessageRole.SYSTEM) { } else if (message.isPermanentlyLocal()) {
hasPendingPredecessor = true localAnchors[message.id] =
pendingLocalOrder = message.localOrder
} else {
noticeAnchors[message.id] =
if (hasPendingPredecessor) { if (hasPendingPredecessor) {
Long.MAX_VALUE Long.MAX_VALUE
} else { } else {
precedingCanonical?.takeIf { it >= 0L } precedingCanonical?.takeIf { it >= 0L }
?: latestCanonical ?: 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 } val previousIndices = previous.withIndex().associate { it.value.id to it.index }
return withIndex() return withIndex()
.sortedWith( .sortedWith(
compareBy<IndexedValue<ChatMessage>> { compareBy<IndexedValue<ChatMessage>> {
it.value.canonicalOrder ?: noticeAnchors[it.value.id] ?: Long.MAX_VALUE it.value.canonicalOrder ?: localAnchors[it.value.id] ?: Long.MAX_VALUE
}.thenBy { if (noticeAnchors[it.value.id]?.let { anchor -> anchor != Long.MAX_VALUE } == true) 1 else 0 } }.thenBy { if (localAnchors[it.value.id]?.let { anchor -> anchor != Long.MAX_VALUE } == true) 1 else 0 }
.thenBy { it.value.localOrder ?: pendingOrderByNotice[it.value.id] ?: Long.MAX_VALUE } .thenBy { it.value.localOrder ?: pendingOrderByLocal[it.value.id] ?: Long.MAX_VALUE }
.thenBy { previousIndices[it.value.id] ?: it.index }, .thenBy { previousIndices[it.value.id] ?: it.index },
).map { it.value } ).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 = internal fun ChatMessage.isSessionStartMarker(): Boolean =
role == MessageRole.SYSTEM && (content == "Session created" || content == "Session branched") role == MessageRole.SYSTEM && (content == "Session created" || content == "Session branched")
@@ -2655,7 +2655,12 @@ class ChatViewModel(
} }
private fun addAssistantMessage(text: String) { 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) } _uiState.update { it.copy(messages = it.messages + msg) }
// Persist — OUTSIDE update{} // Persist — OUTSIDE update{}
@@ -57,7 +57,11 @@ private fun ChatMessage.isSyntheticSystemRow(): Boolean =
displayKind == null && displayKind == null &&
content.startsWith(MAX_ITERATIONS_SYSTEM_MARKER) 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. * 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)) 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 @Test
fun stripGatewaySteerWrapperRemovesEnvelopesSafely() { fun stripGatewaySteerWrapperRemovesEnvelopesSafely() {
val standard = val standard =
@@ -748,6 +793,156 @@ class ChatPagingMergeTest {
assertEquals(listOf("rest-s-1", "live-reply", "review", "rest-s-3"), synced.map { it.id }) 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 @Test
fun sessionCreatedMarkerStaysAtStartOfTranscript() { fun sessionCreatedMarkerStaysAtStartOfTranscript() {
val sessionCreated = val sessionCreated =
@@ -700,6 +700,86 @@ class ChatViewModelTest {
verify { HermesWsClient.send(WsMethods.SESSION_INTERRUPT, any(), any()) } 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 @Test
fun testSlashCommand_interrupt_sendsInterrupt() = fun testSlashCommand_interrupt_sendsInterrupt() =
runTest { runTest {
@@ -103,6 +103,16 @@ class FullBleedTurnsTest {
assertEquals(listOf<ChatTurn>(ChatTurn.User(steer)), result) 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 @Test
fun `max-iterations nudge is a SystemEvent not a user bubble`() { fun `max-iterations nudge is a SystemEvent not a user bubble`() {
// Backend persists the max-iterations notice as a plain role=user row // Backend persists the max-iterations notice as a plain role=user row