Merge pull request #441 from Codename-11/fix/android-session-activity-fallback

fix(android): keep unknown session activity neutral
This commit is contained in:
Bailey Dixon
2026-08-25 19:41:41 -04:00
committed by GitHub
7 changed files with 57 additions and 30 deletions
+1
View File
@@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
### 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.
- **Returning from parent settings keeps Supervised Chat rendered.** Parent access now relocks without rebuilding the active navigation graph, and full Settings keeps a prominent shortcut back to Supervised Mode controls.
## [Android 1.13.1] - 2026-08-25
@@ -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,
@@ -1770,19 +1770,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(
@@ -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