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"))