Merge branch 'dev' into chore/sync-dev-to-main-1.29.6
This commit is contained in:
commit
15a0abc7a7
14 files changed
+468
-302
No files matched your search
@@ -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
|
||||
@@ -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
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user