Compare commits

...
Author SHA1 Message Date
Bailey Dixon 258e6f5390 fix(android): keep clarify custom answers reachable 2026-08-28 22:21:53 -04:00
14 changed files with 323 additions and 21 deletions
+1
View File
@@ -17,6 +17,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
### Fixed
- **Android Clarify cards make custom answers explicit and keyboard-friendly.** Choice prompts label their Other answer field, submit trimmed text from the keyboard, and do not restore an authoritatively expired prompt after session navigation.
- **The visible Android Sphere keeps its smooth procedural motion across startup and chat.** Backgrounded and motion-disabled surfaces remain still without reducing foreground animation to a stepped ambient pulse.
### Removed
@@ -14,7 +14,6 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
@@ -25,6 +24,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Send
@@ -71,6 +71,7 @@ import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
@@ -172,26 +173,26 @@ fun HermesCardBubble(
),
shape = appearanceRoundedCornerShape(12.dp),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.height(IntrinsicSize.Min),
) {
Box(modifier = Modifier.fillMaxWidth()) {
// Accent stripe — runs full card height so tall cards keep the
// color tie. Using the SAME tertiary accent strategy as the
// voice/phone-action bubble marker in MessageBubble.kt so the
// visual language stays consistent.
Box(
modifier = Modifier
.width(3.dp)
.fillMaxHeight()
.background(accentColor),
)
modifier = Modifier.matchParentSize(),
) {
Box(
modifier = Modifier
.width(3.dp)
.fillMaxHeight()
.background(accentColor),
)
}
Column(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
.padding(start = 15.dp, top = 12.dp, end = 12.dp, bottom = 12.dp),
) {
// Header
Row(
@@ -426,6 +427,19 @@ private fun CardInputSlot(
input.kind != HermesCardInput.Kinds.CONFIRM)
)
val submitFreeText = {
val customAnswer = answerText.trim()
if (customAnswer.isNotEmpty()) {
onSubmit(
if (isMultiSelect) {
encodeClarifyMultiSelectAnswer(selectedChoices + customAnswer)
} else {
customAnswer
},
)
}
}
Column(modifier = Modifier.fillMaxWidth()) {
// Choice chips
if (input.choices.isNotEmpty()) {
@@ -515,11 +529,16 @@ private fun CardInputSlot(
InlineAnswerField(
value = answerText,
onValueChange = { answerText = it },
placeholder = stringResource(
if (input.choices.isNotEmpty()) R.string.card_other_answer_placeholder
else R.string.card_answer_placeholder,
),
onSubmit = submitFreeText,
modifier = Modifier.weight(1f),
)
if (!isMultiSelect) {
IconButton(
onClick = { onSubmit(answerText.trim()) },
onClick = submitFreeText,
enabled = answerText.isNotBlank(),
) {
Icon(
@@ -593,6 +612,8 @@ private fun CardInputSlot(
private fun InlineAnswerField(
value: String,
onValueChange: (String) -> Unit,
placeholder: String,
onSubmit: () -> Unit,
modifier: Modifier = Modifier,
) {
val shape = appearanceRoundedCornerShape(16.dp)
@@ -605,7 +626,7 @@ private fun InlineAnswerField(
) {
if (value.isEmpty()) {
Text(
text = stringResource(R.string.card_answer_placeholder),
text = placeholder,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
)
@@ -617,8 +638,12 @@ private fun InlineAnswerField(
color = MaterialTheme.colorScheme.onSurface,
),
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
keyboardActions = KeyboardActions(onSend = { onSubmit() }),
maxLines = 3,
modifier = Modifier.fillMaxWidth(),
modifier = Modifier
.fillMaxWidth()
.semantics { contentDescription = placeholder },
)
}
}
@@ -2509,6 +2509,30 @@ class ChatViewModel : ViewModel() {
}
is GatewayBackgroundInteractionEvent.Expired -> {
if (key != null) {
val checkpoint = backgroundTurnCheckpoints[key]
val checkpointAsk = checkpoint?.pendingAsk
if (checkpoint != null && checkpointAsk != null &&
checkpointAsk.kind == event.ask.kind.name &&
(event.ask.kind == GatewayAsk.Kind.APPROVAL ||
checkpointAsk.requestId == event.ask.requestId)
) {
val updated = checkpoint.copy(
pendingAsk = null,
updatedAt = System.currentTimeMillis(),
)
// Recovery consults memory before disk, so retire the
// stale card synchronously before a navigation can
// reclaim this turn. Persist the same exact owner so
// process restart cannot resurrect it either.
backgroundTurnCheckpoints[key] = updated
chatTurnCheckpointStore?.let { store ->
viewModelScope.launch {
checkpointMutex.withLock {
runCatching { store.write(updated) }
}
}
}
}
backgroundNeedsInputKeys -= key
backgroundPendingInteractions.computeIfPresent(key) { _, current ->
if (current.ask.kind == event.ask.kind &&
@@ -3263,6 +3263,7 @@
<string name="card_send_answer_a11y">Enviar resposta</string>
<string name="card_submit">Enviar</string>
<string name="card_answer_placeholder">Digite uma resposta…</string>
<string name="card_other_answer_placeholder">Outra (digite sua resposta)…</string>
<string name="session_ttl_title">Manter este pareamento por…</string>
<string name="session_ttl_intro">Seu celular se reconectará automaticamente durante este período. Você pode revogar o pareamento a qualquer momento.</string>
<string name="session_ttl_stays_paired">Este dispositivo permanecerá pareado até você revogá-lo.</string>
@@ -3363,6 +3363,7 @@
<string name="card_send_answer_a11y">发送回答</string>
<string name="card_submit">提交</string>
<string name="card_answer_placeholder">输入回答…</string>
<string name="card_other_answer_placeholder">其他(输入自定义回答)…</string>
<string name="session_ttl_title">保持配对的时长…</string>
<string name="session_ttl_intro">在此时间内,手机会自动重新连接。您可以随时撤销配对。</string>
<string name="session_ttl_stays_paired">此设备会保持配对,直到您主动撤销。</string>
+1
View File
@@ -3431,6 +3431,7 @@
<string name="card_send_answer_a11y">Antwort senden</string>
<string name="card_submit">Absenden</string>
<string name="card_answer_placeholder">Antwort eingeben…</string>
<string name="card_other_answer_placeholder">Andere (eigene Antwort eingeben)…</string>
<string name="session_ttl_title">Diese Kopplung behalten für…</string>
<string name="session_ttl_intro">Dein Smartphone verbindet sich in diesem Zeitraum automatisch erneut. Du kannst die Kopplung jederzeit widerrufen.</string>
<string name="session_ttl_stays_paired">Dieses Gerät bleibt gekoppelt, bis du es widerrufst.</string>
+1
View File
@@ -3094,6 +3094,7 @@
<string name="card_send_answer_a11y">Enviar respuesta</string>
<string name="card_submit">Entregar</string>
<string name="card_answer_placeholder">Escribe una respuesta...</string>
<string name="card_other_answer_placeholder">Otra (escribe tu respuesta)…</string>
<string name="session_ttl_title">Mantenga este emparejamiento para...</string>
<string name="session_ttl_intro">Su teléfono se volverá a conectar automáticamente durante esta ventana. Puedes revocar el emparejamiento en cualquier momento.</string>
<string name="session_ttl_stays_paired">Este dispositivo permanecerá emparejado hasta que lo revoques.</string>
+1
View File
@@ -3439,6 +3439,7 @@
<string name="card_send_answer_a11y">回答を送信する</string>
<string name="card_submit">提出する</string>
<string name="card_answer_placeholder">答えを入力してください…</string>
<string name="card_other_answer_placeholder">その他(自由回答を入力)…</string>
<string name="session_ttl_title">このペアリングをしばらく保持してください…</string>
<string name="session_ttl_intro">この期間中に電話は自動的に再接続します。ペアリングはいつでも取り消すことができます。</string>
<string name="session_ttl_stays_paired">このデバイスは、取り消すまでペアリングされたままになります。</string>
+1
View File
@@ -3298,6 +3298,7 @@
<string name="card_send_answer_a11y">Отправить ответ</string>
<string name="card_submit">Отправить</string>
<string name="card_answer_placeholder">Введите ответ…</string>
<string name="card_other_answer_placeholder">Другое (введите свой ответ)…</string>
<string name="session_ttl_title">Сохранить это сопряжение на…</string>
<string name="session_ttl_intro">Ваш телефон будет автоматически переподключаться в течение этого периода. Вы можете отозвать сопряжение в любой момент.</string>
<string name="session_ttl_stays_paired">Это устройство останется сопряженным, пока вы не отзовёте его.</string>
+1
View File
@@ -3754,6 +3754,7 @@
<string name="card_send_answer_a11y">Send answer</string>
<string name="card_submit">Submit</string>
<string name="card_answer_placeholder">Type an answer…</string>
<string name="card_other_answer_placeholder">Other (type your answer)…</string>
<string name="session_ttl_title">Keep this pairing for…</string>
<string name="session_ttl_intro">Your phone will reconnect automatically during this window. You can revoke the pairing at any time.</string>
<string name="session_ttl_stays_paired">This device will stay paired until you revoke it.</string>
@@ -0,0 +1,103 @@
package com.hermesandroid.relay.screenshots
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.test.hasScrollAction
import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.performTouchInput
import androidx.compose.ui.test.swipeUp
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.github.takahirom.roborazzi.captureRoboImage
import com.hermesandroid.relay.data.HermesCard
import com.hermesandroid.relay.data.HermesCardInput
import com.hermesandroid.relay.ui.components.HermesCardBubble
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
import java.io.File
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
@RunWith(AndroidJUnit4::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class ClarifyCardScreenshotTest {
@get:Rule
val compose = createComposeRule()
@Test
@Config(qualifiers = "w320dp-h568dp-xhdpi")
fun compactPhoneLongOptions() = capture("01-compact-phone-long-options.png", 1f)
@Test
@Config(qualifiers = "w320dp-h568dp-xhdpi")
fun compactPhoneLargeText() = capture("02-compact-phone-font-1_5.png", 1.5f)
@Test
@Config(qualifiers = "w720dp-h360dp-xhdpi")
fun landscape() = capture("03-landscape-720x360.png", 1f)
@Test
@Config(qualifiers = "w330dp-h720dp-xhdpi")
fun foldablePaneWidth() = capture("04-foldable-narrow-pane.png", 1.3f)
private fun capture(fileName: String, fontScale: Float) {
compose.setContent {
val density = LocalDensity.current
CompositionLocalProvider(
LocalDensity provides Density(density.density, fontScale),
) {
HermesRelayTheme(themePreference = "dark") {
LazyColumn(
Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.padding(12.dp),
) {
item {
HermesCardBubble(
card = clarifyCard(),
cardKey = "clarify-render",
dispatches = emptyList(),
onActionTap = { _, _ -> },
onInputSubmit = { _, _ -> },
)
}
}
}
}
}
compose.onNodeWithText("Other (type your answer)…").assertExists()
repeat(4) { compose.onNode(hasScrollAction()).performTouchInput { swipeUp() } }
val evidenceDir = File("build/ui-evidence/clarify-card")
evidenceDir.mkdirs()
compose.onRoot().captureRoboImage(File(evidenceDir, fileName).path)
}
private fun clarifyCard() = HermesCard(
type = HermesCard.BuiltInTypes.ASK_CLARIFY,
title = "Hermes needs clarification",
body = "Which deployment approach should I use for the migration?",
input = HermesCardInput(
kind = HermesCardInput.Kinds.CHOICE,
choices = listOf(
"Migrate everything immediately and accept a short maintenance window",
"Keep both systems running while traffic moves in measured stages",
"Pause until every downstream consumer has been verified",
"Use a reversible canary rollout with automatic rollback thresholds",
),
allowFreeText = true,
),
)
}
@@ -13,7 +13,10 @@ import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.click
import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performImeAction
import androidx.compose.ui.test.performTextInput
import androidx.compose.ui.test.performTouchInput
import androidx.compose.ui.test.swipeDown
import androidx.compose.ui.test.swipeUp
@@ -147,6 +150,74 @@ class HermesApprovalCardInteractionTest {
compose.runOnIdle { assertEquals(listOf("[\"prod\",\"dev\"]"), answers) }
}
@Test
fun `custom clarify answer uses IME send and trims exactly once`() {
val answers = mutableListOf<String>()
compose.setContent {
MaterialTheme {
HermesCardBubble(
card = clarifyCard(),
cardKey = "clarify-custom",
dispatches = emptyList(),
onActionTap = { _, _ -> },
onInputSubmit = { _, value -> answers += value },
)
}
}
val field = compose.onNodeWithContentDescription("Other (type your answer)…")
field.performTextInput(" use a hybrid rollout ")
field.performImeAction()
compose.runOnIdle { assertEquals(listOf("use a hybrid rollout"), answers) }
}
@Test
fun `blank custom clarify answer cannot submit from icon or IME`() {
val answers = mutableListOf<String>()
compose.setContent {
MaterialTheme {
HermesCardBubble(
card = clarifyCard(),
cardKey = "clarify-blank",
dispatches = emptyList(),
onActionTap = { _, _ -> },
onInputSubmit = { _, value -> answers += value },
)
}
}
val field = compose.onNodeWithContentDescription("Other (type your answer)…")
field.performTextInput(" ")
compose.onNodeWithContentDescription("Send answer").assertIsNotEnabled()
field.performImeAction()
compose.runOnIdle { assertEquals(emptyList<String>(), answers) }
}
@Test
fun `multi select IME submission combines choices and custom answer`() {
val answers = mutableListOf<String>()
compose.setContent {
MaterialTheme {
HermesCardBubble(
card = clarifyCard(multiSelect = true),
cardKey = "clarify-multi-custom",
dispatches = emptyList(),
onActionTap = { _, _ -> },
onInputSubmit = { _, value -> answers += value },
)
}
}
compose.onNodeWithText("stage").performTouchInput { click() }
val field = compose.onNodeWithContentDescription("Other (type your answer)…")
field.performTextInput("canary first")
field.performImeAction()
compose.runOnIdle { assertEquals(listOf("[\"stage\",\"canary first\"]"), answers) }
}
private fun approvalCard() = HermesCard(
type = HermesCard.BuiltInTypes.ASK_APPROVAL,
title = "Approval requested",
@@ -173,6 +244,17 @@ class HermesApprovalCardInteractionTest {
id = CARD_KEY,
)
private fun clarifyCard(multiSelect: Boolean = false) = HermesCard(
type = HermesCard.BuiltInTypes.ASK_CLARIFY,
title = "Choose environments",
input = HermesCardInput(
kind = HermesCardInput.Kinds.CHOICE,
choices = listOf("dev", "stage", "prod"),
multiSelect = multiSelect,
allowFreeText = true,
),
)
private companion object {
const val CARD_KEY = "approval-session-a"
}
@@ -11,6 +11,7 @@ import com.hermesandroid.relay.data.ChatTurnCheckpoint
import com.hermesandroid.relay.data.ChatTurnCheckpointStore
import com.hermesandroid.relay.data.ChatTurnToolCheckpoint
import com.hermesandroid.relay.data.ChatTurnUserCheckpoint
import com.hermesandroid.relay.data.HermesCard
import com.hermesandroid.relay.data.HermesCardDispatch
import com.hermesandroid.relay.data.MessageRole
import com.hermesandroid.relay.data.Profile
@@ -1049,6 +1050,65 @@ class ChatViewModelGatewayInboundTurnTest {
assertTrue(gatewayHarness.rpcLog.none { it.first == "session.interrupt" })
}
@Test
fun detachedClarifyExpiryCannotRestoreAStaleCardAfterNavigation() {
val secondSession = "stored-session-b"
val contextKey = AgentDisplay.profileContextKey("connection-a", null)
val checkpointStore = MemoryCheckpointStore()
gatewayHarness.resumeLiveSessionIds[secondSession] = "live-b"
viewModel.setChatTurnCheckpointStore(checkpointStore)
viewModel.switchProfileContext(contextKey, STORED_SESSION_ID)
viewModel.sendMessage("Ask before continuing")
gatewayHarness.awaitRpc("prompt.submit")
serverWs.send(
gatewayHarness.eventFrame(
"clarify.request",
buildJsonObject {
put("request_id", "clarify-detached")
put("question", "Which rollout?")
put("choices", buildJsonArray {
add(JsonPrimitive("canary"))
add(JsonPrimitive("all at once"))
})
},
"live-resumed",
),
)
awaitCondition { viewModel.pendingAsk.value?.ask?.requestId == "clarify-detached" }
viewModel.switchSession(secondSession)
gatewayHarness.awaitRpcCount("session.resume", 2)
awaitCondition { handler.currentSessionId.value == secondSession }
awaitCondition { checkpointStore.checkpoint?.pendingAsk?.requestId == "clarify-detached" }
serverWs.send(
gatewayHarness.eventFrame(
"clarify.expire",
buildJsonObject { put("request_id", "clarify-detached") },
"live-resumed",
),
)
awaitCondition {
checkpointStore.checkpoint != null && checkpointStore.checkpoint?.pendingAsk == null
}
gatewayHarness.recoveryRunning = true
gatewayHarness.recoveryAssistant = "Waiting for rollout choice"
viewModel.switchSession(STORED_SESSION_ID)
gatewayHarness.awaitRpc("session.activate")
awaitCondition {
handler.currentSessionId.value == STORED_SESSION_ID && handler.isStreaming.value
}
assertNull(viewModel.pendingAsk.value)
assertTrue(
handler.messages.value.flatMap { it.cards }
.none { it.type == HermesCard.BuiltInTypes.ASK_CLARIFY },
)
assertTrue(gatewayHarness.rpcLog.none { it.first == "clarify.respond" })
}
@Test
fun stopOnUnsolicitedTurnInterruptsTheGatewaySession() {
serverWs.send(gatewayHarness.eventFrame("message.start", null, "live-resumed"))
+6 -6
View File
@@ -13,7 +13,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "28d58a3b9803968124ea6581fd9dbb3a0ce2a9ee1c946228bb6f0b79f987a24a",
"main": "f300cbaf71e3600130e51120b76272fbcdd5ea1d073c95c684d054ced457747d",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -48,7 +48,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "28d58a3b9803968124ea6581fd9dbb3a0ce2a9ee1c946228bb6f0b79f987a24a",
"main": "f300cbaf71e3600130e51120b76272fbcdd5ea1d073c95c684d054ced457747d",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -72,7 +72,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "28d58a3b9803968124ea6581fd9dbb3a0ce2a9ee1c946228bb6f0b79f987a24a",
"main": "f300cbaf71e3600130e51120b76272fbcdd5ea1d073c95c684d054ced457747d",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -96,7 +96,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "28d58a3b9803968124ea6581fd9dbb3a0ce2a9ee1c946228bb6f0b79f987a24a",
"main": "f300cbaf71e3600130e51120b76272fbcdd5ea1d073c95c684d054ced457747d",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -120,7 +120,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "28d58a3b9803968124ea6581fd9dbb3a0ce2a9ee1c946228bb6f0b79f987a24a",
"main": "f300cbaf71e3600130e51120b76272fbcdd5ea1d073c95c684d054ced457747d",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -135,7 +135,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "28d58a3b9803968124ea6581fd9dbb3a0ce2a9ee1c946228bb6f0b79f987a24a",
"main": "f300cbaf71e3600130e51120b76272fbcdd5ea1d073c95c684d054ced457747d",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {