diff --git a/src/apps/mobile/android/app/src/androidTest/kotlin/com/openbitfun/mobile/app/ChatMessageBubbleTest.kt b/src/apps/mobile/android/app/src/androidTest/kotlin/com/openbitfun/mobile/app/ChatMessageBubbleTest.kt
index c7474206fb..296b46557e 100644
--- a/src/apps/mobile/android/app/src/androidTest/kotlin/com/openbitfun/mobile/app/ChatMessageBubbleTest.kt
+++ b/src/apps/mobile/android/app/src/androidTest/kotlin/com/openbitfun/mobile/app/ChatMessageBubbleTest.kt
@@ -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(
diff --git a/src/apps/mobile/android/app/src/androidTest/kotlin/com/openbitfun/mobile/app/ComposerModelSelectorTest.kt b/src/apps/mobile/android/app/src/androidTest/kotlin/com/openbitfun/mobile/app/ComposerModelSelectorTest.kt
index 1cc94f1582..fe785495c8 100644
--- a/src/apps/mobile/android/app/src/androidTest/kotlin/com/openbitfun/mobile/app/ComposerModelSelectorTest.kt
+++ b/src/apps/mobile/android/app/src/androidTest/kotlin/com/openbitfun/mobile/app/ComposerModelSelectorTest.kt
@@ -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
@@ -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
@@ -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) {
@@ -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)
}
}
diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/chat/ComposerBar.kt b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/chat/ComposerBar.kt
index dca0b67895..c1e02751f5 100644
--- a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/chat/ComposerBar.kt
+++ b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/chat/ComposerBar.kt
@@ -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
@@ -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
@@ -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"
@@ -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),
@@ -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,
@@ -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 {},
+ )
}
},
)
@@ -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(
@@ -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) {
@@ -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),
@@ -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
}
@@ -641,17 +658,26 @@ 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),
+ )
+ }
}
}
}
@@ -659,6 +685,17 @@ private fun ModelSelectorContent(
}
}
+@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
diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/chat/ConversationView.kt b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/chat/ConversationView.kt
index 09e71d9ce2..a0ef3cd1ab 100644
--- a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/chat/ConversationView.kt
+++ b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/chat/ConversationView.kt
@@ -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
@@ -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()) {
@@ -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() }) {
diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/settings/RemoteSettingsSheet.kt b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/settings/RemoteSettingsSheet.kt
index e4f32fa941..b3f1e94a92 100644
--- a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/settings/RemoteSettingsSheet.kt
+++ b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/settings/RemoteSettingsSheet.kt
@@ -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 = {
diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/shell/MobileScreen.kt b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/shell/MobileScreen.kt
index cc3a434bc2..d540ae59b7 100644
--- a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/shell/MobileScreen.kt
+++ b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/shell/MobileScreen.kt
@@ -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
@@ -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(
diff --git a/src/apps/mobile/android/app/src/main/res/values-zh/strings.xml b/src/apps/mobile/android/app/src/main/res/values-zh/strings.xml
index 5d7b41c6d0..a20513b3d3 100644
--- a/src/apps/mobile/android/app/src/main/res/values-zh/strings.xml
+++ b/src/apps/mobile/android/app/src/main/res/values-zh/strings.xml
@@ -479,4 +479,9 @@
正在加载更早消息…
任务列表
全部任务已完成
+ 主力模型
+ 快速模型
+ 未配置快速模型,使用主力模型
+ 主力
+ 快速
diff --git a/src/apps/mobile/android/app/src/main/res/values/strings.xml b/src/apps/mobile/android/app/src/main/res/values/strings.xml
index c8eba65128..4cbc72e192 100644
--- a/src/apps/mobile/android/app/src/main/res/values/strings.xml
+++ b/src/apps/mobile/android/app/src/main/res/values/strings.xml
@@ -494,4 +494,9 @@
Loading earlier messages…
Task list
All tasks completed
+ Primary model
+ Fast model
+ Fast model not configured; using primary
+ Primary
+ Fast
diff --git a/src/apps/mobile/ios/OpenBitFun/App/MobileLaunchConfiguration.swift b/src/apps/mobile/ios/OpenBitFun/App/MobileLaunchConfiguration.swift
index 8ce5529e55..cffa70cbfc 100644
--- a/src/apps/mobile/ios/OpenBitFun/App/MobileLaunchConfiguration.swift
+++ b/src/apps/mobile/ios/OpenBitFun/App/MobileLaunchConfiguration.swift
@@ -21,7 +21,7 @@ enum MobileLaunchConfiguration {
ChatMessage(id: UUID(), role: .user, text: "你好"),
ChatMessage(id: UUID(), role: .assistant, text: "这是 OpenBitFun 的移动端会话界面。你可以从手机连接桌面端,查看工作区、会话和智能体的执行状态。")
],
- connectCore: !streamingRegressionPreview && !ProcessInfo.processInfo.arguments.contains("--permission-mailbox-preview") && !ProcessInfo.processInfo.arguments.contains("--harness-preview") && designPreviewScenario() == nil
+ connectCore: !ProcessInfo.processInfo.arguments.contains("--composer-model-picker") && !ProcessInfo.processInfo.arguments.contains("--composer-draft-preview") && !streamingRegressionPreview && !ProcessInfo.processInfo.arguments.contains("--permission-mailbox-preview") && !ProcessInfo.processInfo.arguments.contains("--harness-preview") && designPreviewScenario() == nil
)
return configure(model)
}
@@ -139,18 +139,25 @@ enum MobileLaunchConfiguration {
model.surface = .remote
model.settingsOpen = true
}
- if arguments.contains("--composer-model-picker") ||
+ if arguments.contains("--composer-model-picker") || arguments.contains("--composer-draft-preview") ||
ProcessInfo.processInfo.environment["OPENBITFUN_COMPOSER_MODEL_PICKER"] == "1" {
- model.composerModelPickerPreview = true
+ model.composerModelPickerPreview = !arguments.contains("--composer-draft-preview")
+ model.surface = .local
model.localSessionSelected = true
- model.draft = "\n"
+ model.draft = arguments.contains("--composer-draft-preview")
+ ? String(repeating: "A long draft stays editable after its collapsed preview. ", count: 8) : "\n"
model.modelOptions = [
+ ComposerModelOption(id: "primary", primaryLabel: "GPT-5.6 Codex",
+ secondaryLabel: "GPT-5.6 Codex · openai · 200k", source: "REMOTE", selected: true, role: "PRIMARY"),
+ ComposerModelOption(id: "fast", primaryLabel: "GPT-5 mini",
+ secondaryLabel: "GPT-5 mini · openai · 128k", source: "REMOTE", selected: false, role: "FAST"),
ComposerModelOption(
id: "preview-codex",
primaryLabel: "GPT-5.6 Codex",
secondaryLabel: "OpenBitFun 账号",
source: "ACCOUNT",
- selected: true
+ selected: false,
+ roles: ["PRIMARY"]
),
ComposerModelOption(
id: "preview-local",
diff --git a/src/apps/mobile/ios/OpenBitFun/Features/Chat/ComposerBar.swift b/src/apps/mobile/ios/OpenBitFun/Features/Chat/ComposerBar.swift
index 0becc2d0af..de3dd8636f 100644
--- a/src/apps/mobile/ios/OpenBitFun/Features/Chat/ComposerBar.swift
+++ b/src/apps/mobile/ios/OpenBitFun/Features/Chat/ComposerBar.swift
@@ -52,7 +52,7 @@ struct ComposerBar: View {
}
private var showsSupplementalVoice: Bool {
- hasContent && !speech.isListening && !model.isSending
+ hasContent && !speech.isListening
}
var body: some View {
@@ -214,7 +214,7 @@ struct ComposerBar: View {
if !model.modelOptions.isEmpty {
Button { modelSelectorOpen = true } label: {
HStack(spacing: 3) {
- Text(selectedModel?.primaryLabel ?? model.localized("模型"))
+ Text(selectedModel.map(optionTitle) ?? model.localized("模型"))
.font(MobileDesignTypography.labelMedium.font)
.foregroundStyle(OpenBitFunTheme.ink)
.lineLimit(1)
@@ -227,6 +227,8 @@ struct ComposerBar: View {
}
.buttonStyle(.plain)
.accessibilityLabel(Text(model.localized("选择模型")))
+ .accessibilityValue(Text(selectedModel.map(optionTitle) ?? model.localized("模型")))
+ .accessibilityIdentifier("composer.modelControl")
.anchorPreference(key: ComposerModelSelectorAnchorKey.self, value: .bounds) { $0 }
}
Spacer(minLength: 0)
@@ -244,27 +246,35 @@ struct ComposerBar: View {
if speech.isListening {
ListeningWave()
}
- TextField(
- "",
- text: $model.draft,
- prompt: Text(speech.isListening ? model.localized("正在聆听") : placeholder)
- .foregroundColor(speech.isListening ? OpenBitFunTheme.statusSuccess : OpenBitFunTheme.muted),
- axis: .vertical
- )
- .font(MobileDesignTypography.bodyLarge.font)
- .foregroundStyle(OpenBitFunTheme.ink)
- .lineLimit(1...maxLines)
- .focused($focused)
- // Expand from the tap itself; keyboard/first-responder startup is
- // independent and must not gate the local composer affordances.
- .simultaneousGesture(TapGesture().onEnded { inputExpansionRequested = true })
- .accessibilityIdentifier("composer.input")
- .submitLabel(.send)
- .onSubmit {
- if canSend { submitMessage() }
- }
- if showsSupplementalVoice, !expanded {
- supplementalVoiceAction
+ ZStack(alignment: .leading) {
+ TextField(
+ "",
+ text: $model.draft,
+ prompt: Text(speech.isListening ? model.localized("正在聆听") : placeholder)
+ .foregroundColor(speech.isListening ? OpenBitFunTheme.statusSuccess : OpenBitFunTheme.muted),
+ axis: .vertical
+ )
+ .font(MobileDesignTypography.bodyLarge.font)
+ .foregroundStyle(!expanded && !model.draft.isEmpty ? OpenBitFunTheme.transparent : OpenBitFunTheme.ink)
+ .lineLimit(1...maxLines)
+ .focused($focused)
+ // Expand from the tap itself; keyboard/first-responder startup is
+ // independent and must not gate the local composer affordances.
+ .simultaneousGesture(TapGesture().onEnded { inputExpansionRequested = true })
+ .accessibilityIdentifier("composer.input")
+ .submitLabel(.send)
+ .onSubmit {
+ if canSend { submitMessage() }
+ }
+ if !expanded && !model.draft.isEmpty {
+ Text(model.draft)
+ .font(MobileDesignTypography.bodyLarge.font)
+ .foregroundStyle(OpenBitFunTheme.ink)
+ .lineLimit(1)
+ .truncationMode(.tail)
+ .allowsHitTesting(false)
+ .accessibilityHidden(true)
+ }
}
}
.padding(.leading, speech.isListening ? 12 : 4)
@@ -452,6 +462,10 @@ struct ComposerBar: View {
ScrollView(showsIndicators: false) {
LazyVStack(spacing: MobileDesignGeometry.composerModelSelectorRowGap) {
ForEach(selectorModels) { option in
+ if option.id == selectorModels.first(where: { $0.role == nil })?.id,
+ selectorModels.contains(where: { $0.role != nil }) {
+ Divider().overlay(OpenBitFunTheme.line)
+ }
Button {
modelSelectorOpen = false
model.selectModel(option.id)
@@ -462,16 +476,25 @@ struct ComposerBar: View {
.foregroundStyle(option.selected ? OpenBitFunTheme.ink : OpenBitFunTheme.transparent)
.frame(width: 20, height: 20)
VStack(alignment: .leading, spacing: 2) {
- Text(option.primaryLabel)
+ Text(optionTitle(option))
.font(MobileDesignTypography.labelMedium.font)
.foregroundStyle(OpenBitFunTheme.ink)
- .fixedSize(horizontal: false, vertical: true)
- Text(option.secondaryLabel)
+ .lineLimit(1)
+ .truncationMode(.tail)
+ Text(optionSubtitle(option))
.font(MobileDesignTypography.bodySmall.font)
.foregroundStyle(OpenBitFunTheme.muted)
.fixedSize(horizontal: false, vertical: true)
}
Spacer(minLength: 0)
+ ForEach(option.roles, id: \.self) { role in
+ Text(model.localized(role == "PRIMARY" ? "主力" : "快速"))
+ .font(MobileDesignTypography.labelSmall.font)
+ .foregroundStyle(OpenBitFunTheme.muted)
+ .padding(.horizontal, 6).padding(.vertical, 1)
+ .background(OpenBitFunTheme.soft)
+ .clipShape(RoundedRectangle(cornerRadius: 4))
+ }
}
.padding(.horizontal, 10)
.padding(.vertical, 8)
@@ -484,6 +507,7 @@ struct ComposerBar: View {
)
}
.buttonStyle(.plain)
+ .accessibilityIdentifier("composer.model.\(option.id)")
}
}
}
@@ -497,7 +521,21 @@ struct ComposerBar: View {
}
private var selectorModels: [ComposerModelOption] {
- model.modelOptions.filter(\.selected) + model.modelOptions.filter { !$0.selected }
+ model.modelOptions.filter { $0.role != nil } +
+ model.modelOptions.filter { $0.role == nil && $0.selected } +
+ model.modelOptions.filter { $0.role == nil && !$0.selected }
+ }
+
+ private func optionTitle(_ option: ComposerModelOption) -> String {
+ switch option.role {
+ case "PRIMARY": return model.localized("主力模型")
+ case "FAST": return model.localized("快速模型")
+ default: return option.primaryLabel
+ }
+ }
+
+ private func optionSubtitle(_ option: ComposerModelOption) -> String {
+ option.fallsBackToPrimary ? model.localized("未配置快速模型,使用主力模型") : option.secondaryLabel
}
private func modelSelectorHeight(asSheet: Bool) -> CGFloat {
diff --git a/src/apps/mobile/ios/OpenBitFun/Features/Settings/AppSettingsView.swift b/src/apps/mobile/ios/OpenBitFun/Features/Settings/AppSettingsView.swift
index 9868077694..6c4733a45e 100644
--- a/src/apps/mobile/ios/OpenBitFun/Features/Settings/AppSettingsView.swift
+++ b/src/apps/mobile/ios/OpenBitFun/Features/Settings/AppSettingsView.swift
@@ -12,8 +12,9 @@ struct SettingsView: View {
}
private var selectedModelName: String {
- model.modelOptions.first(where: \.selected)?.primaryLabel
- ?? model.modelOptions.first?.primaryLabel
+ (model.modelOptions.first(where: \.selected) ?? model.modelOptions.first).map {
+ $0.roleLabelKey.map(model.localized) ?? $0.primaryLabel
+ }
?? model.localized("未配置")
}
diff --git a/src/apps/mobile/ios/OpenBitFun/Features/Shell/MobileShellView.swift b/src/apps/mobile/ios/OpenBitFun/Features/Shell/MobileShellView.swift
index a4b3d8431f..d1ce9f1074 100644
--- a/src/apps/mobile/ios/OpenBitFun/Features/Shell/MobileShellView.swift
+++ b/src/apps/mobile/ios/OpenBitFun/Features/Shell/MobileShellView.swift
@@ -415,6 +415,9 @@ struct MobileShellView: View {
startPoint: .top,
endPoint: .bottom
)
+ // Keep the fade behind the home indicator. safeAreaInset
+ // reserves room for controls and the scroll content at rest.
+ .ignoresSafeArea(.container, edges: .bottom)
)
.background(
GeometryReader { proxy in
diff --git a/src/apps/mobile/ios/OpenBitFun/Features/Shell/RemoteCreateSessionView.swift b/src/apps/mobile/ios/OpenBitFun/Features/Shell/RemoteCreateSessionView.swift
index d38e876511..bded3f3cd1 100644
--- a/src/apps/mobile/ios/OpenBitFun/Features/Shell/RemoteCreateSessionView.swift
+++ b/src/apps/mobile/ios/OpenBitFun/Features/Shell/RemoteCreateSessionView.swift
@@ -224,7 +224,7 @@ struct RemoteCreateSessionView: View {
if let selectedModel {
Button { pickerKind = .model } label: {
HStack(spacing: 4) {
- Text(selectedModel.primaryLabel)
+ Text(selectedModel.roleLabelKey.map(model.localized) ?? selectedModel.primaryLabel)
.font(.system(size: 13, weight: .medium))
.foregroundStyle(OpenBitFunTheme.ink)
.lineLimit(1)
@@ -239,7 +239,7 @@ struct RemoteCreateSessionView: View {
[.model: $0]
}
.accessibilityLabel(model.localized(RemoteCreateSelectionKind.model.accessibilityLabelKey))
- .accessibilityValue(selectedModel.primaryLabel)
+ .accessibilityValue(selectedModel.roleLabelKey.map(model.localized) ?? selectedModel.primaryLabel)
.accessibilityHint(model.localized(RemoteCreateSelectionKind.model.accessibilityHintKey))
.disabled(model.remoteCreateSubmitting || model.isSending)
}
@@ -467,8 +467,8 @@ struct RemoteCreateSessionView: View {
selectionRow(
kind: .model,
icon: option.source == "LOCAL" ? "gearshape" : "cloud",
- title: option.primaryLabel,
- subtitle: option.secondaryLabel,
+ title: option.roleLabelKey.map(model.localized) ?? option.primaryLabel,
+ subtitle: option.fallsBackToPrimary ? model.localized("未配置快速模型,使用主力模型") : option.secondaryLabel,
selected: option.id == selectedModelID,
enabled: true
) {
diff --git a/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel+GeneralChat.swift b/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel+GeneralChat.swift
index 8ecff9c318..c0bd384a0a 100644
--- a/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel+GeneralChat.swift
+++ b/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel+GeneralChat.swift
@@ -54,6 +54,17 @@ extension MobileAppModel {
}
func selectModel(_ modelID: String) {
+ #if DEBUG
+ if composerModelPickerPreview {
+ modelOptions = modelOptions.map { option in
+ ComposerModelOption(id: option.id, primaryLabel: option.primaryLabel,
+ secondaryLabel: option.secondaryLabel, source: option.source,
+ selected: option.id == modelID, role: option.role, roles: option.roles,
+ fallsBackToPrimary: option.fallsBackToPrimary)
+ }
+ return
+ }
+ #endif
guard surface == .remote, remoteSessionSelected,
let sessionID = RemoteAuthorityGate.sendSessionID(
selectedSessionID: selectedSessionID, openedSessionID: remoteOpenedSessionID,
diff --git a/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel+RemoteSession.swift b/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel+RemoteSession.swift
index 5d56fd3c64..6915a60ef2 100644
--- a/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel+RemoteSession.swift
+++ b/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel+RemoteSession.swift
@@ -1153,7 +1153,10 @@ extension MobileAppModel {
primaryLabel: option.primaryLabel,
secondaryLabel: option.secondaryLabel,
source: "REMOTE",
- selected: option.selected
+ selected: option.selected,
+ role: option.role?.name,
+ roles: option.roles.map { $0.name },
+ fallsBackToPrimary: option.fallsBackToPrimary
)
}
setPublishedIfChanged(\.modelOptions, to: projectedModelOptions)
diff --git a/src/apps/mobile/ios/OpenBitFun/Presentation/Models/MobilePresentationModels.swift b/src/apps/mobile/ios/OpenBitFun/Presentation/Models/MobilePresentationModels.swift
index bb1a51268c..6a8fb008d7 100644
--- a/src/apps/mobile/ios/OpenBitFun/Presentation/Models/MobilePresentationModels.swift
+++ b/src/apps/mobile/ios/OpenBitFun/Presentation/Models/MobilePresentationModels.swift
@@ -529,6 +529,17 @@ struct ComposerModelOption: Identifiable, Equatable {
let secondaryLabel: String
let source: String
let selected: Bool
+ var role: String? = nil
+ var roles: [String] = []
+ var fallsBackToPrimary: Bool = false
+
+ var roleLabelKey: String? {
+ switch role {
+ case "PRIMARY": return "主力模型"
+ case "FAST": return "快速模型"
+ default: return nil
+ }
+ }
}
enum MobileDownloadPhase {
diff --git a/src/apps/mobile/ios/OpenBitFun/Resources/Localizable.xcstrings b/src/apps/mobile/ios/OpenBitFun/Resources/Localizable.xcstrings
index 6b46df16a3..8043e6c9bf 100644
--- a/src/apps/mobile/ios/OpenBitFun/Resources/Localizable.xcstrings
+++ b/src/apps/mobile/ios/OpenBitFun/Resources/Localizable.xcstrings
@@ -8232,6 +8232,86 @@
}
}
}
+ },
+ "主力模型": {
+ "localizations": {
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "主力模型"
+ }
+ },
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Primary model"
+ }
+ }
+ }
+ },
+ "快速模型": {
+ "localizations": {
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "快速模型"
+ }
+ },
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Fast model"
+ }
+ }
+ }
+ },
+ "未配置快速模型,使用主力模型": {
+ "localizations": {
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "未配置快速模型,使用主力模型"
+ }
+ },
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Fast model not configured; using primary"
+ }
+ }
+ }
+ },
+ "主力": {
+ "localizations": {
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "主力"
+ }
+ },
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Primary"
+ }
+ }
+ }
+ },
+ "快速": {
+ "localizations": {
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "快速"
+ }
+ },
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Fast"
+ }
+ }
+ }
}
},
"version": "1.0"
diff --git a/src/apps/mobile/ios/OpenBitFunUITests/RemoteCodeSessionSendUITests.swift b/src/apps/mobile/ios/OpenBitFunUITests/RemoteCodeSessionSendUITests.swift
index 22dc48be4a..2a16384d00 100644
--- a/src/apps/mobile/ios/OpenBitFunUITests/RemoteCodeSessionSendUITests.swift
+++ b/src/apps/mobile/ios/OpenBitFunUITests/RemoteCodeSessionSendUITests.swift
@@ -948,6 +948,43 @@ private extension XCUIApplication {
}
final class StreamingPresentationUITests: XCTestCase {
+ func testCollapsedDraftKeepsTextAndShowsVoiceOnlyWhenExpanded() {
+ let app = XCUIApplication()
+ app.launchArguments = ["--composer-draft-preview"]
+ app.launch()
+ let input = app.textFields["composer.input"]
+ XCTAssertTrue(input.waitForExistence(timeout: 15))
+ let original = input.value as? String
+ XCTAssertTrue((original?.count ?? 0) > 300)
+ let voice = app.buttons.matching(NSPredicate(format: "label == %@ OR label == %@", "语音输入", "Voice input"))
+ XCTAssertEqual(voice.count, 0)
+ let collapsed = XCTAttachment(screenshot: app.screenshot())
+ collapsed.name = "Collapsed long draft"
+ collapsed.lifetime = .keepAlways
+ add(collapsed)
+ input.tap()
+ XCTAssertTrue(voice.firstMatch.waitForExistence(timeout: 3))
+ XCTAssertEqual(input.value as? String, original)
+ }
+
+ func testComposerRoleRowsSelectSemanticModel() {
+ let app = XCUIApplication()
+ app.launchArguments = ["--composer-model-picker"]
+ app.launch()
+ let primary = app.buttons["composer.model.primary"]
+ let fast = app.buttons["composer.model.fast"]
+ XCTAssertTrue(primary.waitForExistence(timeout: 15))
+ XCTAssertTrue(fast.exists)
+ let menu = XCTAttachment(screenshot: app.screenshot())
+ menu.name = "Model role picker"
+ menu.lifetime = .keepAlways
+ add(menu)
+ fast.tap()
+ let control = app.buttons["composer.modelControl"]
+ XCTAssertTrue(control.waitForExistence(timeout: 3))
+ XCTAssertTrue(["快速模型", "Fast model"].contains(control.value as? String ?? ""))
+ }
+
func testDirectoryOpenRoutesBeforeAuthorityIsReady() {
let app = XCUIApplication()
app.launchArguments = ["--streaming-regression", "--fixture-shell", "--open-loading-regression", "--status-regression"]
diff --git a/src/apps/mobile/shared/core-domain/src/commonMain/kotlin/com/openbitfun/mobile/core/domain/ChatTimelineStore.kt b/src/apps/mobile/shared/core-domain/src/commonMain/kotlin/com/openbitfun/mobile/core/domain/ChatTimelineStore.kt
index ab0539bae3..3208d22074 100644
--- a/src/apps/mobile/shared/core-domain/src/commonMain/kotlin/com/openbitfun/mobile/core/domain/ChatTimelineStore.kt
+++ b/src/apps/mobile/shared/core-domain/src/commonMain/kotlin/com/openbitfun/mobile/core/domain/ChatTimelineStore.kt
@@ -896,7 +896,7 @@ public class ChatTimelineStore public constructor() {
private fun selectedModelIdForCatalog(catalog: RemoteModelCatalog, current: String): String {
catalog.sessionModelId?.takeIf(String::isNotEmpty)?.let { return it }
- if (current.isNotEmpty() && catalog.models.any { it.id == current && it.enabled }) return current
+ if (current.isNotEmpty() && ModelSelectionPolicy.resolve(catalog, current) != null) return current.trim()
return catalog.defaultModels.primary ?: catalog.defaultModels.fast ?: current
}
}
diff --git a/src/apps/mobile/shared/core-domain/src/commonMain/kotlin/com/openbitfun/mobile/core/domain/ModelSelectionPolicy.kt b/src/apps/mobile/shared/core-domain/src/commonMain/kotlin/com/openbitfun/mobile/core/domain/ModelSelectionPolicy.kt
new file mode 100644
index 0000000000..3aa1842d75
--- /dev/null
+++ b/src/apps/mobile/shared/core-domain/src/commonMain/kotlin/com/openbitfun/mobile/core/domain/ModelSelectionPolicy.kt
@@ -0,0 +1,20 @@
+package com.openbitfun.mobile.core.domain
+
+import com.openbitfun.mobile.core.protocol.RemoteModelCatalog
+import com.openbitfun.mobile.core.protocol.RemoteModelConfig
+
+/** Host model selectors remain roles until the host resolves them for execution. */
+public object ModelSelectionPolicy {
+ public fun isRole(selector: String): Boolean = selector.trim() in listOf("primary", "fast")
+
+ public fun resolve(catalog: RemoteModelCatalog, selector: String): RemoteModelConfig? {
+ fun enabled(id: String?): RemoteModelConfig? = catalog.models.firstOrNull {
+ it.enabled && it.id == id.orEmpty().trim() && it.id.isNotEmpty()
+ }
+ return when (val value = selector.trim()) {
+ "primary" -> enabled(catalog.defaultModels.primary)
+ "fast" -> enabled(catalog.defaultModels.fast) ?: enabled(catalog.defaultModels.primary)
+ else -> enabled(value)
+ }
+ }
+}
diff --git a/src/apps/mobile/shared/core-domain/src/commonTest/kotlin/com/openbitfun/mobile/core/domain/ModelSelectionPolicyTest.kt b/src/apps/mobile/shared/core-domain/src/commonTest/kotlin/com/openbitfun/mobile/core/domain/ModelSelectionPolicyTest.kt
new file mode 100644
index 0000000000..6e1e958b71
--- /dev/null
+++ b/src/apps/mobile/shared/core-domain/src/commonTest/kotlin/com/openbitfun/mobile/core/domain/ModelSelectionPolicyTest.kt
@@ -0,0 +1,22 @@
+package com.openbitfun.mobile.core.domain
+
+import com.openbitfun.mobile.core.protocol.RemoteModelCatalog
+import com.openbitfun.mobile.core.protocol.RemoteModelConfig
+import com.openbitfun.mobile.core.protocol.RemoteDefaultModels
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNull
+
+class ModelSelectionPolicyTest {
+ @Test
+ fun rolesUseEnabledDefaultsAndFastFallsBack() {
+ val catalog = RemoteModelCatalog(1, listOf(
+ RemoteModelConfig("a", "A", "provider", "", "a", enabled = true),
+ RemoteModelConfig("b", "B", "provider", "", "b", enabled = false)),
+ RemoteDefaultModels(primary = " a ", fast = "b"))
+ assertEquals("a", ModelSelectionPolicy.resolve(catalog, " primary ")?.id)
+ assertEquals("a", ModelSelectionPolicy.resolve(catalog, " fast ")?.id)
+ assertNull(ModelSelectionPolicy.resolve(catalog, "b"))
+ assertNull(ModelSelectionPolicy.resolve(catalog, "unknown"))
+ }
+}
diff --git a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/session/ConversationModelPresentation.kt b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/session/ConversationModelPresentation.kt
index c43a4c6197..8ffb940d74 100644
--- a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/session/ConversationModelPresentation.kt
+++ b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/session/ConversationModelPresentation.kt
@@ -2,110 +2,96 @@ package com.openbitfun.mobile.core.feature.session
import com.openbitfun.mobile.core.domain.ChatTimelineState
import com.openbitfun.mobile.core.domain.ModelLabelPolicy
+import com.openbitfun.mobile.core.domain.ModelSelectionPolicy
import com.openbitfun.mobile.core.protocol.RemoteModelCatalog
-/**
- * One row of the model picker.
- *
- * Plain strings rather than the protocol's `RemoteModelConfig`: the picker shows
- * two lines and sends an id back, and nothing above this seam should have to
- * know the wire shape. See the design doc section 2.
- */
+/** Stable role facts; localized names belong to each app. */
+public enum class ModelRole(public val selector: String) {
+ PRIMARY("primary"),
+ FAST("fast"),
+}
+
+/** Role selectors remain distinct from the concrete models they resolve to. */
public data class ModelOption public constructor(
public val id: String,
public val primaryLabel: String,
public val secondaryLabel: String,
public val selected: Boolean,
-)
-
-/**
- * The models this session may switch to, already labelled.
- *
- * Disabled models are dropped rather than greyed out — the desktop refuses them,
- * so showing them would only offer a control that cannot work. [fallbackLabel]
- * names a model whose every field is blank; the copy comes from the app.
- */
-public fun ChatTimelineState.modelOptions(fallbackLabel: String): List {
- val selectedId = selectedModelOptionId()
- return modelCatalog.models.filter { it.enabled }.map { model ->
- ModelOption(
- id = model.id,
- primaryLabel = ModelLabelPolicy.primaryLabel(
- model.id,
- model.name,
- model.modelName,
- fallbackLabel,
- ),
- secondaryLabel = ModelLabelPolicy.secondaryLabel(
- model.id,
- model.name,
- model.modelName,
- model.provider,
- fallbackLabel,
- ),
- selected = model.id == selectedId,
- )
- }
+ public val role: ModelRole?,
+ public val roles: List,
+ public val fallsBackToPrimary: Boolean,
+) {
+ /** Keep existing native callers source compatible. */
+ public constructor(id: String, primaryLabel: String, secondaryLabel: String, selected: Boolean) :
+ this(id, primaryLabel, secondaryLabel, selected, null, emptyList(), false)
}
-/** The option the composer's model chip names, or null when nothing is usable. */
+/** Enabled models plus resolvable roles, with explicit selection taking priority. */
+public fun ChatTimelineState.modelOptions(fallbackLabel: String): List =
+ modelCatalog.presentationOptions(
+ fallbackLabel,
+ listOf(selectedModelId, modelCatalog.sessionModelId, modelCatalog.defaultModels.primary),
+ )
+
public fun ChatTimelineState.selectedModelOption(fallbackLabel: String): ModelOption? =
modelOptions(fallbackLabel).firstOrNull { it.selected }
/** Models available while creating a session, before a transcript exists. */
public fun RemoteSessionUiState.Ready.createModelOptions(fallbackLabel: String): List {
val catalog = modelCatalog ?: timeline?.modelCatalog ?: return emptyList()
- val selectedId = listOf(
- timeline?.selectedModelId,
- catalog.sessionModelId,
- catalog.defaultModels.primary,
- ).firstNotNullOfOrNull { candidate ->
- candidate?.takeIf { id -> catalog.models.any { model -> model.id == id && model.enabled } }
- }
- return catalog.presentationOptions(fallbackLabel, selectedId)
+ return catalog.presentationOptions(
+ fallbackLabel,
+ listOf(timeline?.selectedModelId, catalog.sessionModelId, catalog.defaultModels.primary),
+ )
}
private fun RemoteModelCatalog.presentationOptions(
fallbackLabel: String,
- selectedId: String?,
-): List = models.filter { it.enabled }.map { model ->
- ModelOption(
- id = model.id,
- primaryLabel = ModelLabelPolicy.primaryLabel(
- model.id,
- model.name,
- model.modelName,
- fallbackLabel,
- ),
- secondaryLabel = ModelLabelPolicy.secondaryLabel(
- model.id,
- model.name,
- model.modelName,
- model.provider,
- fallbackLabel,
- ),
- selected = model.id == selectedId,
- )
-}
+ candidates: List,
+): List {
+ val selectedId = candidates.firstNotNullOfOrNull { candidate ->
+ candidate?.trim()?.takeIf { ModelSelectionPolicy.resolve(this, it) != null }
+ }
+ fun name(id: String, name: String, modelName: String): String =
+ ModelLabelPolicy.primaryLabel(id, name, modelName, fallbackLabel)
-/**
- * Which model the desktop would actually use.
- *
- * The explicit selection wins, then the session's own model, then the account
- * default — each only if the catalog still has it *and* still has it enabled,
- * which is why this is a search rather than a read. Ported from
- * `ConversationModelPresentationPolicy.selectedModel`.
- */
-private fun ChatTimelineState.selectedModelOptionId(): String? {
- val candidates = listOf(
- selectedModelId,
- modelCatalog.sessionModelId,
- modelCatalog.defaultModels.primary,
- )
- candidates.forEach { candidate ->
- if (candidate.isNullOrEmpty()) return@forEach
- val match = modelCatalog.models.firstOrNull { it.id == candidate && it.enabled }
- if (match != null) return match.id
+ val defaults = ModelRole.entries.mapNotNull { role ->
+ val model = ModelSelectionPolicy.resolve(this, role.selector) ?: return@mapNotNull null
+ val modelName = name(model.id, model.name, model.modelName)
+ val metadata = listOfNotNull(
+ model.provider.trim().takeIf { it.isNotEmpty() },
+ model.contextWindow?.takeIf { it > 0 }?.let {
+ "${kotlin.math.round(it / 1000.0).toInt()}k"
+ },
+ )
+ ModelOption(
+ id = role.selector,
+ primaryLabel = modelName,
+ secondaryLabel = (listOf(modelName) + metadata).joinToString(" · "),
+ selected = selectedId == role.selector,
+ role = role,
+ roles = emptyList(),
+ fallsBackToPrimary = role == ModelRole.FAST && models.none {
+ it.enabled && it.id == defaultModels.fast.orEmpty().trim()
+ },
+ )
+ }
+ val concrete = models.filter { it.enabled }.map { model ->
+ val roles = ModelRole.entries.filter { role ->
+ val configured = if (role == ModelRole.PRIMARY) defaultModels.primary else defaultModels.fast
+ model.id == configured.orEmpty().trim()
+ }
+ ModelOption(
+ id = model.id,
+ primaryLabel = name(model.id, model.name, model.modelName),
+ secondaryLabel = ModelLabelPolicy.secondaryLabel(
+ model.id, model.name, model.modelName, model.provider, fallbackLabel,
+ ),
+ selected = selectedId == model.id,
+ role = null,
+ roles = roles,
+ fallsBackToPrimary = false,
+ )
}
- return null
+ return defaults + concrete
}
diff --git a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/session/MessageBlockPresentation.kt b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/session/MessageBlockPresentation.kt
index 0c9d6d2085..32cda2ecac 100644
--- a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/session/MessageBlockPresentation.kt
+++ b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/session/MessageBlockPresentation.kt
@@ -106,7 +106,7 @@ private fun scopeSubagentItems(items: List): List()
for (entry in items) {
- if (entry.isSubagent != true && entry.tool?.let(ToolNamePolicy::isTask) == true) {
+ if (entry.tool?.let(ToolNamePolicy::isTask) == true) {
result += entry
taskIndex = result.lastIndex
carried = result.last().subItems.orEmpty().indices.toMutableList()
@@ -144,7 +144,8 @@ private fun scopeSubagentItems(items: List): List = emptyList(),
sessionModelId: String? = null,
primary: String? = null,
+ fast: String? = null,
selectedModelId: String = "",
) = ChatTimelineState(
sessionId = "s-1",
@@ -107,7 +131,7 @@ private fun timeline(
modelCatalog = RemoteModelCatalog(
version = 1,
models = models,
- defaultModels = RemoteDefaultModels(primary = primary),
+ defaultModels = RemoteDefaultModels(primary = primary, fast = fast),
sessionModelId = sessionModelId,
),
selectedModelId = selectedModelId,
diff --git a/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/session/MessageBlockPresentationTest.kt b/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/session/MessageBlockPresentationTest.kt
index a6fa93ef1e..5b9de92922 100644
--- a/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/session/MessageBlockPresentationTest.kt
+++ b/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/session/MessageBlockPresentationTest.kt
@@ -8,6 +8,29 @@ import kotlin.test.assertEquals
import kotlin.test.assertTrue
class MessageBlockPresentationTest {
+ @Test
+ fun markedOwnerKeepsLiveChildrenAndStableIdentity() {
+ val owner = item(tool = tool("owner", name = "Task", status = "running")).copy(isSubagent = true)
+ val thought = item(type = "thinking", content = "Inspecting").copy(isSubagent = true)
+ val before = messageBlocks(message(items = listOf(owner)), true).single() as MessageBlock.Subagent
+ val after = messageBlocks(message(items = listOf(owner, thought)), true).single() as MessageBlock.Subagent
+ assertEquals(before.id, after.id)
+ assertEquals("Inspecting", (after.children.single() as MessageBlock.Thinking).text)
+ val completed = messageBlocks(message(items = listOf(owner.copy(tool = owner.tool!!.copy(status = "completed")), thought)), false).single() as MessageBlock.Subagent
+ assertEquals(after.id, completed.id)
+ assertEquals(1, completed.children.size)
+ assertEquals(false, completed.running)
+ }
+
+ @Test
+ fun orphanReasoningHasOnlyOneCopyInsideItsBranch() {
+ val orphan = item(type = "thinking", content = "Early reasoning").copy(isSubagent = true)
+ val block = messageBlocks(message(items = listOf(orphan)), true).single() as MessageBlock.Subagent
+ assertEquals("", block.title)
+ assertEquals("", block.text)
+ assertEquals("Early reasoning", (block.children.single() as MessageBlock.Thinking).text)
+ }
+
@Test
fun markedFlatChildrenBelongToTheirTaskNotTheMainTranscript() {
val task1 = item(tool = tool("task1", name = "Task", status = "running"))