Compare commits

...
Author SHA1 Message Date
Bailey Dixon 16233a712d release(android): android-v1.13.2 2026-08-25 19:45:39 -04:00
Bailey Dixon 0e8b44080d fix(android): keep unknown session activity neutral
(cherry picked from commit ae9b22a9e6)
2026-08-25 19:44:28 -04:00
14 changed files with 91 additions and 47 deletions
+6
View File
@@ -6,6 +6,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
## [Unreleased] ## [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 ## [Android 1.13.1] - 2026-08-25
### Fixed ### Fixed
+6 -7
View File
@@ -1,10 +1,10 @@
# Hermes-Relay Android v1.13.1 # Hermes-Relay Android v1.13.2
**Release Date:** August 25, 2026 **Release Date:** August 25, 2026
## Download ## 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. 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 ## 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 ## Fixed
- Derive session activity from the authoritative live runtime snapshot rather than Dashboard recency. - Stop unavailable or in-flight activity checks from presenting a persistent Checking state.
- Preserve prior activity when a refresh is incomplete, unsupported, or ambiguously scoped. - Reserve full-row activity borders for actual Starting and Working turns.
- Keep session drawer labels, timestamps, and active-turn ownership aligned with the exact profile and session.
## Install / Verify ## 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. - 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. - 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. - 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 的实时运行状态,而不是最近活动时间估算。工作中、启动中、需要输入、空闲、检查中、不可用和后台工作等状态会保持准确;只有完整且明确的更新才会清除旧状态。 当可选的实时活动状态不可用或仍在加载时,会话行现在保持中性显示。只有会话真正处于“启动中”或“工作中”时,才会显示整行活动边框。
+14
View File
@@ -1,5 +1,19 @@
{ {
"versions": [ "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", "version": "1.13.1",
"title": "Accurate session activity", "title": "Accurate session activity",
+3 -4
View File
@@ -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 session rows neutral while optional live activity is unavailable or loading.
* Keep stale activity visible until a complete, unambiguous snapshot clears it. * Show full-row activity borders only during actual Starting or Working turns.
* Distinguish Checking, Unavailable, and Background work in the session drawer.
@@ -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) { fun presentationState(nowMillis: Long = Long.MIN_VALUE): SessionActivityState? = when (freshness) {
SessionActivityFreshness.Revalidating -> SessionActivityState.Checking SessionActivityFreshness.Revalidating -> null
SessionActivityFreshness.Unavailable -> SessionActivityState.Unavailable SessionActivityFreshness.Unavailable -> null
SessionActivityFreshness.Confirmed -> when (phase(nowMillis)) { SessionActivityFreshness.Confirmed -> when (phase(nowMillis)) {
SessionActivityPhase.Starting -> SessionActivityState.Starting SessionActivityPhase.Starting -> SessionActivityState.Starting
SessionActivityPhase.Working -> SessionActivityState.Working SessionActivityPhase.Working -> SessionActivityState.Working
@@ -294,7 +294,9 @@ data class SessionActivityRegistry(
private fun observeOwner(update: SessionActivityUpdate.ObserveOwner): SessionActivityRegistry { private fun observeOwner(update: SessionActivityUpdate.ObserveOwner): SessionActivityRegistry {
val existing = records[update.owner] 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( val observed = SessionActivityRecord(
owner = update.owner, owner = update.owner,
turnPhase = SessionActivityPhase.Idle, turnPhase = SessionActivityPhase.Idle,
@@ -1752,19 +1752,9 @@ private fun Modifier.sessionActivityBorder(
state: SessionActivityState?, state: SessionActivityState?,
animated: Boolean, animated: Boolean,
): Modifier { ): Modifier {
if (state == null) return this if (!sessionActivityShowsRowBorder(state)) return this
val color = when (state) { val color = RelayRefresh.Relay
SessionActivityState.Starting, val shouldRotate = animated
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
)
val phase = if (shouldRotate) { val phase = if (shouldRotate) {
val transition = rememberInfiniteTransition(label = "session-activity") val transition = rememberInfiniteTransition(label = "session-activity")
transition.animateFloat( transition.animateFloat(
@@ -84,6 +84,10 @@ internal fun sessionDrawerStatus(
null -> SessionDrawerStatus.Idle 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. * 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") private val scope = SessionActivityScope.of("connection-a", "default")
@Test @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( val checking = SessionActivityRegistry().reduce(
SessionActivityUpdate.ObserveOwner(owner, generation = 1, observedAtMillis = 1), SessionActivityUpdate.ObserveOwner(owner, generation = 1, observedAtMillis = 1),
) )
assertEquals(SessionActivityPhase.Idle, checking.record(owner)?.phase()) 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( val unavailable = checking.reduce(
SessionActivityUpdate.StatusUnavailable(scope, generation = 1, observedAtMillis = 2), 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)) val confirmedIdle = checking.reduce(activeList(scope, generation = 1))
assertEquals(SessionActivityPhase.Idle, confirmedIdle.record(owner)?.phase()) assertEquals(SessionActivityPhase.Idle, confirmedIdle.record(owner)?.phase())
@@ -28,6 +30,17 @@ class SessionActivityRegistryTest {
assertNull(confirmedIdle.record(owner)?.presentationState()) 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 @Test
fun `directory observation cannot downgrade confirmed live evidence`() { fun `directory observation cannot downgrade confirmed live evidence`() {
val state = SessionActivityRegistry() val state = SessionActivityRegistry()
@@ -184,7 +197,7 @@ class SessionActivityRegistryTest {
} }
@Test @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() val state = SessionActivityRegistry()
.reduce( .reduce(
SessionActivityUpdate.LiveState( SessionActivityUpdate.LiveState(
@@ -206,7 +219,7 @@ class SessionActivityRegistryTest {
assertEquals(SessionActivityPhase.Working, state.record(owner)?.phase()) assertEquals(SessionActivityPhase.Working, state.record(owner)?.phase())
assertEquals(SessionActivityFreshness.Unavailable, state.record(owner)?.freshness) assertEquals(SessionActivityFreshness.Unavailable, state.record(owner)?.freshness)
assertEquals(SessionActivityState.Unavailable, state.record(owner)?.presentationState()) assertNull(state.record(owner)?.presentationState())
} }
@Test @Test
@@ -226,7 +239,7 @@ class SessionActivityRegistryTest {
} }
@Test @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( val starting = SessionActivityRegistry().reduce(
SessionActivityUpdate.LocalSend(owner, generation = 1, observedAtMillis = 1), SessionActivityUpdate.LocalSend(owner, generation = 1, observedAtMillis = 1),
) )
@@ -240,7 +253,7 @@ class SessionActivityRegistryTest {
val checking = starting.reduce( val checking = starting.reduce(
SessionActivityUpdate.BeginGeneration(scope, generation = 2, observedAtMillis = 2), SessionActivityUpdate.BeginGeneration(scope, generation = 2, observedAtMillis = 2),
) )
assertEquals(SessionActivityState.Checking, checking.record(owner)?.presentationState()) assertNull(checking.record(owner)?.presentationState())
} }
@Test @Test
@@ -287,7 +300,7 @@ class SessionActivityRegistryTest {
} }
@Test @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( val state = SessionActivityRegistry().reduce(
SessionActivityUpdate.RestoreCheckpoint( SessionActivityUpdate.RestoreCheckpoint(
owner = owner, owner = owner,
@@ -298,7 +311,7 @@ class SessionActivityRegistryTest {
), ),
) )
assertEquals(SessionActivityState.Checking, state.record(owner)?.presentationState()) assertNull(state.record(owner)?.presentationState())
} }
@Test @Test
@@ -323,7 +336,7 @@ class SessionActivityRegistryTest {
), ),
) )
assertEquals(SessionActivityState.Checking, state.record(owner)?.presentationState()) assertNull(state.record(owner)?.presentationState())
state = state.reduce( state = state.reduce(
SessionActivityUpdate.PendingInputOpened( SessionActivityUpdate.PendingInputOpened(
@@ -5,6 +5,7 @@ import com.hermesandroid.relay.data.ChatSession
import com.hermesandroid.relay.data.SessionActivityState import com.hermesandroid.relay.data.SessionActivityState
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
class SessionDrawerPolicyTest { 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 @Test
fun `profile project status and pull request filters compose`() { fun `profile project status and pull request filters compose`() {
val wanted = row( val wanted = row(
@@ -333,7 +333,7 @@ class ChatViewModelGatewayInboundTurnTest {
} }
@Test @Test
fun unsupportedActiveListProjectsUnavailableInsteadOfRestWorking() { fun unsupportedActiveListLeavesRowsNeutralAcrossDirectoryRefresh() {
bindActivityTestDirectory() bindActivityTestDirectory()
handler.updateSessions( handler.updateSessions(
listOf(SessionItem(id = STORED_SESSION_ID, title = "Recent", isActive = true)), listOf(SessionItem(id = STORED_SESSION_ID, title = "Recent", isActive = true)),
@@ -344,9 +344,14 @@ class ChatViewModelGatewayInboundTurnTest {
gatewayHarness.awaitRpc("session.active_list") gatewayHarness.awaitRpc("session.active_list")
awaitCondition { awaitCondition {
viewModel.backgroundSessionActivityStates.value["default:$STORED_SESSION_ID"] == "default:$STORED_SESSION_ID" !in viewModel.backgroundSessionActivityStates.value
SessionActivityState.Unavailable
} }
viewModel.updateSessionActivityDirectory(
rows = listOf("default" to STORED_SESSION_ID),
)
assertFalse("default:$STORED_SESSION_ID" in viewModel.backgroundSessionActivityStates.value)
} }
@Test @Test
+2 -2
View File
@@ -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): 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 ## Category
+2 -2
View File
@@ -1,6 +1,6 @@
[versions] [versions]
appVersionName = "1.13.1" appVersionName = "1.13.2"
appVersionCode = "50" appVersionCode = "51"
agp = "9.3.2" agp = "9.3.2"
kotlin = "2.4.10" kotlin = "2.4.10"
compose-bom = "2026.08.00" compose-bom = "2026.08.00"