Skip to content

Commit deeac13

Browse files
authored
Merge pull request #3287 from wgqqqqq/wgq/mobile-parity-pr3284
fix(mobile): align iOS and Android with HarmonyOS interaction behavior
2 parents fb74967 + c53fb89 commit deeac13

25 files changed

Lines changed: 532 additions & 150 deletions

File tree

‎src/apps/mobile/android/app/src/androidTest/kotlin/com/openbitfun/mobile/app/ChatMessageBubbleTest.kt‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,22 @@ class ChatMessageBubbleTest {
139139
assertTrue(composeRule.onAllNodesWithText("Build step finished.").fetchSemanticsNodes().isEmpty())
140140
}
141141

142+
143+
@Test
144+
fun anOpenSubagentReceivesNewStepsWithoutClosing() {
145+
var block by mutableStateOf(MessageBlock.Subagent("live-owner", "Live task", true, "",
146+
listOf(MessageBlock.Text("first", "First step", false))))
147+
composeRule.setContent { Bubble(row(kind = ConversationRowKind.ASSISTANT, blocks = listOf(block))) }
148+
composeRule.onNodeWithText("Live task").performClick()
149+
composeRule.runOnIdle {
150+
block = block.copy(children = block.children + MessageBlock.Text("second", "Later step", false))
151+
}
152+
composeRule.onNodeWithText("Later step").assertIsDisplayed()
153+
composeRule.runOnIdle { block = block.copy(running = false, status = "completed") }
154+
composeRule.onNodeWithText("First step").assertIsDisplayed()
155+
composeRule.onNodeWithText("Later step").assertIsDisplayed()
156+
}
157+
142158
@Test
143159
fun aRunningSubagentPreservesUserCollapseAcrossStreamUpdates() {
144160
var block by mutableStateOf(

‎src/apps/mobile/android/app/src/androidTest/kotlin/com/openbitfun/mobile/app/ComposerModelSelectorTest.kt‎

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
package com.openbitfun.mobile.app
22

3+
import android.graphics.Bitmap
4+
import androidx.test.platform.app.InstrumentationRegistry
5+
import java.io.File
36
import androidx.compose.ui.test.assertIsDisplayed
47
import androidx.compose.ui.test.junit4.v2.createComposeRule
58
import androidx.compose.ui.test.onNodeWithTag
@@ -13,6 +16,7 @@ import com.openbitfun.mobile.app.ui.theme.OpenBitFunTheme
1316
import com.openbitfun.mobile.core.feature.connection.ConnectionPhase
1417
import com.openbitfun.mobile.core.feature.session.ChatComposerCapabilities
1518
import com.openbitfun.mobile.core.feature.session.ModelOption
19+
import com.openbitfun.mobile.core.feature.session.ModelRole
1620
import org.junit.Assert.assertEquals
1721
import org.junit.Rule
1822
import org.junit.Test
@@ -21,12 +25,42 @@ class ComposerModelSelectorTest {
2125
@get:Rule
2226
val composeRule = createComposeRule()
2327

28+
private fun capture(name: String) {
29+
composeRule.waitForIdle()
30+
val instrumentation = InstrumentationRegistry.getInstrumentation()
31+
val bitmap = instrumentation.uiAutomation.takeScreenshot()
32+
File(instrumentation.targetContext.getExternalFilesDir(null), name).outputStream().use {
33+
bitmap.compress(Bitmap.CompressFormat.PNG, 100, it)
34+
}
35+
bitmap.recycle()
36+
}
37+
38+
@Test
39+
fun collapsedDraftHasNoSupplementalMicrophone() {
40+
composeRule.setContent {
41+
OpenBitFunTheme(dark = false) {
42+
ComposerBar(draft = "A long draft that must keep its room in the collapsed input".repeat(4),
43+
images = emptyList(), busy = false, streaming = false, phase = ConnectionPhase.CONNECTED,
44+
model = null, capabilities = ChatComposerCapabilities.RemoteChat, placeholder = "Message",
45+
onDraftChange = {}, onRemoveImage = {}, onAttach = {}, onVoice = {}, onSend = {},
46+
onStop = {}, onOpenModels = {}, modifier = androidx.compose.ui.Modifier)
47+
}
48+
}
49+
composeRule.onNodeWithTag("composer-voice").assertDoesNotExist()
50+
composeRule.onNodeWithTag(COMPOSER_INPUT_TEST_TAG).assertIsDisplayed()
51+
capture("parity-collapsed-draft.png")
52+
composeRule.onNodeWithTag(COMPOSER_INPUT_TEST_TAG).performClick()
53+
composeRule.onNodeWithTag("composer-voice").assertIsDisplayed()
54+
}
55+
2456
@Test
2557
fun theExpandedComposerSelectsAConfiguredModel() {
2658
var selected = "account-primary"
2759
val models = listOf(
28-
ModelOption("account-primary", "Primary", "Account model", true),
29-
ModelOption("account-fast", "Fast", "Account model", false),
60+
ModelOption("primary", "Primary", "Account model", false, ModelRole.PRIMARY, emptyList(), false),
61+
ModelOption("fast", "Fast", "Account model", false, ModelRole.FAST, emptyList(), true),
62+
ModelOption("account-primary", "Primary", "Account model", true, null, listOf(ModelRole.PRIMARY), false),
63+
ModelOption("account-fast", "Fast", "Account model", false, null, listOf(ModelRole.FAST), false),
3064
)
3165
composeRule.setContent {
3266
OpenBitFunTheme(dark = false) {
@@ -56,9 +90,11 @@ class ComposerModelSelectorTest {
5690
composeRule.onNodeWithTag(COMPOSER_INPUT_TEST_TAG).performClick()
5791
composeRule.onNodeWithTag(MODEL_CONTROL_TEST_TAG).assertIsDisplayed().performClick()
5892
composeRule.onNodeWithTag(MODEL_SELECTOR_TEST_TAG).assertIsDisplayed()
59-
composeRule.onNodeWithTag(MODEL_SELECTOR_OPTION_TEST_TAG_PREFIX + "account-fast")
60-
.performClick()
93+
composeRule.onNodeWithTag(MODEL_SELECTOR_OPTION_TEST_TAG_PREFIX + "primary").assertIsDisplayed()
94+
composeRule.onNodeWithTag(MODEL_SELECTOR_OPTION_TEST_TAG_PREFIX + "fast").assertIsDisplayed()
95+
capture("parity-model-roles.png")
96+
composeRule.onNodeWithTag(MODEL_SELECTOR_OPTION_TEST_TAG_PREFIX + "fast").performClick()
6197

62-
assertEquals("account-fast", selected)
98+
assertEquals("fast", selected)
6399
}
64100
}

‎src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/chat/ComposerBar.kt‎

Lines changed: 49 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
3333
import androidx.compose.foundation.text.BasicTextField
3434
import androidx.compose.foundation.verticalScroll
3535
import androidx.compose.material3.Icon
36+
import androidx.compose.material3.HorizontalDivider
3637
import androidx.compose.material3.MaterialTheme
3738
import androidx.compose.material3.Surface
3839
import androidx.compose.material3.Text
@@ -59,6 +60,7 @@ import androidx.compose.ui.platform.LocalDensity
5960
import androidx.compose.ui.res.painterResource
6061
import androidx.compose.ui.res.stringResource
6162
import androidx.compose.ui.semantics.Role
63+
import androidx.compose.ui.semantics.clearAndSetSemantics
6264
import androidx.compose.ui.semantics.contentDescription
6365
import androidx.compose.ui.semantics.role
6466
import androidx.compose.ui.semantics.selected
@@ -80,6 +82,7 @@ import com.openbitfun.mobile.core.feature.session.ChatComposerPolicy
8082
import com.openbitfun.mobile.core.feature.session.ComposerImage
8183
import com.openbitfun.mobile.core.feature.session.ComposerPrimaryAction
8284
import com.openbitfun.mobile.core.feature.session.ModelOption
85+
import com.openbitfun.mobile.core.feature.session.ModelRole
8386

8487
internal const val COMPOSER_TEST_TAG: String = "composer"
8588
internal const val COMPOSER_INPUT_TEST_TAG: String = "composer-input"
@@ -287,9 +290,6 @@ internal fun ComposerBar(
287290
enter = compactControlEnter,
288291
exit = compactControlExit,
289292
) {
290-
if (capabilities.showVoiceInput && !busy && (draft.isNotBlank() || images.isNotEmpty())) {
291-
PrimaryActionButton(action = ComposerPrimaryAction.VOICE, onVoice = onVoice, onSend = onSend, onStop = onStop, testTag = "composer-voice")
292-
}
293293
PrimaryActionButton(
294294
action = action,
295295
stopEnabled = ChatComposerPolicy.canStop(streaming, capabilities.requiresRemoteConnection, phase),
@@ -338,8 +338,8 @@ internal fun ComposerBar(
338338
)
339339
}
340340
Box(modifier = Modifier.weight(1f))
341-
if (capabilities.showVoiceInput && !busy && (draft.isNotBlank() || images.isNotEmpty())) {
342-
PrimaryActionButton(action = ComposerPrimaryAction.VOICE, onVoice = onVoice, onSend = onSend, onStop = onStop, testTag = "composer-voice")
341+
if (capabilities.showVoiceInput && (draft.isNotBlank() || images.isNotEmpty())) {
342+
PrimaryActionButton(action = if (busy) ComposerPrimaryAction.VOICE_BLOCKED else ComposerPrimaryAction.VOICE, onVoice = onVoice, onSend = onSend, onStop = onStop, testTag = "composer-voice")
343343
}
344344
PrimaryActionButton(
345345
action = action,
@@ -402,7 +402,17 @@ private fun ComposerField(
402402
color = colors.onSurfaceVariant,
403403
)
404404
}
405-
field()
405+
// Focus expands the same editor. At rest, render a one-line
406+
// preview so overflow cannot scroll sideways or lose the draft.
407+
Box(Modifier.alpha(if (!expanded && draft.isNotEmpty()) 0f else 1f)) { field() }
408+
if (!expanded && draft.isNotEmpty()) Text(
409+
draft,
410+
maxLines = 1,
411+
overflow = TextOverflow.Ellipsis,
412+
style = MaterialTheme.typography.bodyLarge,
413+
color = colors.onSurface,
414+
modifier = Modifier.fillMaxWidth().clearAndSetSemantics {},
415+
)
406416
}
407417
},
408418
)
@@ -446,7 +456,7 @@ private fun ModelControl(
446456
) {
447457
val label = stringResource(R.string.models_title)
448458
val displayModel = model ?: modelOptions.firstOrNull { it.selected }
449-
val displayLabel = displayModel?.primaryLabel ?: label
459+
val displayLabel = displayModel?.let { modelOptionTitle(it) } ?: label
450460
val wide = composerIsWide(LocalConfiguration.current.screenWidthDp)
451461
Box {
452462
Row(
@@ -530,7 +540,9 @@ private fun ModelSelectorContent(
530540
onDismiss: () -> Unit,
531541
) {
532542
val selectorOptions = remember(options) {
533-
options.filter(ModelOption::selected) + options.filterNot(ModelOption::selected)
543+
options.filter { it.role != null } +
544+
options.filter { it.role == null && it.selected } +
545+
options.filter { it.role == null && !it.selected }
534546
}
535547
val visibleRows = selectorOptions.size.coerceAtMost(7)
536548
val listHeight = if (visibleRows == 0) {
@@ -597,7 +609,12 @@ private fun ModelSelectorContent(
597609
.height(listHeight)
598610
.verticalScroll(rememberScrollState()),
599611
) {
600-
selectorOptions.forEach { option ->
612+
selectorOptions.forEachIndexed { index, option ->
613+
if (index > 0 && option.role == null && selectorOptions[index - 1].role != null) {
614+
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
615+
}
616+
val title = modelOptionTitle(option)
617+
val subtitle = modelOptionSubtitle(option)
601618
Row(
602619
verticalAlignment = Alignment.CenterVertically,
603620
horizontalArrangement = Arrangement.spacedBy(10.dp),
@@ -616,7 +633,7 @@ private fun ModelSelectorContent(
616633
.clickable { onSelect(option.id) }
617634
.semantics {
618635
contentDescription =
619-
"${option.primaryLabel} · ${option.secondaryLabel}"
636+
"$title · $subtitle"
620637
role = Role.Button
621638
selected = option.selected
622639
}
@@ -641,24 +658,44 @@ private fun ModelSelectorContent(
641658
modifier = Modifier.weight(1f),
642659
) {
643660
Text(
644-
option.primaryLabel,
661+
title,
645662
fontSize = MaterialTheme.typography.labelLarge.fontSize,
646663
fontWeight = FontWeight.Medium,
647664
color = MaterialTheme.colorScheme.onSurface,
648665
)
649666
Text(
650-
option.secondaryLabel,
667+
subtitle,
651668
fontSize = MaterialTheme.typography.bodySmall.fontSize,
652669
color = MaterialTheme.colorScheme.onSurfaceVariant,
653670
)
654671
}
672+
option.roles.forEach { role ->
673+
Text(
674+
stringResource(if (role == ModelRole.PRIMARY) R.string.model_role_primary else R.string.model_role_fast),
675+
style = MaterialTheme.typography.labelSmall,
676+
color = MaterialTheme.colorScheme.onSurfaceVariant,
677+
modifier = Modifier.background(MaterialTheme.colorScheme.surfaceVariant, RoundedCornerShape(4.dp))
678+
.padding(horizontal = 6.dp, vertical = 1.dp),
679+
)
680+
}
655681
}
656682
}
657683
}
658684
}
659685
}
660686
}
661687

688+
@Composable
689+
internal fun modelOptionTitle(option: ModelOption): String = when (option.role) {
690+
ModelRole.PRIMARY -> stringResource(R.string.model_primary)
691+
ModelRole.FAST -> stringResource(R.string.model_fast)
692+
null -> option.primaryLabel
693+
}
694+
695+
@Composable
696+
internal fun modelOptionSubtitle(option: ModelOption): String =
697+
if (option.fallsBackToPrimary) stringResource(R.string.model_fast_fallback) else option.secondaryLabel
698+
662699
/** What a control that is offered but not usable right now looks like. */
663700
private const val DimmedAlpha: Float = 0.38f
664701

‎src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/chat/ConversationView.kt‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ import androidx.compose.foundation.rememberScrollState
99
import androidx.compose.foundation.verticalScroll
1010
import androidx.compose.foundation.layout.PaddingValues
1111
import androidx.compose.foundation.layout.Row
12+
import androidx.compose.foundation.layout.WindowInsets
13+
import androidx.compose.foundation.layout.navigationBars
14+
import androidx.compose.foundation.layout.ime
1215
import androidx.compose.foundation.layout.heightIn
1316
import androidx.compose.foundation.layout.size
1417
import androidx.compose.material3.Button
@@ -268,6 +271,11 @@ internal fun ConversationView(
268271
var topInset by remember { mutableStateOf(0.dp) }
269272
var bottomInset by remember { mutableStateOf(0.dp) }
270273
val density = LocalDensity.current
274+
// Scaffold already lifts the pane above the keyboard. Navigation only
275+
// belongs to this layer when the keyboard is absent.
276+
val navigationBottom = with(density) {
277+
if (WindowInsets.ime.getBottom(this) > 0) 0.dp else WindowInsets.navigationBars.getBottom(this).toDp()
278+
}
271279
Box(modifier = Modifier.fillMaxSize().testTag(CONVERSATION_TEST_TAG)) {
272280
if (timeline == null) {
273281
Box(Modifier.fillMaxSize()) {
@@ -338,7 +346,8 @@ internal fun ConversationView(
338346
// short of that leaves a line of text sitting crisp and
339347
// legible beside the pill after it has already faded out
340348
// higher up.
341-
.background(bottomOverlayFade()),
349+
.background(bottomOverlayFade())
350+
.padding(bottom = navigationBottom),
342351
) {
343352
if (attachmentDraft.failed.value) {
344353
TextButton(onClick = { attachmentDraft.retry() }) {

‎src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/settings/RemoteSettingsSheet.kt‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -119,8 +119,8 @@ private fun ModelSection(
119119
options.forEachIndexed { index, option ->
120120
if (index > 0) HorizontalDivider()
121121
SelectableRow(
122-
label = option.primaryLabel,
123-
description = option.secondaryLabel,
122+
label = com.openbitfun.mobile.app.ui.chat.modelOptionTitle(option),
123+
description = com.openbitfun.mobile.app.ui.chat.modelOptionSubtitle(option),
124124
selected = option.selected,
125125
enabled = !state.busy && sessionId.isNotEmpty(),
126126
onSelect = {

‎src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/shell/MobileScreen.kt‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ import androidx.compose.foundation.layout.imePadding
1717
import androidx.compose.foundation.layout.padding
1818
import androidx.compose.foundation.layout.safeDrawingPadding
1919
import androidx.compose.foundation.layout.union
20+
import androidx.compose.foundation.layout.only
21+
import androidx.compose.foundation.layout.WindowInsetsSides
22+
import androidx.compose.foundation.layout.consumeWindowInsets
2023
import androidx.compose.foundation.layout.width
2124
import androidx.compose.material3.ExperimentalMaterial3Api
2225
import androidx.compose.material3.MaterialTheme
@@ -440,13 +443,17 @@ internal fun MobileScreen(onAccountRestored: (Boolean) -> Unit = {}) {
440443
// keeps the input bar above the keyboard. `union` rather than a
441444
// second padding: the IME inset already contains the navigation
442445
// bar's, and adding them would leave a gap the height of the bar.
443-
contentWindowInsets = ScaffoldDefaults.contentWindowInsets.union(WindowInsets.ime),
446+
contentWindowInsets = (if (shell.surface == MobileSurface.REMOTE && routedSessionId != null) {
447+
// The conversation owns the bottom strip: its viewport reaches
448+
// the screen edge while the fixed composer clears navigation.
449+
ScaffoldDefaults.contentWindowInsets.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal)
450+
} else ScaffoldDefaults.contentWindowInsets).union(WindowInsets.ime),
444451
// HarmonyOS hides the platform title bar. Each product surface owns
445452
// its 44dp controls and title row, so a Material TopAppBar here would
446453
// add a second header above every conversation and remote page.
447454
topBar = {},
448455
) { insets ->
449-
Box(Modifier.padding(insets)) {
456+
Box(Modifier.padding(insets).consumeWindowInsets(insets)) {
450457
when (shell.surface) {
451458
MobileSurface.REMOTE -> when (controlSummary.source) {
452459
RemoteControlSource.ACCOUNT_DEVICE -> AccountRemoteScreen(

‎src/apps/mobile/android/app/src/main/res/values-zh/strings.xml‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -479,4 +479,9 @@
479479
<string name="chat_loading_older_messages">正在加载更早消息…</string>
480480
<string name="tool_todo_fallback">任务列表</string>
481481
<string name="tool_todo_all_completed">全部任务已完成</string>
482+
<string name="model_primary">主力模型</string>
483+
<string name="model_fast">快速模型</string>
484+
<string name="model_fast_fallback">未配置快速模型,使用主力模型</string>
485+
<string name="model_role_primary">主力</string>
486+
<string name="model_role_fast">快速</string>
482487
</resources>

‎src/apps/mobile/android/app/src/main/res/values/strings.xml‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -494,4 +494,9 @@
494494
<string name="chat_loading_older_messages">Loading earlier messages…</string>
495495
<string name="tool_todo_fallback">Task list</string>
496496
<string name="tool_todo_all_completed">All tasks completed</string>
497+
<string name="model_primary">Primary model</string>
498+
<string name="model_fast">Fast model</string>
499+
<string name="model_fast_fallback">Fast model not configured; using primary</string>
500+
<string name="model_role_primary">Primary</string>
501+
<string name="model_role_fast">Fast</string>
497502
</resources>

0 commit comments

Comments
 (0)