Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
16233a712d | ||
|
|
0e8b44080d |
@@ -6,6 +6,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [Android 1.13.2] - 2026-08-25
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Android session rows stay neutral when optional live activity is unavailable or still loading.** Directory refreshes no longer restore a persistent Checking state, and full-row activity borders are reserved for actual Starting or Working turns.
|
||||
|
||||
## [Android 1.13.1] - 2026-08-25
|
||||
|
||||
### Fixed
|
||||
|
||||
+6
-7
@@ -1,10 +1,10 @@
|
||||
# Hermes-Relay Android v1.13.1
|
||||
# Hermes-Relay Android v1.13.2
|
||||
|
||||
**Release Date:** August 25, 2026
|
||||
|
||||
## Download
|
||||
|
||||
> Installing on your phone? Download `hermes-relay-1.13.1-sideload-release.apk` and tap it for the full feature set, or install the conservative build from [Google Play](https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay).
|
||||
> Installing on your phone? Download `hermes-relay-1.13.2-sideload-release.apk` and tap it for the full feature set, or install the conservative build from [Google Play](https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay).
|
||||
|
||||
The `.aab` file is a Play Console upload bundle and cannot be installed by tapping it on a phone.
|
||||
|
||||
@@ -12,17 +12,16 @@ Verify the download against `SHA256SUMS.txt`. See the [sideload guide](https://h
|
||||
|
||||
## Summary
|
||||
|
||||
This patch makes Android session activity follow live Hermes runtime state instead of a five-minute recency estimate. It keeps Working, Starting, Needs input, Idle, Checking, Unavailable, and Background work accurate while preserving stale state until a complete, unambiguous snapshot can safely replace it.
|
||||
This patch keeps session rows neutral while optional live activity is unavailable or still loading. Full-row activity borders now appear only for actual Starting or Working turns.
|
||||
|
||||
## Fixed
|
||||
|
||||
- Derive session activity from the authoritative live runtime snapshot rather than Dashboard recency.
|
||||
- Preserve prior activity when a refresh is incomplete, unsupported, or ambiguously scoped.
|
||||
- Keep session drawer labels, timestamps, and active-turn ownership aligned with the exact profile and session.
|
||||
- Stop unavailable or in-flight activity checks from presenting a persistent Checking state.
|
||||
- Reserve full-row activity borders for actual Starting and Working turns.
|
||||
|
||||
## Install / Verify
|
||||
|
||||
- App version: **1.13.1** (versionCode **50**).
|
||||
- App version: **1.13.2** (versionCode **51**).
|
||||
- Standard Chat, sessions, Manage, sharing, profile switching, and Vanilla Hermes voice continue to work against unmodified upstream Hermes.
|
||||
- Granular Device Control remains sideload-only; the Google Play build continues to ship Hermes Bridge Core without AccessibilityService Device Control.
|
||||
- The optional Relay plugin remains unnecessary for standard Android chat, sessions, Manage, and Vanilla Hermes voice.
|
||||
|
||||
@@ -1 +1 @@
|
||||
Session activity now follows live Hermes runtime state instead of a recent-activity estimate. Working, Starting, Needs input, Idle, Checking, Unavailable, and Background work stay accurate, and stale state clears only after a complete, unambiguous update.
|
||||
Session rows now stay neutral while optional live activity is unavailable or still loading. Full-row activity borders appear only for actual Starting or Working turns.
|
||||
|
||||
@@ -1 +1 @@
|
||||
会话活动现在依据 Hermes 的实时运行状态,而不是最近活动时间估算。工作中、启动中、需要输入、空闲、检查中、不可用和后台工作等状态会保持准确;只有完整且明确的更新才会清除旧状态。
|
||||
当可选的实时活动状态不可用或仍在加载时,会话行现在保持中性显示。只有会话真正处于“启动中”或“工作中”时,才会显示整行活动边框。
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
{
|
||||
"versions": [
|
||||
{
|
||||
"version": "1.13.2",
|
||||
"title": "Clear session activity",
|
||||
"date": "2026-08-25",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Show only confirmed activity",
|
||||
"bullets": [
|
||||
"Keep session rows neutral while optional live activity is unavailable or still loading.",
|
||||
"Show full-row activity borders only during actual Starting or Working turns."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.13.1",
|
||||
"title": "Accurate session activity",
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
v1.13.1 - Accurate session activity
|
||||
v1.13.2 - Clear session activity
|
||||
|
||||
* Follow live Hermes runtime state for Working, Starting, Needs input, and Idle.
|
||||
* Keep stale activity visible until a complete, unambiguous snapshot clears it.
|
||||
* Distinguish Checking, Unavailable, and Background work in the session drawer.
|
||||
* Keep session rows neutral while optional live activity is unavailable or loading.
|
||||
* Show full-row activity borders only during actual Starting or Working turns.
|
||||
|
||||
@@ -90,10 +90,10 @@ data class SessionActivityRecord(
|
||||
}
|
||||
}
|
||||
|
||||
/** Presentation projection that never labels uncertain or background activity as Working. */
|
||||
/** Presentation projection that never labels missing optional runtime data as session state. */
|
||||
fun presentationState(nowMillis: Long = Long.MIN_VALUE): SessionActivityState? = when (freshness) {
|
||||
SessionActivityFreshness.Revalidating -> SessionActivityState.Checking
|
||||
SessionActivityFreshness.Unavailable -> SessionActivityState.Unavailable
|
||||
SessionActivityFreshness.Revalidating -> null
|
||||
SessionActivityFreshness.Unavailable -> null
|
||||
SessionActivityFreshness.Confirmed -> when (phase(nowMillis)) {
|
||||
SessionActivityPhase.Starting -> SessionActivityState.Starting
|
||||
SessionActivityPhase.Working -> SessionActivityState.Working
|
||||
@@ -294,7 +294,9 @@ data class SessionActivityRegistry(
|
||||
|
||||
private fun observeOwner(update: SessionActivityUpdate.ObserveOwner): SessionActivityRegistry {
|
||||
val existing = records[update.owner]
|
||||
if (existing?.freshness == SessionActivityFreshness.Confirmed) return this
|
||||
// Directory rows establish ownership only. They are not live evidence and must not
|
||||
// turn an unsupported/failed active-list probe back into a permanent Checking row.
|
||||
if (existing != null) return this
|
||||
val observed = SessionActivityRecord(
|
||||
owner = update.owner,
|
||||
turnPhase = SessionActivityPhase.Idle,
|
||||
|
||||
@@ -1752,19 +1752,9 @@ private fun Modifier.sessionActivityBorder(
|
||||
state: SessionActivityState?,
|
||||
animated: Boolean,
|
||||
): Modifier {
|
||||
if (state == null) return this
|
||||
val color = when (state) {
|
||||
SessionActivityState.Starting,
|
||||
SessionActivityState.Working -> RelayRefresh.Relay
|
||||
SessionActivityState.NeedsInput -> RelayRefresh.Amber
|
||||
SessionActivityState.BackgroundWork,
|
||||
SessionActivityState.Checking,
|
||||
SessionActivityState.Unavailable,
|
||||
-> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
val shouldRotate = animated && (
|
||||
state == SessionActivityState.Starting || state == SessionActivityState.Working
|
||||
)
|
||||
if (!sessionActivityShowsRowBorder(state)) return this
|
||||
val color = RelayRefresh.Relay
|
||||
val shouldRotate = animated
|
||||
val phase = if (shouldRotate) {
|
||||
val transition = rememberInfiniteTransition(label = "session-activity")
|
||||
transition.animateFloat(
|
||||
|
||||
@@ -84,6 +84,10 @@ internal fun sessionDrawerStatus(
|
||||
null -> SessionDrawerStatus.Idle
|
||||
}
|
||||
|
||||
/** Desktop-style row emphasis is reserved for an actual foreground turn. */
|
||||
internal fun sessionActivityShowsRowBorder(state: SessionActivityState?): Boolean =
|
||||
state == SessionActivityState.Starting || state == SessionActivityState.Working
|
||||
|
||||
/**
|
||||
* Normalizes live activity to the drawer's profile-scoped row identity.
|
||||
*
|
||||
|
||||
@@ -10,17 +10,19 @@ class SessionActivityRegistryTest {
|
||||
private val scope = SessionActivityScope.of("connection-a", "default")
|
||||
|
||||
@Test
|
||||
fun `directory owner is checking until status is unavailable or confirms idle`() {
|
||||
fun `directory owner stays neutral until status confirms live activity`() {
|
||||
val checking = SessionActivityRegistry().reduce(
|
||||
SessionActivityUpdate.ObserveOwner(owner, generation = 1, observedAtMillis = 1),
|
||||
)
|
||||
assertEquals(SessionActivityPhase.Idle, checking.record(owner)?.phase())
|
||||
assertEquals(SessionActivityState.Checking, checking.record(owner)?.presentationState())
|
||||
assertEquals(SessionActivityFreshness.Revalidating, checking.record(owner)?.freshness)
|
||||
assertNull(checking.record(owner)?.presentationState())
|
||||
|
||||
val unavailable = checking.reduce(
|
||||
SessionActivityUpdate.StatusUnavailable(scope, generation = 1, observedAtMillis = 2),
|
||||
)
|
||||
assertEquals(SessionActivityState.Unavailable, unavailable.record(owner)?.presentationState())
|
||||
assertEquals(SessionActivityFreshness.Unavailable, unavailable.record(owner)?.freshness)
|
||||
assertNull(unavailable.record(owner)?.presentationState())
|
||||
|
||||
val confirmedIdle = checking.reduce(activeList(scope, generation = 1))
|
||||
assertEquals(SessionActivityPhase.Idle, confirmedIdle.record(owner)?.phase())
|
||||
@@ -28,6 +30,17 @@ class SessionActivityRegistryTest {
|
||||
assertNull(confirmedIdle.record(owner)?.presentationState())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `directory refresh cannot restore checking after active status is unavailable`() {
|
||||
val state = SessionActivityRegistry()
|
||||
.reduce(SessionActivityUpdate.ObserveOwner(owner, generation = 1, observedAtMillis = 1))
|
||||
.reduce(SessionActivityUpdate.StatusUnavailable(scope, generation = 1, observedAtMillis = 2))
|
||||
.reduce(SessionActivityUpdate.ObserveOwner(owner, generation = 1, observedAtMillis = 3))
|
||||
|
||||
assertEquals(SessionActivityFreshness.Unavailable, state.record(owner)?.freshness)
|
||||
assertNull(state.record(owner)?.presentationState())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `directory observation cannot downgrade confirmed live evidence`() {
|
||||
val state = SessionActivityRegistry()
|
||||
@@ -184,7 +197,7 @@ class SessionActivityRegistryTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `failed or unsupported status refresh is unavailable rather than idle`() {
|
||||
fun `failed or unsupported status refresh preserves evidence but presents a neutral row`() {
|
||||
val state = SessionActivityRegistry()
|
||||
.reduce(
|
||||
SessionActivityUpdate.LiveState(
|
||||
@@ -206,7 +219,7 @@ class SessionActivityRegistryTest {
|
||||
|
||||
assertEquals(SessionActivityPhase.Working, state.record(owner)?.phase())
|
||||
assertEquals(SessionActivityFreshness.Unavailable, state.record(owner)?.freshness)
|
||||
assertEquals(SessionActivityState.Unavailable, state.record(owner)?.presentationState())
|
||||
assertNull(state.record(owner)?.presentationState())
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -226,7 +239,7 @@ class SessionActivityRegistryTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `presentation keeps starting background and revalidation distinct from working`() {
|
||||
fun `presentation keeps starting and background distinct while revalidation stays neutral`() {
|
||||
val starting = SessionActivityRegistry().reduce(
|
||||
SessionActivityUpdate.LocalSend(owner, generation = 1, observedAtMillis = 1),
|
||||
)
|
||||
@@ -240,7 +253,7 @@ class SessionActivityRegistryTest {
|
||||
val checking = starting.reduce(
|
||||
SessionActivityUpdate.BeginGeneration(scope, generation = 2, observedAtMillis = 2),
|
||||
)
|
||||
assertEquals(SessionActivityState.Checking, checking.record(owner)?.presentationState())
|
||||
assertNull(checking.record(owner)?.presentationState())
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -287,7 +300,7 @@ class SessionActivityRegistryTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `restored needs-input checkpoint stays checking until live confirmation`() {
|
||||
fun `restored needs-input checkpoint stays neutral until live confirmation`() {
|
||||
val state = SessionActivityRegistry().reduce(
|
||||
SessionActivityUpdate.RestoreCheckpoint(
|
||||
owner = owner,
|
||||
@@ -298,7 +311,7 @@ class SessionActivityRegistryTest {
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(SessionActivityState.Checking, state.record(owner)?.presentationState())
|
||||
assertNull(state.record(owner)?.presentationState())
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -323,7 +336,7 @@ class SessionActivityRegistryTest {
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(SessionActivityState.Checking, state.record(owner)?.presentationState())
|
||||
assertNull(state.record(owner)?.presentationState())
|
||||
|
||||
state = state.reduce(
|
||||
SessionActivityUpdate.PendingInputOpened(
|
||||
|
||||
@@ -5,6 +5,7 @@ import com.hermesandroid.relay.data.ChatSession
|
||||
import com.hermesandroid.relay.data.SessionActivityState
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class SessionDrawerPolicyTest {
|
||||
@@ -144,6 +145,17 @@ class SessionDrawerPolicyTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `full row border is limited to foreground live work`() {
|
||||
assertTrue(sessionActivityShowsRowBorder(SessionActivityState.Starting))
|
||||
assertTrue(sessionActivityShowsRowBorder(SessionActivityState.Working))
|
||||
assertFalse(sessionActivityShowsRowBorder(SessionActivityState.NeedsInput))
|
||||
assertFalse(sessionActivityShowsRowBorder(SessionActivityState.BackgroundWork))
|
||||
assertFalse(sessionActivityShowsRowBorder(SessionActivityState.Checking))
|
||||
assertFalse(sessionActivityShowsRowBorder(SessionActivityState.Unavailable))
|
||||
assertFalse(sessionActivityShowsRowBorder(null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `profile project status and pull request filters compose`() {
|
||||
val wanted = row(
|
||||
|
||||
+8
-3
@@ -333,7 +333,7 @@ class ChatViewModelGatewayInboundTurnTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unsupportedActiveListProjectsUnavailableInsteadOfRestWorking() {
|
||||
fun unsupportedActiveListLeavesRowsNeutralAcrossDirectoryRefresh() {
|
||||
bindActivityTestDirectory()
|
||||
handler.updateSessions(
|
||||
listOf(SessionItem(id = STORED_SESSION_ID, title = "Recent", isActive = true)),
|
||||
@@ -344,9 +344,14 @@ class ChatViewModelGatewayInboundTurnTest {
|
||||
gatewayHarness.awaitRpc("session.active_list")
|
||||
|
||||
awaitCondition {
|
||||
viewModel.backgroundSessionActivityStates.value["default:$STORED_SESSION_ID"] ==
|
||||
SessionActivityState.Unavailable
|
||||
"default:$STORED_SESSION_ID" !in viewModel.backgroundSessionActivityStates.value
|
||||
}
|
||||
|
||||
viewModel.updateSessionActivityDirectory(
|
||||
rows = listOf("default" to STORED_SESSION_ID),
|
||||
)
|
||||
|
||||
assertFalse("default:$STORED_SESSION_ID" in viewModel.backgroundSessionActivityStates.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -91,9 +91,9 @@ This app is a community project and is not affiliated with or endorsed by NousRe
|
||||
Paste into Play Console → **What's new** (≤500 characters):
|
||||
|
||||
```
|
||||
v1.13.1 - Accurate session activity
|
||||
v1.13.2 - Clear session activity
|
||||
|
||||
Session activity now follows live Hermes runtime state instead of a recent-activity estimate. Working, Starting, Needs input, Idle, Checking, Unavailable, and Background work stay accurate, and stale state clears only after a complete, unambiguous update.
|
||||
Session rows now stay neutral while optional live activity is unavailable or still loading. Full-row activity borders appear only for actual Starting or Working turns.
|
||||
```
|
||||
## Category
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[versions]
|
||||
appVersionName = "1.13.1"
|
||||
appVersionCode = "50"
|
||||
appVersionName = "1.13.2"
|
||||
appVersionCode = "51"
|
||||
agp = "9.3.2"
|
||||
kotlin = "2.4.10"
|
||||
compose-bom = "2026.08.00"
|
||||
|
||||
Reference in New Issue
Block a user