Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,22 @@ class ChatMessageBubbleTest {
assertTrue(composeRule.onAllNodesWithText("Build step finished.").fetchSemanticsNodes().isEmpty())
}


@Test
fun anOpenSubagentReceivesNewStepsWithoutClosing() {
var block by mutableStateOf(MessageBlock.Subagent("live-owner", "Live task", true, "",
listOf(MessageBlock.Text("first", "First step", false))))
composeRule.setContent { Bubble(row(kind = ConversationRowKind.ASSISTANT, blocks = listOf(block))) }
composeRule.onNodeWithText("Live task").performClick()
composeRule.runOnIdle {
block = block.copy(children = block.children + MessageBlock.Text("second", "Later step", false))
}
composeRule.onNodeWithText("Later step").assertIsDisplayed()
composeRule.runOnIdle { block = block.copy(running = false, status = "completed") }
composeRule.onNodeWithText("First step").assertIsDisplayed()
composeRule.onNodeWithText("Later step").assertIsDisplayed()
}

@Test
fun aRunningSubagentPreservesUserCollapseAcrossStreamUpdates() {
var block by mutableStateOf(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
package com.openbitfun.mobile.app

import android.graphics.Bitmap
import androidx.test.platform.app.InstrumentationRegistry
import java.io.File
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
Expand All @@ -13,6 +16,7 @@ import com.openbitfun.mobile.app.ui.theme.OpenBitFunTheme
import com.openbitfun.mobile.core.feature.connection.ConnectionPhase
import com.openbitfun.mobile.core.feature.session.ChatComposerCapabilities
import com.openbitfun.mobile.core.feature.session.ModelOption
import com.openbitfun.mobile.core.feature.session.ModelRole
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
Expand All @@ -21,12 +25,42 @@ class ComposerModelSelectorTest {
@get:Rule
val composeRule = createComposeRule()

private fun capture(name: String) {
composeRule.waitForIdle()
val instrumentation = InstrumentationRegistry.getInstrumentation()
val bitmap = instrumentation.uiAutomation.takeScreenshot()
File(instrumentation.targetContext.getExternalFilesDir(null), name).outputStream().use {
bitmap.compress(Bitmap.CompressFormat.PNG, 100, it)
}
bitmap.recycle()
}

@Test
fun collapsedDraftHasNoSupplementalMicrophone() {
composeRule.setContent {
OpenBitFunTheme(dark = false) {
ComposerBar(draft = "A long draft that must keep its room in the collapsed input".repeat(4),
images = emptyList(), busy = false, streaming = false, phase = ConnectionPhase.CONNECTED,
model = null, capabilities = ChatComposerCapabilities.RemoteChat, placeholder = "Message",
onDraftChange = {}, onRemoveImage = {}, onAttach = {}, onVoice = {}, onSend = {},
onStop = {}, onOpenModels = {}, modifier = androidx.compose.ui.Modifier)
}
}
composeRule.onNodeWithTag("composer-voice").assertDoesNotExist()
composeRule.onNodeWithTag(COMPOSER_INPUT_TEST_TAG).assertIsDisplayed()
capture("parity-collapsed-draft.png")
composeRule.onNodeWithTag(COMPOSER_INPUT_TEST_TAG).performClick()
composeRule.onNodeWithTag("composer-voice").assertIsDisplayed()
}

@Test
fun theExpandedComposerSelectsAConfiguredModel() {
var selected = "account-primary"
val models = listOf(
ModelOption("account-primary", "Primary", "Account model", true),
ModelOption("account-fast", "Fast", "Account model", false),
ModelOption("primary", "Primary", "Account model", false, ModelRole.PRIMARY, emptyList(), false),
ModelOption("fast", "Fast", "Account model", false, ModelRole.FAST, emptyList(), true),
ModelOption("account-primary", "Primary", "Account model", true, null, listOf(ModelRole.PRIMARY), false),
ModelOption("account-fast", "Fast", "Account model", false, null, listOf(ModelRole.FAST), false),
)
composeRule.setContent {
OpenBitFunTheme(dark = false) {
Expand Down Expand Up @@ -56,9 +90,11 @@ class ComposerModelSelectorTest {
composeRule.onNodeWithTag(COMPOSER_INPUT_TEST_TAG).performClick()
composeRule.onNodeWithTag(MODEL_CONTROL_TEST_TAG).assertIsDisplayed().performClick()
composeRule.onNodeWithTag(MODEL_SELECTOR_TEST_TAG).assertIsDisplayed()
composeRule.onNodeWithTag(MODEL_SELECTOR_OPTION_TEST_TAG_PREFIX + "account-fast")
.performClick()
composeRule.onNodeWithTag(MODEL_SELECTOR_OPTION_TEST_TAG_PREFIX + "primary").assertIsDisplayed()
composeRule.onNodeWithTag(MODEL_SELECTOR_OPTION_TEST_TAG_PREFIX + "fast").assertIsDisplayed()
capture("parity-model-roles.png")
composeRule.onNodeWithTag(MODEL_SELECTOR_OPTION_TEST_TAG_PREFIX + "fast").performClick()

assertEquals("account-fast", selected)
assertEquals("fast", selected)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Icon
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
Expand All @@ -59,6 +60,7 @@ import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.selected
Expand All @@ -80,6 +82,7 @@ import com.openbitfun.mobile.core.feature.session.ChatComposerPolicy
import com.openbitfun.mobile.core.feature.session.ComposerImage
import com.openbitfun.mobile.core.feature.session.ComposerPrimaryAction
import com.openbitfun.mobile.core.feature.session.ModelOption
import com.openbitfun.mobile.core.feature.session.ModelRole

internal const val COMPOSER_TEST_TAG: String = "composer"
internal const val COMPOSER_INPUT_TEST_TAG: String = "composer-input"
Expand Down Expand Up @@ -287,9 +290,6 @@ internal fun ComposerBar(
enter = compactControlEnter,
exit = compactControlExit,
) {
if (capabilities.showVoiceInput && !busy && (draft.isNotBlank() || images.isNotEmpty())) {
PrimaryActionButton(action = ComposerPrimaryAction.VOICE, onVoice = onVoice, onSend = onSend, onStop = onStop, testTag = "composer-voice")
}
PrimaryActionButton(
action = action,
stopEnabled = ChatComposerPolicy.canStop(streaming, capabilities.requiresRemoteConnection, phase),
Expand Down Expand Up @@ -338,8 +338,8 @@ internal fun ComposerBar(
)
}
Box(modifier = Modifier.weight(1f))
if (capabilities.showVoiceInput && !busy && (draft.isNotBlank() || images.isNotEmpty())) {
PrimaryActionButton(action = ComposerPrimaryAction.VOICE, onVoice = onVoice, onSend = onSend, onStop = onStop, testTag = "composer-voice")
if (capabilities.showVoiceInput && (draft.isNotBlank() || images.isNotEmpty())) {
PrimaryActionButton(action = if (busy) ComposerPrimaryAction.VOICE_BLOCKED else ComposerPrimaryAction.VOICE, onVoice = onVoice, onSend = onSend, onStop = onStop, testTag = "composer-voice")
}
PrimaryActionButton(
action = action,
Expand Down Expand Up @@ -402,7 +402,17 @@ private fun ComposerField(
color = colors.onSurfaceVariant,
)
}
field()
// Focus expands the same editor. At rest, render a one-line
// preview so overflow cannot scroll sideways or lose the draft.
Box(Modifier.alpha(if (!expanded && draft.isNotEmpty()) 0f else 1f)) { field() }
if (!expanded && draft.isNotEmpty()) Text(
draft,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.bodyLarge,
color = colors.onSurface,
modifier = Modifier.fillMaxWidth().clearAndSetSemantics {},
)
}
},
)
Expand Down Expand Up @@ -446,7 +456,7 @@ private fun ModelControl(
) {
val label = stringResource(R.string.models_title)
val displayModel = model ?: modelOptions.firstOrNull { it.selected }
val displayLabel = displayModel?.primaryLabel ?: label
val displayLabel = displayModel?.let { modelOptionTitle(it) } ?: label
val wide = composerIsWide(LocalConfiguration.current.screenWidthDp)
Box {
Row(
Expand Down Expand Up @@ -530,7 +540,9 @@ private fun ModelSelectorContent(
onDismiss: () -> Unit,
) {
val selectorOptions = remember(options) {
options.filter(ModelOption::selected) + options.filterNot(ModelOption::selected)
options.filter { it.role != null } +
options.filter { it.role == null && it.selected } +
options.filter { it.role == null && !it.selected }
}
val visibleRows = selectorOptions.size.coerceAtMost(7)
val listHeight = if (visibleRows == 0) {
Expand Down Expand Up @@ -597,7 +609,12 @@ private fun ModelSelectorContent(
.height(listHeight)
.verticalScroll(rememberScrollState()),
) {
selectorOptions.forEach { option ->
selectorOptions.forEachIndexed { index, option ->
if (index > 0 && option.role == null && selectorOptions[index - 1].role != null) {
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
}
val title = modelOptionTitle(option)
val subtitle = modelOptionSubtitle(option)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
Expand All @@ -616,7 +633,7 @@ private fun ModelSelectorContent(
.clickable { onSelect(option.id) }
.semantics {
contentDescription =
"${option.primaryLabel} · ${option.secondaryLabel}"
"$title · $subtitle"
role = Role.Button
selected = option.selected
}
Expand All @@ -641,24 +658,44 @@ private fun ModelSelectorContent(
modifier = Modifier.weight(1f),
) {
Text(
option.primaryLabel,
title,
fontSize = MaterialTheme.typography.labelLarge.fontSize,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurface,
)
Text(
option.secondaryLabel,
subtitle,
fontSize = MaterialTheme.typography.bodySmall.fontSize,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
option.roles.forEach { role ->
Text(
stringResource(if (role == ModelRole.PRIMARY) R.string.model_role_primary else R.string.model_role_fast),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.background(MaterialTheme.colorScheme.surfaceVariant, RoundedCornerShape(4.dp))
.padding(horizontal = 6.dp, vertical = 1.dp),
)
}
}
}
}
}
}
}

@Composable
internal fun modelOptionTitle(option: ModelOption): String = when (option.role) {
ModelRole.PRIMARY -> stringResource(R.string.model_primary)
ModelRole.FAST -> stringResource(R.string.model_fast)
null -> option.primaryLabel
}

@Composable
internal fun modelOptionSubtitle(option: ModelOption): String =
if (option.fallsBackToPrimary) stringResource(R.string.model_fast_fallback) else option.secondaryLabel

/** What a control that is offered but not usable right now looks like. */
private const val DimmedAlpha: Float = 0.38f

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Button
Expand Down Expand Up @@ -268,6 +271,11 @@ internal fun ConversationView(
var topInset by remember { mutableStateOf(0.dp) }
var bottomInset by remember { mutableStateOf(0.dp) }
val density = LocalDensity.current
// Scaffold already lifts the pane above the keyboard. Navigation only
// belongs to this layer when the keyboard is absent.
val navigationBottom = with(density) {
if (WindowInsets.ime.getBottom(this) > 0) 0.dp else WindowInsets.navigationBars.getBottom(this).toDp()
}
Box(modifier = Modifier.fillMaxSize().testTag(CONVERSATION_TEST_TAG)) {
if (timeline == null) {
Box(Modifier.fillMaxSize()) {
Expand Down Expand Up @@ -338,7 +346,8 @@ internal fun ConversationView(
// short of that leaves a line of text sitting crisp and
// legible beside the pill after it has already faded out
// higher up.
.background(bottomOverlayFade()),
.background(bottomOverlayFade())
.padding(bottom = navigationBottom),
) {
if (attachmentDraft.failed.value) {
TextButton(onClick = { attachmentDraft.retry() }) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -119,8 +119,8 @@ private fun ModelSection(
options.forEachIndexed { index, option ->
if (index > 0) HorizontalDivider()
SelectableRow(
label = option.primaryLabel,
description = option.secondaryLabel,
label = com.openbitfun.mobile.app.ui.chat.modelOptionTitle(option),
description = com.openbitfun.mobile.app.ui.chat.modelOptionSubtitle(option),
selected = option.selected,
enabled = !state.busy && sessionId.isNotEmpty(),
onSelect = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.foundation.layout.union
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.width
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
Expand Down Expand Up @@ -440,13 +443,17 @@ internal fun MobileScreen(onAccountRestored: (Boolean) -> Unit = {}) {
// keeps the input bar above the keyboard. `union` rather than a
// second padding: the IME inset already contains the navigation
// bar's, and adding them would leave a gap the height of the bar.
contentWindowInsets = ScaffoldDefaults.contentWindowInsets.union(WindowInsets.ime),
contentWindowInsets = (if (shell.surface == MobileSurface.REMOTE && routedSessionId != null) {
// The conversation owns the bottom strip: its viewport reaches
// the screen edge while the fixed composer clears navigation.
ScaffoldDefaults.contentWindowInsets.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal)
} else ScaffoldDefaults.contentWindowInsets).union(WindowInsets.ime),
// HarmonyOS hides the platform title bar. Each product surface owns
// its 44dp controls and title row, so a Material TopAppBar here would
// add a second header above every conversation and remote page.
topBar = {},
) { insets ->
Box(Modifier.padding(insets)) {
Box(Modifier.padding(insets).consumeWindowInsets(insets)) {
when (shell.surface) {
MobileSurface.REMOTE -> when (controlSummary.source) {
RemoteControlSource.ACCOUNT_DEVICE -> AccountRemoteScreen(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -479,4 +479,9 @@
<string name="chat_loading_older_messages">正在加载更早消息…</string>
<string name="tool_todo_fallback">任务列表</string>
<string name="tool_todo_all_completed">全部任务已完成</string>
<string name="model_primary">主力模型</string>
<string name="model_fast">快速模型</string>
<string name="model_fast_fallback">未配置快速模型,使用主力模型</string>
<string name="model_role_primary">主力</string>
<string name="model_role_fast">快速</string>
</resources>
5 changes: 5 additions & 0 deletions src/apps/mobile/android/app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -494,4 +494,9 @@
<string name="chat_loading_older_messages">Loading earlier messages…</string>
<string name="tool_todo_fallback">Task list</string>
<string name="tool_todo_all_completed">All tasks completed</string>
<string name="model_primary">Primary model</string>
<string name="model_fast">Fast model</string>
<string name="model_fast_fallback">Fast model not configured; using primary</string>
<string name="model_role_primary">Primary</string>
<string name="model_role_fast">Fast</string>
</resources>
Loading
Loading