From 75bca9c78d10ceae4ddc0fe3f06d0a05509a2104 Mon Sep 17 00:00:00 2001 From: wgqqqqq Date: Wed, 26 Aug 2026 09:26:29 +0800 Subject: [PATCH 1/5] feat(mobile): align Android and iOS remote experiences - add adaptive modal, session, pairing, model, and tool-question flows - improve remote session paging, workspace creation, account expiry, and timeline sync - add iOS localization, speech, attachments, file preview, and native parity - sync mobile design contracts, generated tokens, and focused tests --- .gitignore | 1 + src/apps/mobile/android/app/build.gradle.kts | 4 +- .../mobile/app/AccountRemoteScreenTest.kt | 70 + .../mobile/app/ChatMessageBubbleTest.kt | 8 +- .../bitfun/mobile/app/ConversationViewTest.kt | 4 + .../mobile/app/CreateSessionScreenTest.kt | 75 + .../mobile/app/RemoteSessionListViewTest.kt | 59 + .../mobile/app/platform/WindowMetrics.kt | 28 +- .../bitfun/mobile/app/state/AppShellState.kt | 19 + .../mobile/app/ui/account/AccountScreen.kt | 2 +- .../app/ui/chat/BitFunHeaderActionMenu.kt | 20 +- .../mobile/app/ui/chat/ChatMessageBubble.kt | 4 + .../bitfun/mobile/app/ui/chat/ComposerBar.kt | 181 +- .../mobile/app/ui/chat/ConversationHeader.kt | 4 +- .../app/ui/chat/ConversationTimelineView.kt | 17 + .../mobile/app/ui/chat/ConversationView.kt | 24 +- .../mobile/app/ui/chat/GeneralChatScreen.kt | 92 +- .../app/ui/chat/message/MessageBlockList.kt | 3 + .../app/ui/chat/tool/ToolInteractionPanels.kt | 120 + .../mobile/app/ui/chat/tool/ToolStatusList.kt | 28 +- .../app/ui/common/AdaptiveModalSurface.kt | 98 + .../ui/common/SignedOutConnectionActions.kt | 77 + .../ui/remote/ConnectAccountDeviceScreen.kt | 282 ++ .../mobile/app/ui/remote/ConnectView.kt | 52 +- .../app/ui/remote/CreateSessionScreen.kt | 253 +- .../mobile/app/ui/remote/PairingScreen.kt | 62 +- .../app/ui/remote/RemoteSessionListView.kt | 297 +- .../app/ui/remote/SessionActionSheet.kt | 189 +- .../app/ui/session/RenameSessionDialog.kt | 64 - .../app/ui/settings/GeneralSettingsScreen.kt | 133 +- .../app/ui/settings/ModelServiceScreen.kt | 286 +- .../app/ui/settings/PermissionModeCard.kt | 6 +- .../mobile/app/ui/settings/SettingsChrome.kt | 6 +- .../app/ui/shell/BitFunCompactDrawer.kt | 165 +- .../mobile/app/ui/shell/MobileScreen.kt | 231 +- .../mobile/app/ui/shell/sidebar/AppSidebar.kt | 55 +- .../app/ui/shell/sidebar/SidebarFooter.kt | 30 +- .../sidebar/SidebarRemoteWorkspaceSection.kt | 43 +- .../ui/shell/sidebar/SidebarSessionList.kt | 31 +- .../com/bitfun/mobile/app/ui/theme/Theme.kt | 3 + .../ui/theme/generated/MobileDesignTokens.kt | 40 +- .../app/src/main/res/values-zh/strings.xml | 13 + .../app/src/main/res/values/strings.xml | 13 + .../ui/chat/tool/ToolInteractionPanelsTest.kt | 81 + .../components/mobile-components.json | 91 + .../preview/generated/mobile-design-data.js | 418 ++- .../design-system/tokens/mobile-tokens.json | 40 +- .../main/ets/generated/MobileDesignTokens.ets | 36 + .../main/ets/pages/components/ComposerBar.ets | 14 +- .../main/resources/base/element/color.json | 2 +- .../main/resources/dark/element/color.json | 2 +- .../ios/BitFun.xcodeproj/project.pbxproj | 27 +- .../mobile/ios/BitFun/App/BitFunApp.swift | 3 + .../Features/Chat/ChatTimelineView.swift | 835 ++++- .../BitFun/Features/Chat/ComposerBar.swift | 576 +++- .../Features/Chat/ConversationHeader.swift | 270 +- .../AdaptiveModalComponents.swift | 276 ++ .../GeneratedMobileDesignTokens.swift | 38 +- .../DesignSystem/MobileDesignGallery.swift | 14 +- .../Features/Shell/MobileShellView.swift | 2843 +++++++++++++++-- .../Shell/SessionActionComponents.swift | 297 ++ .../BitFun/Features/Shell/SidebarView.swift | 860 ++++- src/apps/mobile/ios/BitFun/Info.plist | 6 +- .../Infrastructure/MobileAppModel.swift | 1608 +++++++++- .../Infrastructure/MobileCoreAdapter.swift | 365 ++- .../Infrastructure/MobileLocalization.swift | 49 + .../BitFun/Resources/Localizable.xcstrings | 2688 ++++++++++++++++ src/apps/mobile/ios/README.md | 7 +- .../core/domain/ChatTimelineProjector.kt | 24 +- .../mobile/core/domain/ChatTimelineStore.kt | 87 +- .../mobile/core/domain/SessionListPolicy.kt | 8 + .../mobile/core/domain/ToolQuestionPolicy.kt | 69 + .../core/domain/ChatTimelineProjectorTest.kt | 18 +- .../core/domain/ChatTimelineStoreTest.kt | 39 + .../core/domain/SessionListPolicyTest.kt | 7 + .../core/domain/ToolQuestionPolicyTest.kt | 85 + .../core/feature/account/AccountDefaults.kt | 8 + .../core/feature/account/AccountStore.kt | 24 +- .../feature/layout/SettingsPlacementPolicy.kt | 197 ++ .../session/ConversationModelPresentation.kt | 37 + .../session/ConversationPresentation.kt | 47 +- .../feature/session/RemoteSessionStore.kt | 197 +- .../feature/session/RemoteSessionUiState.kt | 30 +- .../core/feature/session/ToolQuestion.kt | 29 + .../core/feature/account/AccountStoreTest.kt | 19 + .../layout/SettingsPlacementPolicyTest.kt | 105 + .../session/ConversationPresentationTest.kt | 35 +- .../feature/session/RemoteSessionStoreTest.kt | 122 +- .../shell/RemoteSidebarPresentationTest.kt | 2 + .../core/transport/CloudAccountClient.kt | 19 +- .../core/transport/CloudAccountClientTest.kt | 2 + 91 files changed, 14472 insertions(+), 1376 deletions(-) create mode 100644 src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/common/AdaptiveModalSurface.kt create mode 100644 src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/common/SignedOutConnectionActions.kt create mode 100644 src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/remote/ConnectAccountDeviceScreen.kt delete mode 100644 src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/session/RenameSessionDialog.kt create mode 100644 src/apps/mobile/android/app/src/test/kotlin/com/bitfun/mobile/app/ui/chat/tool/ToolInteractionPanelsTest.kt create mode 100644 src/apps/mobile/ios/BitFun/Features/DesignSystem/AdaptiveModalComponents.swift create mode 100644 src/apps/mobile/ios/BitFun/Features/Shell/SessionActionComponents.swift create mode 100644 src/apps/mobile/ios/BitFun/Infrastructure/MobileLocalization.swift create mode 100644 src/apps/mobile/ios/BitFun/Resources/Localizable.xcstrings create mode 100644 src/apps/mobile/shared/core-domain/src/commonMain/kotlin/com/bitfun/mobile/core/domain/ToolQuestionPolicy.kt create mode 100644 src/apps/mobile/shared/core-domain/src/commonTest/kotlin/com/bitfun/mobile/core/domain/ToolQuestionPolicyTest.kt create mode 100644 src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/bitfun/mobile/core/feature/account/AccountDefaults.kt create mode 100644 src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/bitfun/mobile/core/feature/layout/SettingsPlacementPolicy.kt create mode 100644 src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/bitfun/mobile/core/feature/session/ToolQuestion.kt create mode 100644 src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/bitfun/mobile/core/feature/layout/SettingsPlacementPolicyTest.kt diff --git a/.gitignore b/.gitignore index 852881de86..d7a96eb19c 100644 --- a/.gitignore +++ b/.gitignore @@ -111,3 +111,4 @@ src/apps/mobile/android/build/ src/apps/mobile/android/*/build/ src/apps/mobile/android/local.properties src/apps/mobile/android/.kotlin/ +/src/apps/mobile/ios/build/ diff --git a/src/apps/mobile/android/app/build.gradle.kts b/src/apps/mobile/android/app/build.gradle.kts index 07d61049c0..9112ab90ed 100644 --- a/src/apps/mobile/android/app/build.gradle.kts +++ b/src/apps/mobile/android/app/build.gradle.kts @@ -26,7 +26,7 @@ android { minSdk = libs.versions.androidMinSdk.get().toInt() targetSdk = libs.versions.androidTargetSdk.get().toInt() versionCode = 1 - versionName = "0.1.0" + versionName = "1.0.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } @@ -79,6 +79,8 @@ dependencies { implementation(libs.google.code.scanner) implementation(libs.androidx.window) + testImplementation("junit:junit:4.13.2") + debugImplementation(libs.compose.ui.tooling) debugImplementation(libs.compose.ui.test.manifest) androidTestImplementation(platform(libs.compose.bom)) diff --git a/src/apps/mobile/android/app/src/androidTest/kotlin/com/bitfun/mobile/app/AccountRemoteScreenTest.kt b/src/apps/mobile/android/app/src/androidTest/kotlin/com/bitfun/mobile/app/AccountRemoteScreenTest.kt index fb43a183c2..cecbad1b48 100644 --- a/src/apps/mobile/android/app/src/androidTest/kotlin/com/bitfun/mobile/app/AccountRemoteScreenTest.kt +++ b/src/apps/mobile/android/app/src/androidTest/kotlin/com/bitfun/mobile/app/AccountRemoteScreenTest.kt @@ -5,14 +5,25 @@ import androidx.compose.ui.test.assertCountEquals import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.v2.createComposeRule import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import com.bitfun.mobile.app.ui.remote.CONNECT_ACCOUNT_DEVICE_REFRESH_TEST_TAG +import com.bitfun.mobile.app.ui.remote.CONNECT_ACCOUNT_DEVICE_ROW_TEST_TAG_PREFIX +import com.bitfun.mobile.app.ui.remote.CONNECT_ACCOUNT_DEVICE_SCAN_TEST_TAG +import com.bitfun.mobile.app.ui.remote.ConnectAccountDeviceScreen import com.bitfun.mobile.app.ui.remote.AccountRemoteScreen import com.bitfun.mobile.app.ui.theme.BitFunTheme +import com.bitfun.mobile.core.feature.account.AccountDeviceUi +import com.bitfun.mobile.core.feature.account.AccountUiState import com.bitfun.mobile.core.feature.connection.ConnectionPhase +import com.bitfun.mobile.core.feature.layout.SettingsPlacement +import com.bitfun.mobile.core.feature.layout.SettingsPlacementMode import com.bitfun.mobile.core.feature.session.RemoteSessionUiState import com.bitfun.mobile.core.feature.workspace.RemoteWorkspaceUiState import org.junit.Rule import org.junit.Test +import org.junit.Assert.assertEquals class AccountRemoteScreenTest { @get:Rule @@ -27,8 +38,29 @@ class AccountRemoteScreenTest { workspaceState = RemoteWorkspaceUiState.Idle, deviceId = "device-1", deviceName = "Studio Mac", + createDevices = emptyList(), accountUsername = "tester", phase = ConnectionPhase.CONNECTED, + settingsPlacement = SettingsPlacement( + mode = SettingsPlacementMode.BOTTOM, + width = 0, + height = 0, + maxHeight = 0, + ), + sessionDetailsPlacement = SettingsPlacement( + mode = SettingsPlacementMode.BOTTOM, + width = 0, + height = 0, + maxHeight = 0, + ), + viewSettingsPlacement = SettingsPlacement( + mode = SettingsPlacementMode.BOTTOM, + width = 0, + height = 0, + maxHeight = 0, + ), + onOpenRemoteSettings = {}, + onCreateDevicePick = {}, onSessionIntent = {}, onWorkspaceIntent = {}, modifier = Modifier, @@ -38,4 +70,42 @@ class AccountRemoteScreenTest { composeRule.onAllNodesWithText("Connect to a desktop").assertCountEquals(0) } + + @Test + fun aSignedInAccountWithoutATargetCanRefreshSelectOrScan() { + var refreshes = 0 + var selected = "" + var scans = 0 + composeRule.setContent { + BitFunTheme(dark = false) { + ConnectAccountDeviceScreen( + state = AccountUiState.Ready( + userId = "user-1", + username = "tester", + devices = listOf( + AccountDeviceUi("desk-1", "Studio Mac", online = true, lastSeenAt = null), + AccountDeviceUi("desk-2", "Office PC", online = false, lastSeenAt = null), + ), + selectedDeviceId = null, + selectedDeviceName = null, + ), + onBack = {}, + onRefresh = { refreshes += 1 }, + onSelect = { selected = it }, + onOpenScanner = { scans += 1 }, + modifier = Modifier, + ) + } + } + + composeRule.onNodeWithText("Choose a desktop").assertIsDisplayed() + composeRule.onNodeWithTag(CONNECT_ACCOUNT_DEVICE_REFRESH_TEST_TAG).performClick() + composeRule.onNodeWithTag(CONNECT_ACCOUNT_DEVICE_ROW_TEST_TAG_PREFIX + "desk-1").performClick() + composeRule.onNodeWithTag(CONNECT_ACCOUNT_DEVICE_ROW_TEST_TAG_PREFIX + "desk-2").performClick() + composeRule.onNodeWithTag(CONNECT_ACCOUNT_DEVICE_SCAN_TEST_TAG).performClick() + + assertEquals(1, refreshes) + assertEquals("desk-1", selected) + assertEquals(1, scans) + } } diff --git a/src/apps/mobile/android/app/src/androidTest/kotlin/com/bitfun/mobile/app/ChatMessageBubbleTest.kt b/src/apps/mobile/android/app/src/androidTest/kotlin/com/bitfun/mobile/app/ChatMessageBubbleTest.kt index e076cd66c1..8bf4656f0a 100644 --- a/src/apps/mobile/android/app/src/androidTest/kotlin/com/bitfun/mobile/app/ChatMessageBubbleTest.kt +++ b/src/apps/mobile/android/app/src/androidTest/kotlin/com/bitfun/mobile/app/ChatMessageBubbleTest.kt @@ -6,6 +6,7 @@ import androidx.compose.ui.test.getUnclippedBoundsInRoot import androidx.compose.ui.test.junit4.v2.createComposeRule import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText +import androidx.test.platform.app.InstrumentationRegistry import com.bitfun.mobile.app.ui.chat.ChatMessageBubble import com.bitfun.mobile.app.ui.chat.message.SUBAGENT_GROUP_TEST_TAG import com.bitfun.mobile.app.ui.chat.message.TYPING_DOTS_TEST_TAG @@ -91,12 +92,12 @@ class ChatMessageBubbleTest { } @Test - fun aMessageThatNeverLeftTheDeviceSaysSo() { + fun aMessageThatNeverLeftTheDeviceUsesTheSendFailureCopy() { composeRule.setContent { Bubble(row(kind = ConversationRowKind.USER, text = "ship it", showRetry = true)) } - composeRule.onNodeWithText("Not delivered.").assertIsDisplayed() + composeRule.onNodeWithText(string(R.string.chat_send_failed)).assertIsDisplayed() } @Test @@ -181,4 +182,7 @@ class ChatMessageBubbleTest { question = null, actions = emptySet(), ) + + private fun string(resource: Int): String = + InstrumentationRegistry.getInstrumentation().targetContext.getString(resource) } diff --git a/src/apps/mobile/android/app/src/androidTest/kotlin/com/bitfun/mobile/app/ConversationViewTest.kt b/src/apps/mobile/android/app/src/androidTest/kotlin/com/bitfun/mobile/app/ConversationViewTest.kt index b7f18366a1..fa11131136 100644 --- a/src/apps/mobile/android/app/src/androidTest/kotlin/com/bitfun/mobile/app/ConversationViewTest.kt +++ b/src/apps/mobile/android/app/src/androidTest/kotlin/com/bitfun/mobile/app/ConversationViewTest.kt @@ -45,6 +45,8 @@ class ConversationViewTest { BitFunTheme(dark = false) { ConversationTimelineView( rows = listOf(assistantRow(answer)), + hasMoreMessages = false, + onLoadOlder = {}, enabled = true, onApproveTool = {}, onRejectTool = { _, _ -> }, @@ -175,6 +177,8 @@ class ConversationViewTest { private fun TimelineForTest(rows: List) { ConversationTimelineView( rows = rows, + hasMoreMessages = false, + onLoadOlder = {}, enabled = true, onApproveTool = {}, onRejectTool = { _, _ -> }, diff --git a/src/apps/mobile/android/app/src/androidTest/kotlin/com/bitfun/mobile/app/CreateSessionScreenTest.kt b/src/apps/mobile/android/app/src/androidTest/kotlin/com/bitfun/mobile/app/CreateSessionScreenTest.kt index 674e2d1d08..1c0276f8a9 100644 --- a/src/apps/mobile/android/app/src/androidTest/kotlin/com/bitfun/mobile/app/CreateSessionScreenTest.kt +++ b/src/apps/mobile/android/app/src/androidTest/kotlin/com/bitfun/mobile/app/CreateSessionScreenTest.kt @@ -9,11 +9,15 @@ import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performTextInput import com.bitfun.mobile.app.ui.chat.COMPOSER_INPUT_TEST_TAG import com.bitfun.mobile.app.ui.chat.COMPOSER_SEND_TEST_TAG +import com.bitfun.mobile.app.ui.chat.MODEL_CONTROL_TEST_TAG +import com.bitfun.mobile.app.ui.chat.MODEL_SELECTOR_OPTION_TEST_TAG_PREFIX import com.bitfun.mobile.app.ui.remote.CREATE_SESSION_BACK_TEST_TAG import com.bitfun.mobile.app.ui.remote.CREATE_SESSION_WORKSPACE_TEST_TAG +import com.bitfun.mobile.app.ui.remote.CreateDeviceChoice import com.bitfun.mobile.app.ui.remote.CreateSessionScreen import com.bitfun.mobile.core.feature.connection.ConnectionPhase import com.bitfun.mobile.core.feature.session.RemoteSessionIntent +import com.bitfun.mobile.core.feature.session.ModelOption import com.bitfun.mobile.core.feature.workspace.RemoteFileDownloadUiState import com.bitfun.mobile.core.feature.workspace.RemoteFilePreviewUiState import com.bitfun.mobile.core.feature.workspace.RemoteWorkspaceUiState @@ -54,6 +58,9 @@ class CreateSessionScreenTest { workspaceState = readyWorkspace(), phase = ConnectionPhase.CONNECTED, deviceId = "desk-1", + devices = emptyList(), + compact = true, + onDevicePick = {}, busy = false, onBack = {}, onWorkspaceIntent = {}, @@ -84,6 +91,9 @@ class CreateSessionScreenTest { workspaceState = readyWorkspace(), phase = ConnectionPhase.CONNECTED, deviceId = "", + devices = emptyList(), + compact = true, + onDevicePick = {}, busy = false, onBack = {}, onWorkspaceIntent = {}, @@ -101,6 +111,37 @@ class CreateSessionScreenTest { assertNull(intent) } + @Test + fun selectedModelIsAppliedToTheNewSession() { + var intent: RemoteSessionIntent? = null + composeRule.setContent { + CreateSessionScreen( + workspaceState = readyWorkspace(), + phase = ConnectionPhase.CONNECTED, + deviceId = "desk-1", + devices = emptyList(), + modelOptions = listOf( + ModelOption("model-primary", "Primary", "Account", selected = true), + ModelOption("model-fast", "Fast", "Account", selected = false), + ), + compact = true, + onDevicePick = {}, + busy = false, + onBack = {}, + onWorkspaceIntent = {}, + onIntent = { intent = it }, + modifier = Modifier, + ) + } + + composeRule.onNodeWithTag(COMPOSER_INPUT_TEST_TAG).performTextInput("review the parser") + composeRule.onNodeWithTag(MODEL_CONTROL_TEST_TAG).performClick() + composeRule.onNodeWithTag(MODEL_SELECTOR_OPTION_TEST_TAG_PREFIX + "model-fast").performClick() + composeRule.onNodeWithTag(COMPOSER_SEND_TEST_TAG).performClick() + + assertEquals("model-fast", (intent as RemoteSessionIntent.CreateSession).modelId) + } + @Test fun theWorkspaceRowOpensAPickerThatSaysWhenThereIsNothingToPick() { composeRule.setContent { @@ -108,6 +149,9 @@ class CreateSessionScreenTest { workspaceState = readyWorkspace(), phase = ConnectionPhase.CONNECTED, deviceId = "desk-1", + devices = emptyList(), + compact = true, + onDevicePick = {}, busy = false, onBack = {}, onWorkspaceIntent = {}, @@ -125,6 +169,34 @@ class CreateSessionScreenTest { .assertIsDisplayed() } + @Test + fun wideCreateRouteAnchorsTheDesktopPickerAndSwitchesTargets() { + var picked: String? = null + composeRule.setContent { + CreateSessionScreen( + workspaceState = readyWorkspace(), + phase = ConnectionPhase.CONNECTED, + deviceId = "desk-1", + devices = listOf( + CreateDeviceChoice("desk-1", "Studio Mac", online = true, selected = true), + CreateDeviceChoice("desk-2", "Office PC", online = true, selected = false), + ), + compact = false, + onDevicePick = { picked = it }, + busy = false, + onBack = {}, + onWorkspaceIntent = {}, + onIntent = {}, + modifier = Modifier, + ) + } + + composeRule.onNodeWithText("Studio Mac").performClick() + composeRule.onNodeWithText("Office PC").assertIsDisplayed().performClick() + + assertEquals("desk-2", picked) + } + @Test fun backLeavesWithoutCreatingAnything() { var back = 0 @@ -135,6 +207,9 @@ class CreateSessionScreenTest { workspaceState = readyWorkspace(), phase = ConnectionPhase.CONNECTED, deviceId = "desk-1", + devices = emptyList(), + compact = true, + onDevicePick = {}, busy = false, onBack = { back += 1 }, onWorkspaceIntent = {}, diff --git a/src/apps/mobile/android/app/src/androidTest/kotlin/com/bitfun/mobile/app/RemoteSessionListViewTest.kt b/src/apps/mobile/android/app/src/androidTest/kotlin/com/bitfun/mobile/app/RemoteSessionListViewTest.kt index a602b4f242..960f3a873d 100644 --- a/src/apps/mobile/android/app/src/androidTest/kotlin/com/bitfun/mobile/app/RemoteSessionListViewTest.kt +++ b/src/apps/mobile/android/app/src/androidTest/kotlin/com/bitfun/mobile/app/RemoteSessionListViewTest.kt @@ -1,6 +1,7 @@ package com.bitfun.mobile.app import androidx.compose.ui.Modifier +import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.test.assertCountEquals import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.v2.createComposeRule @@ -8,14 +9,21 @@ import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick +import com.bitfun.mobile.app.ui.remote.ChatCreateControl +import com.bitfun.mobile.app.ui.remote.ProjectCreateControl import com.bitfun.mobile.app.ui.remote.RemoteSessionListView +import com.bitfun.mobile.app.ui.remote.SESSION_CHAT_CREATE_TEST_TAG +import com.bitfun.mobile.app.ui.remote.SESSION_PROJECT_CREATE_TEST_TAG_PREFIX import com.bitfun.mobile.app.ui.remote.SESSION_SEARCH_FIELD_TEST_TAG import com.bitfun.mobile.app.ui.remote.SESSION_SEARCH_TOGGLE_TEST_TAG import com.bitfun.mobile.app.ui.settings.VIEW_SETTINGS_TEST_TAG import com.bitfun.mobile.app.ui.settings.VIEW_SETTINGS_TOGGLE_TEST_TAG import com.bitfun.mobile.app.ui.theme.BitFunTheme import com.bitfun.mobile.core.feature.session.RemoteSessionUiState +import com.bitfun.mobile.core.feature.layout.SettingsPlacement +import com.bitfun.mobile.core.feature.layout.SettingsPlacementMode import com.bitfun.mobile.core.feature.workspace.RemoteWorkspaceUiState +import org.junit.Assert.assertEquals import org.junit.Rule import org.junit.Test @@ -30,6 +38,9 @@ class RemoteSessionListViewTest { RemoteSessionListView( state = RemoteSessionUiState.Idle, workspaceState = RemoteWorkspaceUiState.Idle, + compact = true, + sessionDetailsPlacement = compactPlacement, + viewSettingsPlacement = compactPlacement, connectionDetails = {}, onIntent = {}, onWorkspaceIntent = {}, @@ -66,8 +77,13 @@ class RemoteSessionListViewTest { query = "", agentFilter = com.bitfun.mobile.core.feature.session.SessionAgentFilter.ALL, hasMore = false, + hasMoreMessages = false, + modelCatalog = null, ), workspaceState = RemoteWorkspaceUiState.Idle, + compact = true, + sessionDetailsPlacement = compactPlacement, + viewSettingsPlacement = compactPlacement, connectionDetails = {}, onIntent = {}, onWorkspaceIntent = {}, @@ -82,4 +98,47 @@ class RemoteSessionListViewTest { composeRule.onNodeWithTag(VIEW_SETTINGS_TOGGLE_TEST_TAG).performClick() composeRule.onNodeWithTag(VIEW_SETTINGS_TEST_TAG).assertIsDisplayed() } + + @Test + fun projectCreateUsesAnAnchoredCodeCoworkMenu() { + val open = mutableStateOf(false) + var agentType: String? = null + val path = "/work/bitfun" + + composeRule.setContent { + BitFunTheme(dark = false) { + ProjectCreateControl( + path = path, + expanded = open.value, + onToggle = { open.value = !open.value }, + onDismiss = { open.value = false }, + onCreateAgent = { agentType = it }, + ) + } + } + + composeRule.onNodeWithTag(SESSION_PROJECT_CREATE_TEST_TAG_PREFIX + path).performClick() + composeRule.onNodeWithText("Code").assertIsDisplayed().performClick() + assertEquals("code", agentType) + } + + @Test + fun chatSectionCreateIsASectionAnchoredAction() { + var clicked = false + composeRule.setContent { + BitFunTheme(dark = false) { + ChatCreateControl(onCreate = { clicked = true }) + } + } + + composeRule.onNodeWithTag(SESSION_CHAT_CREATE_TEST_TAG).assertIsDisplayed().performClick() + assertEquals(true, clicked) + } } + +private val compactPlacement = SettingsPlacement( + mode = SettingsPlacementMode.BOTTOM, + width = 0, + height = 0, + maxHeight = 0, +) diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/platform/WindowMetrics.kt b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/platform/WindowMetrics.kt index 136e7afad2..b0aa686014 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/platform/WindowMetrics.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/platform/WindowMetrics.kt @@ -13,6 +13,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.window.layout.FoldingFeature import androidx.window.layout.WindowInfoTracker import com.bitfun.mobile.core.feature.layout.ConversationLayoutPolicy +import com.bitfun.mobile.core.feature.layout.HorizontalWindowCrease import com.bitfun.mobile.core.feature.layout.WindowCrease import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map @@ -34,10 +35,12 @@ internal data class WindowMetrics( val isExpandedFoldable: Boolean, val isHoverLayout: Boolean, val creases: List, + val horizontalCreases: List, ) private data class AndroidFoldInfo( val creases: List, + val horizontalCreases: List, val hasFoldingFeature: Boolean, val hoverCandidate: Boolean, ) @@ -45,12 +48,10 @@ private data class AndroidFoldInfo( /** * Reads the current window and the hinges crossing it. * - * Only vertical creases are kept, and only their leading edge and thickness — - * the same filter `AppRootPresentation.ets` applies to - * `display.getCurrentFoldCreaseRegion()`, for the same reason: a crease running - * across the window splits nothing a master/detail layout cares about. The - * conversion to dp happens here rather than in the policy, so the shared code - * never has to know what a pixel is on this device. + * Vertical creases feed the master/detail policy; horizontal creases feed the + * hover/operate-region modal policy. Both are reduced to their leading edge and + * thickness here, so shared code never imports Android WindowManager types or + * has to know what a pixel is on this device. */ @Composable internal fun rememberWindowMetrics(): WindowMetrics { @@ -68,7 +69,7 @@ internal fun rememberWindowMetrics(): WindowMetrics { // somewhere that has no fold to report anyway. val foldInfoFlow = remember(activity, density) { if (activity == null) { - flowOf(AndroidFoldInfo(emptyList(), false, false)) + flowOf(AndroidFoldInfo(emptyList(), emptyList(), false, false)) } else { WindowInfoTracker.getOrCreate(activity) .windowLayoutInfo(activity) @@ -85,6 +86,16 @@ internal fun rememberWindowMetrics(): WindowMetrics { ) } }, + horizontalCreases = features + .filter { it.orientation == FoldingFeature.Orientation.HORIZONTAL } + .map { feature -> + with(density) { + HorizontalWindowCrease( + top = feature.bounds.top.toDp().value.toInt(), + height = feature.bounds.height().toDp().value.toInt(), + ) + } + }, hasFoldingFeature = features.isNotEmpty(), hoverCandidate = features.any { feature -> feature.orientation == FoldingFeature.Orientation.HORIZONTAL && @@ -95,7 +106,7 @@ internal fun rememberWindowMetrics(): WindowMetrics { } } val foldInfo by foldInfoFlow.collectAsStateWithLifecycle( - AndroidFoldInfo(emptyList(), false, false), + AndroidFoldInfo(emptyList(), emptyList(), false, false), ) return WindowMetrics( @@ -112,6 +123,7 @@ internal fun rememberWindowMetrics(): WindowMetrics { heightDp, ), creases = foldInfo.creases, + horizontalCreases = foldInfo.horizontalCreases, ) } diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/state/AppShellState.kt b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/state/AppShellState.kt index eb106f33c7..cb6bc342a7 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/state/AppShellState.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/state/AppShellState.kt @@ -54,6 +54,7 @@ internal class AppShellState( sidebarQuery: String, remoteSessionId: String? = null, remoteCreating: Boolean = false, + remoteScanRequested: Boolean = false, ) { internal var surface: MobileSurface by mutableStateOf(surface) private set @@ -82,6 +83,9 @@ internal class AppShellState( internal var remoteCreating: Boolean by mutableStateOf(remoteCreating) private set + internal var remoteScanRequested: Boolean by mutableStateOf(remoteScanRequested) + private set + internal fun show(next: MobileSurface) { surface = next } @@ -94,6 +98,7 @@ internal class AppShellState( internal fun createRemoteSession() { surface = MobileSurface.REMOTE + remoteScanRequested = false remoteCreating = true remoteSessionId = null } @@ -103,6 +108,17 @@ internal class AppShellState( remoteSessionId = null } + internal fun openRemoteScanner() { + surface = MobileSurface.REMOTE + remoteCreating = false + remoteSessionId = null + remoteScanRequested = true + } + + internal fun closeRemoteScanner() { + remoteScanRequested = false + } + /** Opens the requested settings surface in the shared overlay host. */ internal fun openSettings(mode: SettingsMode) { settingsMode = mode @@ -159,6 +175,7 @@ internal class AppShellState( it.sidebarQuery, it.remoteSessionId, it.remoteCreating, + it.remoteScanRequested, ) }, restore = { @@ -172,6 +189,7 @@ internal class AppShellState( sidebarQuery = it[6] as String, remoteSessionId = it.getOrNull(7) as String?, remoteCreating = it.getOrNull(8) as? Boolean ?: false, + remoteScanRequested = it.getOrNull(9) as? Boolean ?: false, ) }, ) @@ -190,5 +208,6 @@ internal fun rememberAppShellState(): AppShellState = rememberSaveable(saver = A sidebarQuery = "", remoteSessionId = null, remoteCreating = false, + remoteScanRequested = false, ) } diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/account/AccountScreen.kt b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/account/AccountScreen.kt index 36b8153bbc..4cc2c249ec 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/account/AccountScreen.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/account/AccountScreen.kt @@ -297,7 +297,7 @@ private fun AccountBackButton(onClick: () -> Unit, modifier: Modifier) { } } -private fun AccountFailureReason.messageRes(): Int = when (this) { +internal fun AccountFailureReason.messageRes(): Int = when (this) { AccountFailureReason.INVALID_CREDENTIALS -> R.string.account_invalid_credentials AccountFailureReason.AUTHENTICATION -> R.string.account_authentication AccountFailureReason.RATE_LIMITED -> R.string.account_rate_limited diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/chat/BitFunHeaderActionMenu.kt b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/chat/BitFunHeaderActionMenu.kt index f711ee9491..8e88510d46 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/chat/BitFunHeaderActionMenu.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/chat/BitFunHeaderActionMenu.kt @@ -48,6 +48,7 @@ import androidx.compose.ui.window.PopupPositionProvider import androidx.compose.ui.window.PopupProperties import com.bitfun.mobile.app.ui.theme.BitFunEaseOut import com.bitfun.mobile.app.ui.theme.MotionQuickMillis +import com.bitfun.mobile.app.ui.theme.generated.MobileDesignGeometry internal const val HEADER_ACTION_MENU_TEST_TAG: String = "header-action-menu" @@ -112,18 +113,23 @@ internal fun BitFunHeaderActionMenu( Surface( modifier = modifier .testTag(HEADER_ACTION_MENU_TEST_TAG) - .width(292.dp) - .clip(RoundedCornerShape(16.dp)) + .width(MobileDesignGeometry.PopoverWidth) + .clip(RoundedCornerShape(MobileDesignGeometry.PopoverRadius)) .border( BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), - RoundedCornerShape(16.dp), + RoundedCornerShape(MobileDesignGeometry.PopoverRadius), ), - shape = RoundedCornerShape(16.dp), + shape = RoundedCornerShape(MobileDesignGeometry.PopoverRadius), color = MaterialTheme.colorScheme.surfaceContainerLow, tonalElevation = 0.dp, - shadowElevation = 18.dp, + shadowElevation = MobileDesignGeometry.PopoverShadowRadius, ) { - Column(Modifier.padding(horizontal = 12.dp, vertical = 10.dp)) { + Column( + Modifier.padding( + horizontal = MobileDesignGeometry.PopoverPadding, + vertical = MobileDesignGeometry.PopoverVerticalPadding, + ), + ) { Box( modifier = Modifier.fillMaxWidth().height(28.dp).padding(start = 8.dp), contentAlignment = Alignment.CenterStart, @@ -187,7 +193,7 @@ private fun HeaderActionRow(action: BitFunHeaderAction, onDismiss: () -> Unit) { Row( modifier = Modifier .fillMaxWidth() - .height(48.dp) + .height(MobileDesignGeometry.PopoverActionHeight) .clip(RoundedCornerShape(10.dp)) .background( if (action.selected) MaterialTheme.colorScheme.surfaceVariant diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/chat/ChatMessageBubble.kt b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/chat/ChatMessageBubble.kt index 10aeb80187..4282784216 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/chat/ChatMessageBubble.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/chat/ChatMessageBubble.kt @@ -23,6 +23,7 @@ import com.bitfun.mobile.app.ui.chat.message.MessageImageGallery import com.bitfun.mobile.app.ui.chat.message.ThinkingBlock import com.bitfun.mobile.app.ui.chat.tool.ToolStatusList import com.bitfun.mobile.core.feature.session.ConversationRow +import com.bitfun.mobile.core.feature.session.QuestionAnswer import com.bitfun.mobile.core.feature.session.ConversationRowKind import com.bitfun.mobile.core.feature.workspace.RemoteFileDownloadUiState @@ -43,6 +44,7 @@ internal fun ChatMessageBubble( onRejectTool: (String, String) -> Unit, onCancelTool: (String, String) -> Unit, onAnswerTool: (String, String) -> Unit, + onAnswerToolStructured: (String, List) -> Unit, onRetry: (String) -> Unit, onOpenLink: (String, String) -> Unit, /** The file the preview surface is showing, so its card can say so. */ @@ -76,6 +78,7 @@ internal fun ChatMessageBubble( onRejectTool = onRejectTool, onCancelTool = onCancelTool, onAnswerTool = onAnswerTool, + onAnswerToolStructured = onAnswerToolStructured, onOpenLink = onOpenLink, previewingRemotePath = previewingRemotePath, previewLoading = previewLoading, @@ -143,6 +146,7 @@ private fun AssistantContent(row: ConversationRow, callbacks: MessageBlockCallba onReject = callbacks.onRejectTool, onCancel = callbacks.onCancelTool, onAnswer = callbacks.onAnswerTool, + onAnswerStructured = callbacks.onAnswerToolStructured, onOpenFile = callbacks.onOpenLink, modifier = Modifier, ) diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/chat/ComposerBar.kt b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/chat/ComposerBar.kt index ccf15a90ec..3cc07956ef 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/chat/ComposerBar.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/chat/ComposerBar.kt @@ -9,6 +9,7 @@ import androidx.compose.animation.fadeOut import androidx.compose.animation.shrinkHorizontally import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -26,18 +27,18 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape 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.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -60,8 +61,10 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import com.bitfun.mobile.app.R import com.bitfun.mobile.app.ui.theme.BitFunEaseOut import com.bitfun.mobile.app.ui.theme.MotionQuickMillis @@ -451,11 +454,21 @@ private fun ModelControl( ) } if (wide) { - DropdownMenu(expanded = selectorOpen, onDismissRequest = onSelectorDismiss) { + DropdownMenu( + expanded = selectorOpen, + onDismissRequest = onSelectorDismiss, + modifier = Modifier.width(MobileDesignGeometry.ComposerModelSelectorWidth), + shape = RoundedCornerShape(MobileDesignGeometry.ComposerModelSelectorRadius), + containerColor = MaterialTheme.colorScheme.surfaceContainerLow, + tonalElevation = 0.dp, + shadowElevation = MobileDesignGeometry.PopoverShadowRadius, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + ) { ModelSelectorContent( options = modelOptions, onSelect = onSelectModel, compact = true, + onDismiss = onSelectorDismiss, ) } } @@ -465,8 +478,18 @@ private fun ModelControl( onDismissRequest = onSelectorDismiss, sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), containerColor = MaterialTheme.colorScheme.surface, + shape = RoundedCornerShape( + topStart = MobileDesignGeometry.SelectionTopRadius, + topEnd = MobileDesignGeometry.SelectionTopRadius, + ), + dragHandle = null, ) { - ModelSelectorContent(options = modelOptions, onSelect = onSelectModel, compact = false) + ModelSelectorContent( + options = modelOptions, + onSelect = onSelectModel, + compact = false, + onDismiss = onSelectorDismiss, + ) } } } @@ -476,67 +499,131 @@ private fun ModelSelectorContent( options: List, onSelect: (String) -> Unit, compact: Boolean, + onDismiss: () -> Unit, ) { + val selectorOptions = remember(options) { + options.filter(ModelOption::selected) + options.filterNot(ModelOption::selected) + } + val visibleRows = selectorOptions.size.coerceAtMost(7) + val listHeight = if (visibleRows == 0) { + MobileDesignGeometry.ComposerModelSelectorRowHeight + } else { + MobileDesignGeometry.ComposerModelSelectorRowHeight * visibleRows + + MobileDesignGeometry.ComposerModelSelectorRowGap * (visibleRows - 1) + } Column( + verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier - .then(if (compact) Modifier.width(300.dp) else Modifier.fillMaxWidth()) - .padding(vertical = if (compact) 4.dp else 12.dp) + .fillMaxWidth() + // Material's anchored menu reserves 8dp vertically around its + // content. Add only the remaining 2dp there so both the popover + // and the sheet expose the HarmonyOS 10dp inner inset. + .padding(horizontal = 10.dp, vertical = if (compact) 2.dp else 10.dp) .testTag(MODEL_SELECTOR_TEST_TAG), ) { - Text( - stringResource(R.string.model_selector_title), - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.padding(horizontal = 18.dp, vertical = 10.dp), - ) - if (options.isEmpty()) { + if (!compact) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().height(32.dp), + ) { + Text( + stringResource(R.string.model_selector_title), + fontSize = 13.sp, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f), + ) + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(MobileDesignGeometry.SelectionCloseSize) + .clip(CircleShape) + .clickable(onClick = onDismiss), + ) { + Icon( + painterResource(R.drawable.ic_symbol_xmark), + contentDescription = stringResource(R.string.common_close), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(15.dp), + ) + } + } + } + if (selectorOptions.isEmpty()) { Text( stringResource(R.string.model_selector_empty), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(horizontal = 18.dp, vertical = 14.dp), + modifier = Modifier + .fillMaxWidth() + .height(MobileDesignGeometry.ComposerModelSelectorRowHeight) + .padding(horizontal = 10.dp, vertical = 14.dp), ) } else { - options.forEachIndexed { index, option -> - DropdownMenuItem( - modifier = Modifier.testTag(MODEL_SELECTOR_OPTION_TEST_TAG_PREFIX + option.id), - text = { - Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { - Text(option.primaryLabel, maxLines = 1, overflow = TextOverflow.Ellipsis) + Column( + verticalArrangement = Arrangement.spacedBy( + MobileDesignGeometry.ComposerModelSelectorRowGap, + ), + modifier = Modifier + .fillMaxWidth() + .height(listHeight) + .verticalScroll(rememberScrollState()), + ) { + selectorOptions.forEach { option -> + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + modifier = Modifier + .fillMaxWidth() + .height(MobileDesignGeometry.ComposerModelSelectorRowHeight) + .clip( + RoundedCornerShape( + MobileDesignGeometry.ComposerModelSelectorRowRadius, + ), + ) + .background( + if (option.selected) MaterialTheme.colorScheme.surfaceVariant + else Color.Transparent, + ) + .clickable { onSelect(option.id) } + .padding(horizontal = 10.dp) + .testTag(MODEL_SELECTOR_OPTION_TEST_TAG_PREFIX + option.id), + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.size(20.dp), + ) { + if (option.selected) { + Icon( + painterResource(R.drawable.ic_symbol_checkmark_circle), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.size(16.dp), + ) + } + } + Column( + verticalArrangement = Arrangement.spacedBy(2.dp), + modifier = Modifier.weight(1f), + ) { + Text( + option.primaryLabel, + fontSize = 13.sp, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) Text( option.secondaryLabel, - style = MaterialTheme.typography.bodySmall, + fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis, ) } - }, - leadingIcon = { - Icon( - painterResource( - if (option.secondaryLabel == stringResource(R.string.model_selector_local)) { - R.drawable.ic_symbol_gearshape - } else { - R.drawable.ic_symbol_cloud - }, - ), - contentDescription = null, - modifier = Modifier.size(19.dp), - ) - }, - trailingIcon = if (option.selected) { - { - Icon( - painterResource(R.drawable.ic_symbol_checkmark_circle_fill), - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(19.dp), - ) - } - } else null, - onClick = { onSelect(option.id) }, - ) - if (!compact && index != options.lastIndex) HorizontalDivider() + } + } } } } diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/chat/ConversationHeader.kt b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/chat/ConversationHeader.kt index cbdeddcbb1..c8e2e41dce 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/chat/ConversationHeader.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/chat/ConversationHeader.kt @@ -170,7 +170,7 @@ internal fun ConversationHeader( if (canStop) { add( BitFunHeaderAction( - icon = R.drawable.ic_symbol_xmark, + icon = R.drawable.ic_symbol_gearshape, label = stringResource(R.string.message_stop), onClick = onStop, dividerBefore = true, @@ -199,7 +199,7 @@ internal fun ConversationHeader( /** Rename in place: a field and the two verdicts, as the source's row is. */ @Composable -private fun TitleEditor( +internal fun TitleEditor( draft: String, enabled: Boolean, onDraftChange: (String) -> Unit, diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/chat/ConversationTimelineView.kt b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/chat/ConversationTimelineView.kt index 9f98281fa8..a261f076a6 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/chat/ConversationTimelineView.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/chat/ConversationTimelineView.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn @@ -14,6 +15,8 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf @@ -31,17 +34,21 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.bitfun.mobile.app.R import com.bitfun.mobile.core.feature.session.ConversationRow +import com.bitfun.mobile.core.feature.session.QuestionAnswer import com.bitfun.mobile.core.feature.workspace.RemoteFileDownloadUiState /** Timeline renderer over feature-owned presentation rows; session routing stays above it. */ @Composable internal fun ConversationTimelineView( rows: List, + hasMoreMessages: Boolean, + onLoadOlder: () -> Unit, enabled: Boolean, onApproveTool: (String) -> Unit, onRejectTool: (String, String) -> Unit, onCancelTool: (String, String) -> Unit, onAnswerTool: (String, String) -> Unit, + onAnswerToolStructured: (String, List) -> Unit, onRetry: (String) -> Unit, onOpenFile: (String, String) -> Unit, previewingRemotePath: String, @@ -81,6 +88,15 @@ internal fun ConversationTimelineView( contentPadding = PaddingValues(start = 20.dp, end = 20.dp, bottom = 12.dp), verticalArrangement = Arrangement.spacedBy(12.dp, Alignment.Bottom), ) { + if (hasMoreMessages) { + item(key = "load-older-messages") { + Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { + TextButton(onClick = onLoadOlder, enabled = enabled) { + Text(stringResource(R.string.chat_load_older_messages)) + } + } + } + } items(rows, key = { it.id }) { row -> ChatMessageBubble( row = row, @@ -89,6 +105,7 @@ internal fun ConversationTimelineView( onRejectTool = onRejectTool, onCancelTool = onCancelTool, onAnswerTool = onAnswerTool, + onAnswerToolStructured = onAnswerToolStructured, onRetry = onRetry, onOpenLink = onOpenFile, previewingRemotePath = previewingRemotePath, diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/chat/ConversationView.kt b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/chat/ConversationView.kt index 82393f41f7..1f6b3638d1 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/chat/ConversationView.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/chat/ConversationView.kt @@ -13,7 +13,6 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ModalBottomSheet import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -27,13 +26,18 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.bitfun.mobile.app.R import com.bitfun.mobile.app.ui.settings.RemoteSettingsSheet +import com.bitfun.mobile.app.ui.common.AdaptiveModalSurface import com.bitfun.mobile.core.feature.connection.ConnectionPhase +import com.bitfun.mobile.core.feature.layout.SettingsPlacement import com.bitfun.mobile.core.feature.session.ChatComposerCapabilities import com.bitfun.mobile.core.feature.session.ComposerImage import com.bitfun.mobile.core.feature.session.ConversationRowKind +import com.bitfun.mobile.core.feature.session.RemoteSessionIntent.AnswerStructuredQuestion +import com.bitfun.mobile.core.feature.session.QuestionAnswer import com.bitfun.mobile.core.feature.session.RemoteSessionIntent import com.bitfun.mobile.core.feature.session.RemoteSessionUiState import com.bitfun.mobile.core.feature.session.conversationRows +import com.bitfun.mobile.core.feature.session.modelOptions import com.bitfun.mobile.core.feature.session.selectedModelOption import com.bitfun.mobile.core.feature.workspace.RemoteFileDownloadUiState import java.util.UUID @@ -66,6 +70,7 @@ private const val MAX_IMAGE_BYTES = 8 * 1024 * 1024 internal fun ConversationView( state: RemoteSessionUiState.Ready, phase: ConnectionPhase, + settingsPlacement: SettingsPlacement, onBack: () -> Unit, onOpenSidebar: (() -> Unit)? = null, onIntent: (RemoteSessionIntent) -> Unit, @@ -162,6 +167,8 @@ internal fun ConversationView( ConversationTimelineView( rows = visibleRows, + hasMoreMessages = state.hasMoreMessages, + onLoadOlder = { onIntent(RemoteSessionIntent.LoadOlderMessages) }, enabled = !state.busy, onApproveTool = { toolId -> onIntent(RemoteSessionIntent.ApproveTool(sessionId, toolId)) @@ -175,6 +182,9 @@ internal fun ConversationView( onAnswerTool = { toolId, answer -> onIntent(RemoteSessionIntent.AnswerQuestion(sessionId, toolId, answer)) }, + onAnswerToolStructured = { toolId, answers -> + onIntent(AnswerStructuredQuestion(sessionId, toolId, answers)) + }, onRetry = { text -> onIntent(RemoteSessionIntent.SendMessage(sessionId, text, null)) }, @@ -195,11 +205,15 @@ internal fun ConversationView( streaming = timeline.activeTurn != null, phase = phase, model = timeline.selectedModelOption(stringResource(R.string.models_unnamed)), + modelOptions = timeline.modelOptions(stringResource(R.string.models_unnamed)), capabilities = ChatComposerCapabilities.RemoteChat, placeholder = stringResource(R.string.message_input_label), onDraftChange = { draft = it }, onRemoveImage = { id -> images = images.filterNot { it.id == id } }, onOpenModels = { showSettings = true }, + onSelectModel = { modelId -> + onIntent(RemoteSessionIntent.SelectModel(sessionId, modelId)) + }, modifier = Modifier, onAttach = { photoPicker.launch( @@ -234,12 +248,16 @@ internal fun ConversationView( } if (showSettings) { - ModalBottomSheet(onDismissRequest = { showSettings = false }) { + AdaptiveModalSurface( + visible = true, + placement = settingsPlacement, + onDismissRequest = { showSettings = false }, + ) { surfaceModifier -> RemoteSettingsSheet( state = state, sessionId = sessionId, onIntent = onIntent, - modifier = Modifier.fillMaxWidth().padding(16.dp), + modifier = surfaceModifier.padding(16.dp), ) } } diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/chat/GeneralChatScreen.kt b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/chat/GeneralChatScreen.kt index 7448ffc037..a1ba2249f1 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/chat/GeneralChatScreen.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/chat/GeneralChatScreen.kt @@ -25,13 +25,10 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.AlertDialog import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Text -import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -42,7 +39,6 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource @@ -53,11 +49,12 @@ import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.bitfun.mobile.app.R -import com.bitfun.mobile.app.ui.session.RenameSessionDialog import com.bitfun.mobile.app.ui.common.CircleControl +import com.bitfun.mobile.app.ui.common.AdaptiveModalSurface import com.bitfun.mobile.app.ui.settings.ModelServiceScreen import com.bitfun.mobile.app.viewmodel.GeneralChatViewModel import com.bitfun.mobile.core.feature.connection.ConnectionPhase +import com.bitfun.mobile.core.feature.layout.SettingsPlacement import com.bitfun.mobile.core.feature.generalchat.GeneralChatFailureReason import com.bitfun.mobile.core.feature.generalchat.GeneralChatIntent import com.bitfun.mobile.core.feature.session.ChatComposerCapabilities @@ -91,6 +88,7 @@ private const val MAX_GENERAL_CHAT_IMAGE_BYTES = 8 * 1024 * 1024 @Composable internal fun GeneralChatScreen( modifier: Modifier, + modelServicePlacement: SettingsPlacement, onOpenSidebar: (() -> Unit)? = null, viewModel: GeneralChatViewModel = viewModel(factory = GeneralChatViewModel.Factory), ) { @@ -101,7 +99,6 @@ internal fun GeneralChatScreen( var menuOpen by rememberSaveable { mutableStateOf(false) } var showModelService by rememberSaveable { mutableStateOf(false) } var renaming by rememberSaveable { mutableStateOf(false) } - var confirmDelete by rememberSaveable { mutableStateOf(false) } val untitled = stringResource(R.string.sidebar_untitled) val userLabel = stringResource(R.string.general_chat_role_user) @@ -112,6 +109,7 @@ internal fun GeneralChatScreen( ?.title ?.takeIf(String::isNotBlank) ?: stringResource(R.string.app_name) + var renameDraft by rememberSaveable(title) { mutableStateOf(title) } val visibleRows = remember(rows) { rows.filter { it.kind != ConversationRowKind.EMPTY } } val uploadedFileCount = visibleRows.sumOf { it.images.size } // Read in composition, not in the menu callback -- see `ConversationView`. @@ -216,6 +214,7 @@ internal fun GeneralChatScreen( .weight(1f) .padding(horizontal = 8.dp) .clickable(enabled = state.sessions.any { it.id == state.sessionId }) { + renameDraft = title renaming = true }, textAlign = TextAlign.Center, @@ -273,7 +272,7 @@ internal fun GeneralChatScreen( }, ), BitFunHeaderAction( - icon = R.drawable.ic_symbol_archivebox, + icon = R.drawable.ic_symbol_folder, label = stringResource( if (archived) R.string.session_unarchive else R.string.session_archive, ), @@ -284,10 +283,12 @@ internal fun GeneralChatScreen( }, ), BitFunHeaderAction( - icon = R.drawable.ic_symbol_trash, + icon = R.drawable.ic_symbol_gearshape, label = stringResource(R.string.session_delete), enabled = stored, - onClick = { confirmDelete = true }, + onClick = { + viewModel.dispatch(GeneralChatIntent.DeleteSession(state.sessionId)) + }, ), ), ) @@ -297,6 +298,19 @@ internal fun GeneralChatScreen( } } + if (renaming) { + TitleEditor( + draft = renameDraft, + enabled = !state.busy, + onDraftChange = { renameDraft = it }, + onSave = { + viewModel.dispatch(GeneralChatIntent.RenameSession(state.sessionId, renameDraft.trim())) + renaming = false + }, + onCancel = { renaming = false }, + ) + } + if (visibleRows.isEmpty()) { Box( modifier = Modifier.weight(1f).fillMaxWidth(), @@ -330,6 +344,7 @@ internal fun GeneralChatScreen( onRejectTool = { _, _ -> }, onCancelTool = { _, _ -> }, onAnswerTool = { _, _ -> }, + onAnswerToolStructured = { _, _ -> }, onRetry = { text -> viewModel.dispatch(GeneralChatIntent.UpdateDraft(text)) viewModel.dispatch(GeneralChatIntent.Send) @@ -406,20 +421,11 @@ internal fun GeneralChatScreen( viewModel.dispatch(GeneralChatIntent.ClearConfigFailure) viewModel.dispatch(GeneralChatIntent.ClearConnectionTest) } - ModalBottomSheet( + AdaptiveModalSurface( + visible = true, + placement = modelServicePlacement, onDismissRequest = dismissModelService, - // Straight to full height. Material's default opens a tall sheet at - // half height first, which would show this panel's header over a - // blank half and hide the rows it exists to show until the user - // dragged it — the source's sheet has one height and arrives at it. - sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), - // The panel brings its own header with the close button in it, and a - // handle above that header would be a second way out drawn over the - // first — the source's sheet has none either. - dragHandle = null, - containerColor = MaterialTheme.colorScheme.surface, - shape = RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp), - ) { + ) { surfaceModifier -> ModelServiceScreen( config = state.config, models = state.models, @@ -435,51 +441,11 @@ internal fun GeneralChatScreen( viewModel.state.value.configFailure == null }, onClose = dismissModelService, - // The source's `height('78%')`: tall enough for the form and the - // keyboard under it, short enough that the conversation it was - // opened from is still visible above. - // - // Measured off the screen rather than asked of the parent: a - // sheet hands its content an unbounded height, so a fraction of - // it is a fraction of nothing and the panel would collapse onto - // whatever its rows happen to add up to. - modifier = Modifier - .fillMaxWidth() - .height(LocalConfiguration.current.screenHeightDp.dp * 0.78f), + modifier = surfaceModifier, ) } } - if (renaming) { - RenameSessionDialog( - currentTitle = state.sessions.firstOrNull { it.id == state.sessionId }?.title.orEmpty(), - onConfirm = { viewModel.dispatch(GeneralChatIntent.RenameSession(state.sessionId, it)) }, - onDismiss = { renaming = false }, - ) - } - - if (confirmDelete) { - AlertDialog( - onDismissRequest = { confirmDelete = false }, - title = { Text(stringResource(R.string.session_delete)) }, - text = { Text(stringResource(R.string.general_chat_delete_confirm)) }, - confirmButton = { - TextButton( - onClick = { - viewModel.dispatch(GeneralChatIntent.DeleteSession(state.sessionId)) - confirmDelete = false - }, - ) { - Text(stringResource(R.string.session_delete)) - } - }, - dismissButton = { - TextButton(onClick = { confirmDelete = false }) { - Text(stringResource(R.string.common_cancel)) - } - }, - ) - } } /** Shown above the transcript rather than instead of it: an unconfigured provider diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/chat/message/MessageBlockList.kt b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/chat/message/MessageBlockList.kt index 0f45a8c359..b7440f8b7c 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/chat/message/MessageBlockList.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/chat/message/MessageBlockList.kt @@ -32,6 +32,7 @@ import com.bitfun.mobile.app.ui.chat.FileReferenceCards import com.bitfun.mobile.app.ui.chat.MarkdownContent import com.bitfun.mobile.app.ui.chat.tool.ToolStatusList import com.bitfun.mobile.core.feature.session.MessageBlock +import com.bitfun.mobile.core.feature.session.QuestionAnswer import com.bitfun.mobile.core.feature.workspace.RemoteFileDownloadUiState internal const val SUBAGENT_GROUP_TEST_TAG: String = "subagent-group" @@ -43,6 +44,7 @@ internal data class MessageBlockCallbacks( val onRejectTool: (String, String) -> Unit, val onCancelTool: (String, String) -> Unit, val onAnswerTool: (String, String) -> Unit, + val onAnswerToolStructured: (String, List) -> Unit, val onOpenLink: (String, String) -> Unit, /** The file the preview surface is showing, so its card can say so. */ val previewingRemotePath: String, @@ -100,6 +102,7 @@ private fun MessageBlockView(block: MessageBlock, callbacks: MessageBlockCallbac onReject = callbacks.onRejectTool, onCancel = callbacks.onCancelTool, onAnswer = callbacks.onAnswerTool, + onAnswerStructured = callbacks.onAnswerToolStructured, onOpenFile = callbacks.onOpenLink, modifier = Modifier, ) diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/chat/tool/ToolInteractionPanels.kt b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/chat/tool/ToolInteractionPanels.kt index b099461382..9aa4016e12 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/chat/tool/ToolInteractionPanels.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/chat/tool/ToolInteractionPanels.kt @@ -6,13 +6,17 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.selection.toggleable import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.RadioButton import androidx.compose.material3.Text +import androidx.compose.material3.Checkbox import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -22,6 +26,10 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.bitfun.mobile.app.R +import com.bitfun.mobile.core.feature.session.QuestionAnswer +import com.bitfun.mobile.core.feature.session.QuestionAnswerValue +import com.bitfun.mobile.core.feature.session.QuestionOption +import com.bitfun.mobile.core.feature.session.ToolQuestion /** * Approve and reject as equal halves of one row, ported from @@ -116,6 +124,118 @@ internal fun ToolQuestionAnswerPanel( } } +internal fun isOtherOption(label: String, otherLabel: String): Boolean { + val normalizedLabel = label.trim() + return normalizedLabel.equals("other", ignoreCase = true) || + normalizedLabel.equals(otherLabel.trim(), ignoreCase = true) || + normalizedLabel == "其他" +} + +internal fun effectiveOptions( + question: ToolQuestion, + otherLabel: String, +): List = question.options + if ( + question.options.none { isOtherOption(it.label, otherLabel) } +) { + listOf(QuestionOption(otherLabel, null)) +} else { + emptyList() +} + +internal fun buildStructuredAnswers( + questions: List, + selectedByIndex: Map>, + customByIndex: Map, + otherLabel: String, +): List = questions.map { question -> + val selected = selectedByIndex[question.index].orEmpty() + val custom = customByIndex[question.index].orEmpty().trim() + val effectiveOptions = effectiveOptions(question, otherLabel) + val values = effectiveOptions.map { it.label }.filter { it in selected }.map { label -> + if (isOtherOption(label, otherLabel)) custom else label + } + if (question.multiSelect) { + QuestionAnswer(question.index, QuestionAnswerValue.Choice(values)) + } else { + QuestionAnswer(question.index, QuestionAnswerValue.Text(values.firstOrNull().orEmpty())) + } +} + +@Composable +internal fun ToolStructuredQuestionPanel( + toolId: String, + questions: List, + enabled: Boolean, + onSubmit: (List) -> Unit, +) { + val otherLabel = stringResource(R.string.tool_option_other) + var selectedByIndex by remember(toolId) { mutableStateOf>>(emptyMap()) } + var customByIndex by remember(toolId) { mutableStateOf>(emptyMap()) } + val answers = buildStructuredAnswers(questions, selectedByIndex, customByIndex, otherLabel) + val canSubmit = enabled && toolId.isNotEmpty() && questions.all { question -> + val selected = selectedByIndex[question.index].orEmpty() + selected.isNotEmpty() && (!selected.any { isOtherOption(it, otherLabel) } || customByIndex[question.index].orEmpty().isNotBlank()) + } + + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + questions.forEach { question -> + val selected = selectedByIndex[question.index].orEmpty() + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + if (question.header.isNotBlank()) { + Text(question.header, style = MaterialTheme.typography.labelLarge) + } + Text(question.question, style = MaterialTheme.typography.bodyMedium) + val options = effectiveOptions(question, otherLabel) + options.forEach { option -> + val checked = option.label in selected + Column( + modifier = Modifier.fillMaxWidth().toggleable( + value = checked, + enabled = enabled, + role = if (question.multiSelect) androidx.compose.ui.semantics.Role.Checkbox else androidx.compose.ui.semantics.Role.RadioButton, + onValueChange = { + selectedByIndex = selectedByIndex.toMutableMap().apply { + this[question.index] = if (question.multiSelect) { + if (checked) selected - option.label else selected + option.label + } else { + setOf(option.label) + } + } + }, + ).padding(vertical = 2.dp), + ) { + Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) { + if (question.multiSelect) Checkbox(checked, null, enabled = enabled) + else RadioButton(checked, null, enabled = enabled) + Text(option.label, style = MaterialTheme.typography.bodyMedium) + } + option.description?.takeIf(String::isNotBlank)?.let { + Text(it, style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(start = 48.dp)) + } + } + if (isOtherOption(option.label, otherLabel) && checked) { + OutlinedTextField( + value = customByIndex[question.index].orEmpty(), + onValueChange = { customByIndex = customByIndex + (question.index to it) }, + label = { Text(stringResource(R.string.tool_answer_label)) }, + enabled = enabled, + modifier = Modifier.fillMaxWidth().padding(start = 48.dp), + ) + } + } + } + } + PillButton( + label = stringResource(R.string.tool_answer_send), + primary = true, + enabled = canSubmit, + compact = false, + onClick = { onSubmit(answers) }, + modifier = Modifier.fillMaxWidth(), + ) + } +} + /** * The source draws every tool action as a 32-high rounded capsule, filled for * the affirmative one and outlined for the rest. This is that shape. diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/chat/tool/ToolStatusList.kt b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/chat/tool/ToolStatusList.kt index 72274482eb..ad3ec3c34c 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/chat/tool/ToolStatusList.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/chat/tool/ToolStatusList.kt @@ -32,6 +32,7 @@ import androidx.compose.ui.unit.dp import com.bitfun.mobile.app.R import com.bitfun.mobile.core.feature.session.ToolAction import com.bitfun.mobile.core.feature.session.ToolCard +import com.bitfun.mobile.core.feature.session.QuestionAnswer import com.bitfun.mobile.core.feature.session.ToolOperation import com.bitfun.mobile.core.feature.session.ToolPhase import com.bitfun.mobile.core.feature.session.ToolRow @@ -64,6 +65,7 @@ internal fun ToolStatusList( onReject: (String, String) -> Unit, onCancel: (String, String) -> Unit, onAnswer: (String, String) -> Unit, + onAnswerStructured: (String, List) -> Unit, onOpenFile: (String, String) -> Unit, modifier: Modifier, ) { @@ -81,6 +83,7 @@ internal fun ToolStatusList( onReject = { reason -> onReject(row.tool.id, reason) }, onCancel = { reason -> onCancel(row.tool.id, reason) }, onAnswer = { answer -> onAnswer(row.tool.id, answer) }, + onAnswerStructured = { answers -> onAnswerStructured(row.tool.id, answers) }, onOpenFile = onOpenFile, modifier = Modifier, ) @@ -147,6 +150,7 @@ private fun CollapsedToolGroup( onReject = {}, onCancel = {}, onAnswer = {}, + onAnswerStructured = {}, onOpenFile = onOpenFile, modifier = Modifier, ) @@ -170,6 +174,7 @@ internal fun ToolStatusRow( onReject: (String) -> Unit, onCancel: (String) -> Unit, onAnswer: (String) -> Unit, + onAnswerStructured: (List) -> Unit, onOpenFile: (String, String) -> Unit, modifier: Modifier, ) { @@ -271,13 +276,22 @@ internal fun ToolStatusRow( } if (ToolAction.ANSWER in tool.actions) { - ToolQuestionAnswerPanel( - toolId = tool.id, - // The agent did not always send a prompt to quote, so we ask in ours. - prompt = tool.question ?: stringResource(R.string.tool_question_default), - enabled = enabled, - onSubmit = onAnswer, - ) + if (tool.questions.isNotEmpty()) { + ToolStructuredQuestionPanel( + toolId = tool.id, + questions = tool.questions, + enabled = enabled, + onSubmit = onAnswerStructured, + ) + } else { + ToolQuestionAnswerPanel( + toolId = tool.id, + // The agent did not always send a prompt to quote, so we ask in ours. + prompt = tool.question ?: stringResource(R.string.tool_question_default), + enabled = enabled, + onSubmit = onAnswer, + ) + } } if (ToolAction.CANCEL in tool.actions) { diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/common/AdaptiveModalSurface.kt b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/common/AdaptiveModalSurface.kt new file mode 100644 index 0000000000..6e686723e9 --- /dev/null +++ b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/common/AdaptiveModalSurface.kt @@ -0,0 +1,98 @@ +package com.bitfun.mobile.app.ui.common + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Surface +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.bitfun.mobile.app.ui.theme.generated.MobileDesignGeometry +import com.bitfun.mobile.app.ui.theme.bitFunColors +import com.bitfun.mobile.core.feature.layout.SettingsPlacement +import com.bitfun.mobile.core.feature.layout.SettingsPlacementMode + +/** + * Native modal lifecycle around the shared mobile overlay visual contract. + * + * Compact and hover windows keep Material's modal sheet semantics. A side + * placement uses a full-window Dialog so back handling, focus containment and + * accessibility isolation remain native while the surface docks to the + * physical trailing region selected by the shared policy. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun AdaptiveModalSurface( + visible: Boolean, + placement: SettingsPlacement, + onDismissRequest: () -> Unit, + content: @Composable (Modifier) -> Unit, +) { + if (!visible) return + + if (placement.mode == SettingsPlacementMode.SIDE) { + Dialog( + onDismissRequest = onDismissRequest, + properties = DialogProperties( + dismissOnBackPress = true, + dismissOnClickOutside = true, + usePlatformDefaultWidth = false, + decorFitsSystemWindows = false, + ), + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background(bitFunColors.modalScrim) + .clickable(onClick = onDismissRequest) + .safeDrawingPadding(), + contentAlignment = Alignment.CenterEnd, + ) { + Surface( + color = MaterialTheme.colorScheme.background, + shape = RoundedCornerShape(MobileDesignGeometry.SheetSideRadius), + shadowElevation = MobileDesignGeometry.PopoverShadowRadius, + modifier = Modifier + .width(placement.width.dp) + .height(placement.height.dp) + .clickable(interactionSource = null, indication = null, onClick = {}), + ) { + content(Modifier.fillMaxSize()) + } + } + } + return + } + + ModalBottomSheet( + onDismissRequest = onDismissRequest, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + containerColor = MaterialTheme.colorScheme.background, + shape = RoundedCornerShape( + topStart = MobileDesignGeometry.SheetTopRadius, + topEnd = MobileDesignGeometry.SheetTopRadius, + ), + dragHandle = null, + ) { + val modifier = if (placement.mode == SettingsPlacementMode.FOLD_OPERATE && placement.height > 0) { + Modifier.fillMaxWidth().height(placement.height.dp) + } else { + Modifier.fillMaxWidth().fillMaxHeight(0.94f) + } + content(modifier) + } +} diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/common/SignedOutConnectionActions.kt b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/common/SignedOutConnectionActions.kt new file mode 100644 index 0000000000..0acfa050b6 --- /dev/null +++ b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/common/SignedOutConnectionActions.kt @@ -0,0 +1,77 @@ +package com.bitfun.mobile.app.ui.common + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +/** Shared signed-out choice used by both the sidebar and the connection page. */ +@Composable +internal fun SignedOutConnectionActions( + scanLabel: String, + accountLabel: String, + onScan: () -> Unit, + onOpenAccount: () -> Unit, + modifier: Modifier = Modifier, + showScan: Boolean = true, + enabled: Boolean = true, + buttonHeight: Dp = 48.dp, + spacing: Dp = 10.dp, + fontSize: Int = 16, +) { + val shape = RoundedCornerShape(buttonHeight / 2) + Column( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(spacing), + ) { + if (showScan) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(buttonHeight) + .clip(shape) + .border(1.dp, MaterialTheme.colorScheme.outlineVariant, shape) + .clickable(enabled = enabled, onClick = onScan), + contentAlignment = Alignment.Center, + ) { + Text( + scanLabel, + fontSize = fontSize.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + ) + } + } + Box( + modifier = Modifier + .fillMaxWidth() + .height(buttonHeight) + .clip(shape) + .background(MaterialTheme.colorScheme.primary) + .clickable(enabled = enabled, onClick = onOpenAccount), + contentAlignment = Alignment.Center, + ) { + Text( + accountLabel, + fontSize = fontSize.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onPrimary, + ) + } + } +} diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/remote/ConnectAccountDeviceScreen.kt b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/remote/ConnectAccountDeviceScreen.kt new file mode 100644 index 0000000000..bec77241f5 --- /dev/null +++ b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/remote/ConnectAccountDeviceScreen.kt @@ -0,0 +1,282 @@ +package com.bitfun.mobile.app.ui.remote + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.bitfun.mobile.app.R +import com.bitfun.mobile.app.ui.account.messageRes +import com.bitfun.mobile.app.ui.theme.bitFunColors +import com.bitfun.mobile.core.feature.account.AccountUiState + +internal const val CONNECT_ACCOUNT_DEVICE_TEST_TAG: String = "connect-account-device" +internal const val CONNECT_ACCOUNT_DEVICE_REFRESH_TEST_TAG: String = "connect-account-device-refresh" +internal const val CONNECT_ACCOUNT_DEVICE_SCAN_TEST_TAG: String = "connect-account-device-scan" +internal const val CONNECT_ACCOUNT_DEVICE_ROW_TEST_TAG_PREFIX: String = "connect-account-device-row:" + +/** + * Signed-in landing page for remote control, matching HarmonyOS' + * `ConnectAccountDevicePage`. + * + * A signed-in phone should not fall through to anonymous pairing just because + * no desktop happened to be online when its session was restored. The last + * device snapshot remains useful, refresh is explicit, and QR pairing is still + * available as the alternate path. + */ +@Composable +internal fun ConnectAccountDeviceScreen( + state: AccountUiState.Ready, + onBack: () -> Unit, + onRefresh: () -> Unit, + onSelect: (String) -> Unit, + onOpenScanner: () -> Unit, + modifier: Modifier, +) { + Column( + modifier = modifier + .fillMaxSize() + .testTag(CONNECT_ACCOUNT_DEVICE_TEST_TAG), + ) { + Row( + modifier = Modifier.fillMaxWidth().height(92.dp).padding(start = 28.dp, end = 28.dp, top = 18.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.Top, + ) { + Surface( + onClick = onBack, + shape = CircleShape, + color = MaterialTheme.colorScheme.surfaceVariant, + modifier = Modifier.size(48.dp), + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + painterResource(R.drawable.ic_symbol_chevron_left), + contentDescription = stringResource(R.string.common_back), + tint = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.size(23.dp), + ) + } + } + Column(verticalArrangement = Arrangement.spacedBy(4.dp), modifier = Modifier.weight(1f)) { + Text( + stringResource(R.string.connect_account_devices_title), + fontSize = 22.sp, + lineHeight = 28.sp, + fontWeight = FontWeight.Bold, + ) + Text( + stringResource(R.string.connect_account_devices_subtitle), + fontSize = 13.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + Column( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(start = 28.dp, end = 28.dp, top = 10.dp, bottom = 34.dp), + verticalArrangement = Arrangement.spacedBy(18.dp), + ) { + Text( + stringResource(R.string.connect_account_devices_body), + fontSize = 14.sp, + lineHeight = 21.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + DeviceListCard(state = state, onRefresh = onRefresh, onSelect = onSelect) + Surface( + onClick = onOpenScanner, + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.surface, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + modifier = Modifier + .fillMaxWidth() + .height(58.dp) + .testTag(CONNECT_ACCOUNT_DEVICE_SCAN_TEST_TAG), + ) { + Row( + modifier = Modifier.padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + painterResource(R.drawable.ic_symbol_link), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(22.dp).alpha(0.66f), + ) + Text( + stringResource(R.string.connect_account_devices_scan), + fontSize = 16.sp, + fontWeight = FontWeight.Medium, + modifier = Modifier.weight(1f), + ) + Icon( + painterResource(R.drawable.ic_symbol_chevron_right), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(15.dp).alpha(0.44f), + ) + } + } + } + } +} + +@Composable +private fun DeviceListCard( + state: AccountUiState.Ready, + onRefresh: () -> Unit, + onSelect: (String) -> Unit, +) { + Surface( + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.surface, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + modifier = Modifier.fillMaxWidth(), + ) { + Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) { + Row(modifier = Modifier.fillMaxWidth().height(38.dp), verticalAlignment = Alignment.CenterVertically) { + Text( + stringResource(R.string.connect_account_devices_available), + fontSize = 16.sp, + fontWeight = FontWeight.Bold, + ) + Spacer(Modifier.weight(1f)) + if (state.refreshing) { + CircularProgressIndicator( + strokeWidth = 2.dp, + modifier = Modifier.size(16.dp), + ) + } else { + Text( + stringResource( + if (state.refreshFailure == null) { + R.string.account_devices_refresh + } else { + R.string.account_devices_retry + }, + ), + fontSize = 14.sp, + color = if (state.refreshFailure == null) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.error + }, + modifier = Modifier + .clickable(onClick = onRefresh) + .padding(horizontal = 6.dp, vertical = 6.dp) + .testTag(CONNECT_ACCOUNT_DEVICE_REFRESH_TEST_TAG), + ) + } + } + state.refreshFailure?.let { failure -> + Text( + stringResource(failure.messageRes()), + fontSize = 13.sp, + lineHeight = 19.sp, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(bottom = 6.dp), + ) + } + if (state.devices.isEmpty()) { + Box( + modifier = Modifier.fillMaxWidth().height(120.dp), + contentAlignment = Alignment.CenterStart, + ) { + Text( + stringResource(R.string.account_devices_empty), + fontSize = 14.sp, + lineHeight = 20.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } else { + state.devices.forEach { device -> + val selected = device.id == state.selectedDeviceId + Row( + modifier = Modifier + .fillMaxWidth() + .height(60.dp) + .clickable(enabled = device.online, onClick = { onSelect(device.id) }) + .alpha(if (device.online) 1f else 0.64f) + .testTag(CONNECT_ACCOUNT_DEVICE_ROW_TEST_TAG_PREFIX + device.id), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + painterResource(R.drawable.ic_symbol_desktop), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(26.dp).alpha(if (device.online) 0.68f else 0.38f), + ) + Column(verticalArrangement = Arrangement.spacedBy(3.dp), modifier = Modifier.weight(1f)) { + Text( + device.name.ifBlank { device.id }, + fontSize = 15.sp, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + stringResource(if (device.online) R.string.account_online else R.string.account_offline), + fontSize = 13.sp, + color = if (device.online) { + bitFunColors.success + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + if (device.online && !selected) { + Surface(color = MaterialTheme.colorScheme.surfaceVariant, shape = RoundedCornerShape(14.dp)) { + Text( + stringResource(R.string.account_connect), + fontSize = 14.sp, + modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), + ) + } + } else if (selected) { + Text( + stringResource(R.string.account_device_current_control), + fontSize = 13.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + } + } +} diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/remote/ConnectView.kt b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/remote/ConnectView.kt index b128084f2c..9e74e45599 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/remote/ConnectView.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/remote/ConnectView.kt @@ -56,6 +56,7 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.bitfun.mobile.app.R +import com.bitfun.mobile.app.ui.common.SignedOutConnectionActions import com.bitfun.mobile.app.ui.theme.bitFunColors import com.bitfun.mobile.core.feature.pairing.PairingIntent import com.bitfun.mobile.core.feature.pairing.PairingUiState @@ -85,10 +86,13 @@ internal fun ConnectView( onSubmit: (PairingIntent.Submit) -> Unit, onDismiss: () -> Unit, onBack: () -> Unit, + onOpenAccount: () -> Unit, + startScanning: Boolean = false, + onScanStarted: () -> Unit = {}, modifier: Modifier, ) { var manual by rememberSaveable { mutableStateOf(false) } - var scanning by rememberSaveable { mutableStateOf(false) } + var scanning by rememberSaveable { mutableStateOf(startScanning) } var url by rememberSaveable { mutableStateOf("") } var userId by rememberSaveable { mutableStateOf("") } // Never rememberSaveable: a password must not reach saved instance state. @@ -137,6 +141,13 @@ internal fun ConnectView( .addOnCanceledListener { scanFailed = true } Unit } + LaunchedEffect(startScanning) { + if (startScanning) { + scanning = true + scan() + onScanStarted() + } + } Box(modifier = modifier.fillMaxSize().testTag(CONNECT_TEST_TAG)) { Column( @@ -161,6 +172,7 @@ internal fun ConnectView( }, onDismiss = onDismiss, onBack = onBack, + onOpenAccount = onOpenAccount, ) } } @@ -192,6 +204,7 @@ private fun ColumnScope.IntroPairing( onScan: () -> Unit, onDismiss: () -> Unit, onBack: () -> Unit, + onOpenAccount: () -> Unit, ) { Box { Hero() @@ -224,42 +237,31 @@ private fun ColumnScope.IntroPairing( ) { ConnectDesktopGlyph() Text( - stringResource(R.string.connect_title), + stringResource(R.string.connect_choose_connection), fontSize = 24.sp, lineHeight = 30.sp, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center, ) - Centered(stringResource(R.string.connect_body), fontSize = 17.sp, lineHeight = 25.sp) - Centered(stringResource(R.string.connect_steps), fontSize = 17.sp, lineHeight = 25.sp) - if (state is PairingUiState.Failed) { PairingFailureCard(state, onDismiss) } } - Button( - onClick = onScan, - enabled = !connecting, + SignedOutConnectionActions( + scanLabel = stringResource(R.string.sidebar_scan_to_connect), + accountLabel = stringResource(R.string.sidebar_sign_in), + onScan = onScan, + onOpenAccount = onOpenAccount, modifier = Modifier .align(Alignment.CenterHorizontally) .padding(bottom = 14.dp) - .fillMaxWidth(0.82f) - .height(58.dp), - shape = RoundedCornerShape(29.dp), - contentPadding = ButtonDefaults.ContentPadding, - ) { - if (connecting) { - CircularProgressIndicator(modifier = Modifier.padding(end = 8.dp)) - Text(stringResource(R.string.pairing_connecting)) - } else { - Text( - stringResource(R.string.connect_have_pair_code), - fontSize = 21.sp, - fontWeight = FontWeight.Bold, - ) - } - } + .fillMaxWidth(0.82f), + enabled = !connecting, + buttonHeight = 58.dp, + spacing = 12.dp, + fontSize = 20, + ) } @Composable @@ -341,7 +343,7 @@ private fun ManualPairing( val consumeTouches = remember { MutableInteractionSource() } Box( modifier = modifier - .background(Color.Black.copy(alpha = 0.58f)) + .background(bitFunColors.modalScrim) .clickable(enabled = !connecting, onClick = onBack), contentAlignment = Alignment.Center, ) { diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/remote/CreateSessionScreen.kt b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/remote/CreateSessionScreen.kt index ec0fc0ba59..c7494695fb 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/remote/CreateSessionScreen.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/remote/CreateSessionScreen.kt @@ -17,8 +17,12 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.DropdownMenu import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Text @@ -30,6 +34,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment +import androidx.compose.ui.draw.alpha import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusManager import androidx.compose.ui.platform.LocalFocusManager @@ -44,8 +49,10 @@ import com.bitfun.mobile.app.ui.common.CircleControl import com.bitfun.mobile.core.feature.connection.ConnectionPhase import com.bitfun.mobile.core.feature.session.ChatComposerCapabilities import com.bitfun.mobile.core.feature.session.CreateSessionPresenter +import com.bitfun.mobile.core.feature.session.ModelOption import com.bitfun.mobile.core.feature.session.RemoteSessionIntent import com.bitfun.mobile.core.feature.session.RemoteSessionUiState +import com.bitfun.mobile.core.feature.session.createModelOptions import com.bitfun.mobile.core.feature.workspace.RemoteWorkspaceIntent import com.bitfun.mobile.core.feature.workspace.RemoteWorkspaceUiState @@ -53,6 +60,15 @@ internal const val CREATE_SESSION_TEST_TAG: String = "create-session" internal const val CREATE_SESSION_BACK_TEST_TAG: String = "create-session-back" internal const val CREATE_SESSION_WORKSPACE_TEST_TAG: String = "create-session-workspace" +internal data class CreateDeviceChoice( + val id: String, + val name: String, + val online: Boolean, + val selected: Boolean, +) + +private enum class CreateSelectionKind { DEVICE, WORKSPACE } + /** * [CreateSessionScreen] plus the one thing it cannot decide for itself: when the * session it asked for exists. @@ -68,6 +84,9 @@ internal fun CreateSessionRoute( workspaceState: RemoteWorkspaceUiState, phase: ConnectionPhase, deviceId: String, + devices: List, + compact: Boolean, + onDevicePick: (String) -> Unit, onBack: () -> Unit, onCreated: (String) -> Unit, onWorkspaceIntent: (RemoteWorkspaceIntent) -> Unit, @@ -75,6 +94,7 @@ internal fun CreateSessionRoute( modifier: Modifier, ) { val ready = sessionState as? RemoteSessionUiState.Ready + val modelOptions = ready?.createModelOptions(stringResource(R.string.models_unnamed)).orEmpty() val baseline = rememberSaveable { mutableStateOf(ready?.selectedSessionId) } val created = ready?.selectedSessionId val hasTimeline = ready?.timeline != null @@ -86,6 +106,10 @@ internal fun CreateSessionRoute( workspaceState = workspaceState, phase = phase, deviceId = deviceId, + devices = devices, + modelOptions = modelOptions, + compact = compact, + onDevicePick = onDevicePick, // Anything other than a settled list means the store is mid-request or // has nothing to create against, and either way the send would be lost. busy = ready?.busy ?: true, @@ -116,18 +140,54 @@ internal fun CreateSessionScreen( phase: ConnectionPhase, /** The desktop this would run on. Empty means there is nothing to create on. */ deviceId: String, + devices: List, + modelOptions: List = emptyList(), + compact: Boolean, busy: Boolean, onBack: () -> Unit, + onDevicePick: (String) -> Unit, onWorkspaceIntent: (RemoteWorkspaceIntent) -> Unit, onIntent: (RemoteSessionIntent) -> Unit, modifier: Modifier, ) { var draft by rememberSaveable { mutableStateOf("") } var workspacePath by rememberSaveable { mutableStateOf("") } + var selectedModelId by rememberSaveable { mutableStateOf(null) } // Not saveable: an open sheet is a finger part-way through a gesture. - var pickerOpen by remember { mutableStateOf(false) } + var pickerKind by remember { mutableStateOf(null) } val focusManager = LocalFocusManager.current val ready = workspaceState as? RemoteWorkspaceUiState.Ready + LaunchedEffect(modelOptions) { + if (modelOptions.none { it.id == selectedModelId }) { + selectedModelId = modelOptions.firstOrNull { it.selected }?.id ?: modelOptions.firstOrNull()?.id + } + } + val selectedModel = modelOptions.firstOrNull { it.id == selectedModelId } + ?: modelOptions.firstOrNull { it.selected } + ?: modelOptions.firstOrNull() + LaunchedEffect(ready?.selected?.kind, ready?.assistants, workspacePath) { + if (workspacePath.isEmpty() && ready?.selected?.kind != ASSISTANT_KIND) { + ready?.assistants?.firstOrNull()?.let { + onWorkspaceIntent(RemoteWorkspaceIntent.SelectAssistant(it.path)) + } + } + } + val selectWorkspace: (String) -> Unit = { path -> + workspacePath = path + // Applied now rather than at send: `set_workspace` is a round trip to + // the desktop, so the settled selection can be shown while the draft is + // still being written. Chat selects the assistant workspace; projects + // select their concrete workspace. + if (path.isEmpty()) { + if (ready?.selected?.kind != ASSISTANT_KIND) { + ready?.assistants?.firstOrNull()?.let { + onWorkspaceIntent(RemoteWorkspaceIntent.SelectAssistant(it.path)) + } + } + } else { + onWorkspaceIntent(RemoteWorkspaceIntent.SelectWorkspace(path)) + } + } val voiceInput = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> if (result.resultCode == Activity.RESULT_OK) { @@ -165,25 +225,91 @@ internal fun CreateSessionScreen( ) } - ContextRow( - glyph = if (workspacePath.isEmpty()) { - R.drawable.ic_symbol_message - } else { - R.drawable.ic_symbol_folder - }, - label = when { - workspaceState is RemoteWorkspaceUiState.Loading -> stringResource(R.string.sessions_loading) - workspacePath.isEmpty() -> stringResource(R.string.create_chat) - else -> ready?.workspaces?.firstOrNull { it.path == workspacePath }?.name.orEmpty() - .ifEmpty { workspacePath } - }, - enabled = !busy, - onClick = { - focusManager.clearFocus() - pickerOpen = true - }, - modifier = Modifier.testTag(CREATE_SESSION_WORKSPACE_TEST_TAG), - ) + Column(modifier = Modifier.fillMaxWidth()) { + if (!compact && devices.isNotEmpty()) { + Box(modifier = Modifier.fillMaxWidth()) { + ContextRow( + glyph = R.drawable.ic_symbol_desktop, + label = devices.firstOrNull { it.selected }?.name + ?.ifBlank { deviceId } + ?: deviceId, + expanded = pickerKind == CreateSelectionKind.DEVICE, + enabled = !busy, + onClick = { + focusManager.clearFocus() + pickerKind = CreateSelectionKind.DEVICE + }, + modifier = Modifier, + ) + DropdownMenu( + expanded = pickerKind == CreateSelectionKind.DEVICE, + onDismissRequest = { pickerKind = null }, + modifier = Modifier.width(340.dp), + shape = RoundedCornerShape(16.dp), + containerColor = MaterialTheme.colorScheme.surface, + tonalElevation = 0.dp, + shadowElevation = 18.dp, + ) { + DevicePicker( + devices = devices, + onPick = { id -> + pickerKind = null + onDevicePick(id) + }, + modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), + ) + } + } + } + + Box(modifier = Modifier.fillMaxWidth()) { + ContextRow( + glyph = if (workspacePath.isEmpty()) { + R.drawable.ic_symbol_message + } else { + R.drawable.ic_symbol_folder + }, + label = when { + workspaceState is RemoteWorkspaceUiState.Loading -> stringResource(R.string.sessions_loading) + workspacePath.isEmpty() -> stringResource(R.string.create_chat) + else -> ready?.workspaces?.firstOrNull { it.path == workspacePath }?.name.orEmpty() + .ifEmpty { workspacePath } + }, + expanded = pickerKind == CreateSelectionKind.WORKSPACE, + enabled = !busy, + onClick = { + focusManager.clearFocus() + pickerKind = CreateSelectionKind.WORKSPACE + }, + modifier = Modifier.testTag(CREATE_SESSION_WORKSPACE_TEST_TAG), + ) + if (!compact) { + DropdownMenu( + expanded = pickerKind == CreateSelectionKind.WORKSPACE, + onDismissRequest = { pickerKind = null }, + modifier = Modifier.width(340.dp), + shape = RoundedCornerShape(16.dp), + containerColor = MaterialTheme.colorScheme.surface, + tonalElevation = 0.dp, + shadowElevation = 18.dp, + ) { + WorkspacePicker( + workspaces = ready?.workspaces.orEmpty().map { + WorkspaceChoice(path = it.path, name = it.name) + }, + selectedPath = workspacePath, + showHeader = false, + onDismiss = { pickerKind = null }, + onPick = { path -> + pickerKind = null + selectWorkspace(path) + }, + modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), + ) + } + } + } + } ComposerBar( draft = draft, @@ -196,13 +322,15 @@ internal fun CreateSessionScreen( // comes back, and the source blocks only the send too. phase = if (deviceId.isEmpty()) ConnectionPhase.DISCONNECTED else phase, // There is no session yet, so there is no per-session model to swap. - model = null, + model = selectedModel, + modelOptions = modelOptions, capabilities = ChatComposerCapabilities.RemoteCreate, placeholder = stringResource(R.string.create_placeholder), onDraftChange = { draft = it }, onRemoveImage = {}, onAttach = {}, onOpenModels = {}, + onSelectModel = { selectedModelId = it }, onVoice = { voiceInput.launch( Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply { @@ -220,7 +348,7 @@ internal fun CreateSessionScreen( agentType = CreateSessionPresenter.agentType(workspacePath), title = "", instruction = draft, - modelId = null, + modelId = selectedModelId, ), ) draft = "" @@ -231,35 +359,24 @@ internal fun CreateSessionScreen( ) } - if (pickerOpen) { - ModalBottomSheet(onDismissRequest = { pickerOpen = false }) { + if (compact && pickerKind == CreateSelectionKind.WORKSPACE) { + ModalBottomSheet(onDismissRequest = { pickerKind = null }) { WorkspacePicker( workspaces = ready?.workspaces.orEmpty().map { WorkspaceChoice(path = it.path, name = it.name) }, selectedPath = workspacePath, + showHeader = true, + onDismiss = { pickerKind = null }, onPick = { path -> - pickerOpen = false - workspacePath = path - // Applied now rather than at send: `set_workspace` is a round - // trip to the desktop, and doing it here means the row can - // show what the desktop actually settled on while the user is - // still typing. The source binds the same two workspaces — - // a project for code, the assistant's own for chat. - if (path.isEmpty()) { - if (ready?.selected?.kind != ASSISTANT_KIND) { - ready?.assistants?.firstOrNull()?.let { - onWorkspaceIntent(RemoteWorkspaceIntent.SelectAssistant(it.path)) - } - } - } else { - onWorkspaceIntent(RemoteWorkspaceIntent.SelectWorkspace(path)) - } + pickerKind = null + selectWorkspace(path) }, modifier = Modifier.fillMaxWidth().padding(bottom = 24.dp), ) } } + } /** What the desktop calls the workspace it keeps its chat sessions in. */ @@ -300,6 +417,7 @@ private fun DismissFiller(focusManager: FocusManager, modifier: Modifier) { private fun ContextRow( @DrawableRes glyph: Int, label: String, + expanded: Boolean, enabled: Boolean, onClick: () -> Unit, modifier: Modifier, @@ -328,7 +446,10 @@ private fun ContextRow( modifier = Modifier.weight(1f, fill = false), ) Icon( - painterResource(R.drawable.ic_symbol_chevron_right), + painterResource( + if (expanded) R.drawable.ic_symbol_chevron_up + else R.drawable.ic_symbol_chevron_down, + ), contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(14.dp), @@ -336,6 +457,25 @@ private fun ContextRow( } } +@Composable +private fun DevicePicker( + devices: List, + onPick: (String) -> Unit, + modifier: Modifier, +) { + Column(modifier = modifier) { + devices.forEach { device -> + PickerRow( + title = device.name.ifBlank { device.id }, + subtitle = stringResource(if (device.online) R.string.account_online else R.string.account_offline), + selected = device.selected, + enabled = device.online || device.selected, + onClick = { onPick(device.id) }, + ) + } + } +} + /** * Where the session will run: chat, or one of the desktop's recent workspaces. * @@ -346,15 +486,32 @@ private fun ContextRow( private fun WorkspacePicker( workspaces: List, selectedPath: String, + showHeader: Boolean, + onDismiss: () -> Unit, onPick: (String) -> Unit, modifier: Modifier, ) { Column(modifier = modifier) { - Text( - stringResource(R.string.create_workspace_picker), - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp), - ) + if (showHeader) { + Row( + modifier = Modifier.fillMaxWidth().height(52.dp).padding(start = 18.dp, end = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + stringResource(R.string.create_workspace_picker), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.weight(1f), + ) + IconButton(onClick = onDismiss, modifier = Modifier.size(44.dp)) { + Icon( + painterResource(R.drawable.ic_symbol_xmark), + contentDescription = stringResource(R.string.common_close), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(16.dp), + ) + } + } + } PickerRow( title = stringResource(R.string.create_chat), subtitle = "", @@ -388,12 +545,14 @@ private fun PickerRow( title: String, subtitle: String, selected: Boolean, + enabled: Boolean = true, onClick: () -> Unit, ) { Row( modifier = Modifier .fillMaxWidth() - .clickable(onClick = onClick) + .alpha(if (enabled) 1f else 0.55f) + .clickable(enabled = enabled, onClick = onClick) .padding(horizontal = 24.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/remote/PairingScreen.kt b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/remote/PairingScreen.kt index 2f3957e692..2bfafae775 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/remote/PairingScreen.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/remote/PairingScreen.kt @@ -39,6 +39,7 @@ import com.bitfun.mobile.app.ui.common.CircleControl import com.bitfun.mobile.app.ui.shell.MENU_TEST_TAG import com.bitfun.mobile.app.viewmodel.PairingViewModel import com.bitfun.mobile.core.feature.connection.ConnectionPhase +import com.bitfun.mobile.core.feature.layout.SettingsPlacement import com.bitfun.mobile.core.feature.connection.connectionPhase import com.bitfun.mobile.core.feature.pairing.ConnectionLiveness import com.bitfun.mobile.core.feature.pairing.PairedWorkspace @@ -60,14 +61,21 @@ import com.bitfun.mobile.core.feature.workspace.RemoteWorkspaceUiState @Composable internal fun PairingScreen( modifier: Modifier, + settingsPlacement: SettingsPlacement, + sessionDetailsPlacement: SettingsPlacement, + viewSettingsPlacement: SettingsPlacement, + onOpenRemoteSettings: () -> Unit, onOpenSidebar: (() -> Unit)? = null, onBack: () -> Unit = {}, + onOpenAccount: () -> Unit = {}, compact: Boolean = true, requestedSessionId: String? = null, creatingSession: Boolean = false, onOpenSession: (String) -> Unit = {}, onCreateSession: () -> Unit = {}, onRemoteHome: () -> Unit = {}, + startScanning: Boolean = false, + onScanStarted: () -> Unit = {}, viewModel: PairingViewModel = viewModel(factory = PairingViewModel.Factory), ) { val state by viewModel.state.collectAsStateWithLifecycle() @@ -87,8 +95,14 @@ internal fun PairingScreen( remoteState = remoteState, workspaceState = workspaceState, phase = current.connectionPhase(), + settingsPlacement = settingsPlacement, + sessionDetailsPlacement = sessionDetailsPlacement, + viewSettingsPlacement = viewSettingsPlacement, + onOpenRemoteSettings = onOpenRemoteSettings, deviceId = current.workspace.roomLabel, + createDevices = emptyList(), desktopName = "", + onCreateDevicePick = {}, onSessionIntent = viewModel::dispatchSession, onWorkspaceIntent = viewModel::dispatchWorkspace, onOpenSidebar = onOpenSidebar, @@ -115,6 +129,9 @@ internal fun PairingScreen( onSubmit = viewModel::dispatch, onDismiss = { viewModel.dispatch(PairingIntent.Dismiss) }, onBack = onBack, + onOpenAccount = onOpenAccount, + startScanning = startScanning, + onScanStarted = onScanStarted, modifier = modifier, ) } @@ -127,8 +144,14 @@ internal fun AccountRemoteScreen( workspaceState: RemoteWorkspaceUiState, deviceId: String, deviceName: String, + createDevices: List, accountUsername: String, phase: ConnectionPhase, + settingsPlacement: SettingsPlacement, + sessionDetailsPlacement: SettingsPlacement, + viewSettingsPlacement: SettingsPlacement, + onOpenRemoteSettings: () -> Unit, + onCreateDevicePick: (String) -> Unit, onSessionIntent: (com.bitfun.mobile.core.feature.session.RemoteSessionIntent) -> Unit, onWorkspaceIntent: (RemoteWorkspaceIntent) -> Unit, onOpenSidebar: (() -> Unit)? = null, @@ -144,8 +167,14 @@ internal fun AccountRemoteScreen( remoteState = remoteState, workspaceState = workspaceState, phase = phase, + settingsPlacement = settingsPlacement, + sessionDetailsPlacement = sessionDetailsPlacement, + viewSettingsPlacement = viewSettingsPlacement, + onOpenRemoteSettings = onOpenRemoteSettings, deviceId = deviceId, + createDevices = createDevices, desktopName = deviceName, + onCreateDevicePick = onCreateDevicePick, onSessionIntent = onSessionIntent, onWorkspaceIntent = onWorkspaceIntent, onOpenSidebar = onOpenSidebar, @@ -167,8 +196,14 @@ private fun RemoteConnectedScreen( remoteState: RemoteSessionUiState, workspaceState: RemoteWorkspaceUiState, phase: ConnectionPhase, + settingsPlacement: SettingsPlacement, + sessionDetailsPlacement: SettingsPlacement, + viewSettingsPlacement: SettingsPlacement, + onOpenRemoteSettings: () -> Unit, deviceId: String, + createDevices: List, desktopName: String, + onCreateDevicePick: (String) -> Unit, onSessionIntent: (com.bitfun.mobile.core.feature.session.RemoteSessionIntent) -> Unit, onWorkspaceIntent: (RemoteWorkspaceIntent) -> Unit, onOpenSidebar: (() -> Unit)?, @@ -189,6 +224,7 @@ private fun RemoteConnectedScreen( ConversationView( state = conversation, phase = phase, + settingsPlacement = settingsPlacement, onBack = onRemoteHome, onOpenSidebar = onOpenSidebar, onIntent = onSessionIntent, @@ -227,6 +263,9 @@ private fun RemoteConnectedScreen( workspaceState = workspaceState, phase = phase, deviceId = deviceId, + devices = createDevices, + compact = compact, + onDevicePick = onCreateDevicePick, onBack = onRemoteHome, onCreated = onOpenSession, onWorkspaceIntent = onWorkspaceIntent, @@ -239,14 +278,18 @@ private fun RemoteConnectedScreen( desktopName = desktopName, onOpenSidebar = onOpenSidebar, onCreate = onCreateSession, + onOpenRemoteSettings = onOpenRemoteSettings, modifier = modifier, ) } else { Column(modifier = modifier.fillMaxSize()) { - RemoteShellHeader(onOpenSidebar) + RemoteShellHeader(onOpenSidebar, onOpenRemoteSettings = onOpenRemoteSettings) RemoteSessionListView( state = remoteState, workspaceState = workspaceState, + compact = compact, + sessionDetailsPlacement = sessionDetailsPlacement, + viewSettingsPlacement = viewSettingsPlacement, connectionDetails = connectionDetails, onIntent = onSessionIntent, onWorkspaceIntent = onWorkspaceIntent, @@ -264,6 +307,7 @@ private fun RemoteCompactHome( desktopName: String, onOpenSidebar: (() -> Unit)?, onCreate: () -> Unit, + onOpenRemoteSettings: () -> Unit, modifier: Modifier, ) { val ready = remoteState as? RemoteSessionUiState.Ready @@ -282,7 +326,7 @@ private fun RemoteCompactHome( } Column(modifier = modifier.fillMaxSize()) { - RemoteShellHeader(onOpenSidebar, desktopName) + RemoteShellHeader(onOpenSidebar, desktopName, onOpenRemoteSettings) Column( modifier = Modifier .weight(1f) @@ -326,7 +370,11 @@ private fun RemoteCompactHome( } @Composable -private fun RemoteShellHeader(onOpenSidebar: (() -> Unit)?, subtitle: String = "") { +private fun RemoteShellHeader( + onOpenSidebar: (() -> Unit)?, + subtitle: String = "", + onOpenRemoteSettings: () -> Unit, +) { val hasSubtitle = subtitle.isNotBlank() Row( modifier = Modifier @@ -366,7 +414,13 @@ private fun RemoteShellHeader(onOpenSidebar: (() -> Unit)?, subtitle: String = " ) } } - Box(Modifier.size(44.dp)) + CircleControl( + icon = R.drawable.ic_symbol_gearshape, + glyphSize = 19, + contentDescription = stringResource(R.string.remote_settings_title), + onClick = onOpenRemoteSettings, + modifier = Modifier, + ) } } diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/remote/RemoteSessionListView.kt b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/remote/RemoteSessionListView.kt index fb45ad7b71..676209a134 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/remote/RemoteSessionListView.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/remote/RemoteSessionListView.kt @@ -26,10 +26,8 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Text import androidx.compose.material3.TextButton -import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -42,6 +40,9 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource @@ -49,6 +50,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.IntRect import androidx.compose.ui.unit.sp import com.bitfun.mobile.app.R import com.bitfun.mobile.app.state.SessionViewSettings @@ -56,7 +58,9 @@ import com.bitfun.mobile.app.ui.settings.SessionViewSettingsSheet import com.bitfun.mobile.app.ui.settings.VIEW_SETTINGS_TOGGLE_TEST_TAG import com.bitfun.mobile.app.ui.settings.statusText import com.bitfun.mobile.app.ui.shell.sidebar.SidebarCircleButton +import com.bitfun.mobile.app.ui.theme.generated.MobileDesignGeometry import com.bitfun.mobile.core.feature.session.RelativeTime +import com.bitfun.mobile.core.feature.layout.SettingsPlacement import com.bitfun.mobile.core.feature.session.RemoteSessionFailureReason import com.bitfun.mobile.core.feature.session.RemoteSessionIntent import com.bitfun.mobile.core.feature.session.RemoteSessionUiState @@ -71,8 +75,9 @@ import com.bitfun.mobile.core.feature.workspace.RemoteWorkspaceUiState import kotlinx.coroutines.delay internal const val SESSION_LIST_TEST_TAG: String = "session-list" -internal const val SESSION_CREATE_TEST_TAG: String = "session-create" internal const val SESSION_PROJECTS_TEST_TAG: String = "session-projects" +internal const val SESSION_PROJECT_CREATE_TEST_TAG_PREFIX: String = "session-project-create:" +internal const val SESSION_CHAT_CREATE_TEST_TAG: String = "session-chat-create" internal const val SESSION_SHOW_MORE_TEST_TAG_PREFIX: String = "session-show-more:" internal const val SESSION_SEARCH_TOGGLE_TEST_TAG: String = "session-search-toggle" internal const val SESSION_SEARCH_FIELD_TEST_TAG: String = "session-search-field" @@ -87,6 +92,9 @@ internal const val SESSION_SEARCH_FIELD_TEST_TAG: String = "session-search-field internal fun RemoteSessionListView( state: RemoteSessionUiState, workspaceState: RemoteWorkspaceUiState, + compact: Boolean, + sessionDetailsPlacement: SettingsPlacement, + viewSettingsPlacement: SettingsPlacement, connectionDetails: @Composable () -> Unit, onIntent: (RemoteSessionIntent) -> Unit, onWorkspaceIntent: (RemoteWorkspaceIntent) -> Unit, @@ -103,7 +111,11 @@ internal fun RemoteSessionListView( RemoteSessionListContent( state = state, workspaceState = workspaceState, + compact = compact, + sessionDetailsPlacement = sessionDetailsPlacement, + viewSettingsPlacement = viewSettingsPlacement, onIntent = onIntent, + onWorkspaceIntent = onWorkspaceIntent, onOpen = onOpen, onCreate = onCreate, connectionDetails = connectionDetails, @@ -130,7 +142,11 @@ internal fun RemoteSessionListView( internal fun RemoteSessionListContent( state: RemoteSessionUiState, workspaceState: RemoteWorkspaceUiState, + compact: Boolean, + sessionDetailsPlacement: SettingsPlacement, + viewSettingsPlacement: SettingsPlacement, onIntent: (RemoteSessionIntent) -> Unit, + onWorkspaceIntent: (RemoteWorkspaceIntent) -> Unit, onOpen: (String) -> Unit, /** Opens the longer create route, where the first message is written. */ onCreate: () -> Unit, @@ -140,13 +156,16 @@ internal fun RemoteSessionListContent( var search by rememberSaveable { mutableStateOf("") } var searchOpen by rememberSaveable { mutableStateOf(false) } var actionsFor by rememberSaveable { mutableStateOf(null) } + var actionAnchor by remember { mutableStateOf(null) } var detailsFor by rememberSaveable { mutableStateOf(null) } var viewSettingsOpen by rememberSaveable { mutableStateOf(false) } var collapsedSectionKeys by rememberSaveable { mutableStateOf>(emptyList()) } var revealedSectionKeys by rememberSaveable { mutableStateOf>(emptyList()) } // Not saveable, like the delete confirmation: an open menu is a finger // half-way through a gesture, not a place to come back to. - var createMenuOpen by remember { mutableStateOf(false) } + var projectCreateMenuPath by remember { mutableStateOf(null) } + var pendingProjectCreate by remember { mutableStateOf?>(null) } + var pendingAssistantCreate by remember { mutableStateOf(false) } var viewSettings by rememberSaveable(stateSaver = SessionViewSettings.Saver) { mutableStateOf(SessionViewSettings.Default) } @@ -161,12 +180,44 @@ internal fun RemoteSessionListContent( onIntent(RemoteSessionIntent.Search(search)) } } + LaunchedEffect(workspaceState, pendingProjectCreate, pendingAssistantCreate) { + when (workspaceState) { + is RemoteWorkspaceUiState.Ready -> { + pendingProjectCreate?.let { pending -> + if (workspaceState.selected?.path == pending.first) { + pendingProjectCreate = null + onIntent(RemoteSessionIntent.CreateSession(pending.second)) + } + } + if (pendingAssistantCreate && workspaceState.selected?.kind == ASSISTANT_WORKSPACE_KIND) { + pendingAssistantCreate = false + onIntent(RemoteSessionIntent.CreateSession("Claw")) + } + } + is RemoteWorkspaceUiState.Failed -> { + pendingProjectCreate = null + pendingAssistantCreate = false + } + else -> Unit + } + } + val createAssistantSession = { + val workspaceReady = workspaceState as? RemoteWorkspaceUiState.Ready + if (workspaceReady?.selected?.kind == ASSISTANT_WORKSPACE_KIND) { + onIntent(RemoteSessionIntent.CreateSession("Claw")) + } else { + val assistant = workspaceReady?.assistants?.firstOrNull() + if (assistant == null) { + onCreate() + } else { + pendingAssistantCreate = true + onWorkspaceIntent(RemoteWorkspaceIntent.SelectAssistant(assistant.path)) + } + } + } Column(modifier = Modifier.fillMaxSize()) { RemoteSessionListHeader( - searchOpen = searchOpen, - busy = ready?.busy == true, - createMenuOpen = createMenuOpen, onToggleViewSettings = { viewSettingsOpen = true }, onToggleSearch = { searchOpen = !searchOpen @@ -175,13 +226,6 @@ internal fun RemoteSessionListContent( onIntent(RemoteSessionIntent.Search("")) } }, - onToggleCreateMenu = { createMenuOpen = !createMenuOpen }, - onDismissCreateMenu = { createMenuOpen = false }, - onCreate = onCreate, - onCreateAgent = { agentType -> - createMenuOpen = false - onIntent(RemoteSessionIntent.CreateSession(agentType)) - }, ) if (searchOpen) { RemoteSessionSearchField( @@ -236,6 +280,34 @@ internal fun RemoteSessionListContent( SectionHeader( section = section, collapsed = collapsed, + createMenuOpen = section is SessionListSection.Project && + projectCreateMenuPath == section.path, + onToggleCreateMenu = if (section is SessionListSection.Project) { + { + projectCreateMenuPath = if (projectCreateMenuPath == section.path) { + null + } else { + section.path + } + } + } else null, + onDismissCreateMenu = { projectCreateMenuPath = null }, + onCreateAgent = if (section is SessionListSection.Project) { + { agentType -> + projectCreateMenuPath = null + val selectedPath = (workspaceState as? RemoteWorkspaceUiState.Ready) + ?.selected?.path + if (selectedPath == section.path) { + onIntent(RemoteSessionIntent.CreateSession(agentType)) + } else { + pendingProjectCreate = section.path to agentType + onWorkspaceIntent(RemoteWorkspaceIntent.SelectWorkspace(section.path)) + } + } + } else null, + onCreateAssistant = if (section is SessionListSection.Chat) { + createAssistantSession + } else null, onToggle = { collapsedSectionKeys = if (collapsed) { collapsedSectionKeys - sectionKey @@ -263,7 +335,10 @@ internal fun RemoteSessionListContent( onIntent(RemoteSessionIntent.Open(session.id)) onOpen(session.id) }, - onActions = { actionsFor = session.id }, + onActions = { anchor -> + actionAnchor = anchor + actionsFor = session.id + }, ) } if (!collapsed && batch.nextCount > 0) { @@ -292,22 +367,39 @@ internal fun RemoteSessionListContent( // the old copy would show a title the list no longer has. If the // session is gone entirely the sheet closes with it. rows.firstOrNull { it.id == actionsFor }?.let { session -> - SessionActionSheet( + val capabilities = SessionActionPolicy.resolve( + SessionActionScope.REMOTE, + session.agentType, + state.busy, + ) + val dismissActions = { + actionsFor = null + actionAnchor = null + } + val openDetails = { detailsFor = session.id } + val delete = { onIntent(RemoteSessionIntent.DeleteSession(session.id)) } + if (compact || actionAnchor == null) SessionActionSheet( title = session.title, status = session.status, - capabilities = SessionActionPolicy.resolve( - SessionActionScope.REMOTE, - session.agentType, - state.busy, - ), - onViewDetails = { detailsFor = session.id }, + capabilities = capabilities, + onViewDetails = openDetails, // Archive and export are local-storage operations, so // the policy never offers them for a REMOTE scope and // these cannot be reached from this list. onArchive = {}, onExport = {}, - onDelete = { onIntent(RemoteSessionIntent.DeleteSession(session.id)) }, - onDismiss = { actionsFor = null }, + onDelete = delete, + onDismiss = dismissActions, + ) else SessionActionPopup( + anchorBounds = actionAnchor!!, + title = session.title, + status = session.status, + capabilities = capabilities, + onViewDetails = openDetails, + onArchive = {}, + onExport = {}, + onDelete = delete, + onDismiss = dismissActions, ) } rows.firstOrNull { it.id == detailsFor }?.let { session -> @@ -320,6 +412,7 @@ internal fun RemoteSessionListContent( createdAt = session.createdAt, updatedAt = session.updatedAt, messageCount = session.messageCount, + placement = sessionDetailsPlacement, onDismiss = { detailsFor = null }, ) } @@ -331,13 +424,12 @@ internal fun RemoteSessionListContent( if (viewSettingsOpen && ready != null) { val workspace = workspaceState.asSessionContext() - ModalBottomSheet( + com.bitfun.mobile.app.ui.common.AdaptiveModalSurface( + visible = true, + placement = viewSettingsPlacement, onDismissRequest = { viewSettingsOpen = false }, - sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), - containerColor = MaterialTheme.colorScheme.background, - dragHandle = null, - ) { - Column(Modifier.fillMaxWidth().verticalScroll(rememberScrollState())) { + ) { surfaceModifier -> + Column(surfaceModifier.verticalScroll(rememberScrollState())) { SessionViewSettingsSheet( settings = viewSettings, workspaces = remember(ready.sessions, workspace) { @@ -360,15 +452,8 @@ internal fun RemoteSessionListContent( @Composable private fun RemoteSessionListHeader( - searchOpen: Boolean, - busy: Boolean, - createMenuOpen: Boolean, onToggleViewSettings: () -> Unit, onToggleSearch: () -> Unit, - onToggleCreateMenu: () -> Unit, - onDismissCreateMenu: () -> Unit, - onCreate: () -> Unit, - onCreateAgent: (String) -> Unit, ) { Row( modifier = Modifier.fillMaxWidth().height(50.dp), @@ -396,46 +481,6 @@ private fun RemoteSessionListHeader( onClick = onToggleSearch, modifier = Modifier.testTag(SESSION_SEARCH_TOGGLE_TEST_TAG), ) - Box { - SidebarCircleButton( - icon = R.drawable.ic_symbol_square_and_pencil, - contentDescription = stringResource(R.string.sidebar_new_chat), - diameter = 38, - onClick = onToggleCreateMenu, - modifier = Modifier, - ) - DropdownMenu( - expanded = createMenuOpen, - onDismissRequest = onDismissCreateMenu, - modifier = Modifier.width(150.dp), - shape = RoundedCornerShape(14.dp), - containerColor = MaterialTheme.colorScheme.surface, - tonalElevation = 0.dp, - shadowElevation = 18.dp, - ) { - CompactCreateMenuItem( - label = stringResource(R.string.create_title), - enabled = !busy, - onClick = { - onDismissCreateMenu() - onCreate() - }, - modifier = Modifier.testTag(SESSION_CREATE_TEST_TAG), - ) - CompactCreateMenuItem( - label = stringResource(R.string.sessions_filter_code), - enabled = !busy, - onClick = { onCreateAgent("code") }, - modifier = Modifier, - ) - CompactCreateMenuItem( - label = stringResource(R.string.sessions_filter_cowork), - enabled = !busy, - onClick = { onCreateAgent("cowork") }, - modifier = Modifier, - ) - } - } } } @@ -452,7 +497,7 @@ private fun CompactCreateMenuItem( }, onClick = onClick, enabled = enabled, - modifier = modifier.height(42.dp), + modifier = modifier.height(MobileDesignGeometry.CompactPopoverActionHeight), ) } @@ -555,6 +600,11 @@ private fun ProjectTreeHeader(projectCount: Int) { private fun SectionHeader( section: SessionListSection, collapsed: Boolean, + createMenuOpen: Boolean, + onToggleCreateMenu: (() -> Unit)?, + onDismissCreateMenu: () -> Unit, + onCreateAgent: ((String) -> Unit)?, + onCreateAssistant: (() -> Unit)?, onToggle: () -> Unit, ) { val label = when (section) { @@ -601,6 +651,16 @@ private fun SectionHeader( style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) + ProjectCreateControl( + path = section.path, + expanded = createMenuOpen, + onToggle = { onToggleCreateMenu?.invoke() }, + onDismiss = onDismissCreateMenu, + onCreateAgent = { onCreateAgent?.invoke(it) }, + ) + } + if (section is SessionListSection.Chat) { + ChatCreateControl(onCreate = { onCreateAssistant?.invoke() }) } Icon( painterResource( @@ -614,6 +674,68 @@ private fun SectionHeader( } } +@Composable +internal fun ChatCreateControl(onCreate: () -> Unit) { + IconButton( + onClick = onCreate, + modifier = Modifier.size(40.dp).testTag(SESSION_CHAT_CREATE_TEST_TAG), + ) { + Icon( + painterResource(R.drawable.ic_symbol_square_and_pencil), + contentDescription = stringResource(R.string.sidebar_new_chat), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(18.dp), + ) + } +} + +@Composable +internal fun ProjectCreateControl( + path: String, + expanded: Boolean, + onToggle: () -> Unit, + onDismiss: () -> Unit, + onCreateAgent: (String) -> Unit, +) { + Box { + IconButton( + onClick = onToggle, + modifier = Modifier + .size(36.dp) + .testTag(SESSION_PROJECT_CREATE_TEST_TAG_PREFIX + path), + ) { + Icon( + painterResource(R.drawable.ic_symbol_square_and_pencil), + contentDescription = stringResource(R.string.sidebar_new_chat), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(18.dp), + ) + } + DropdownMenu( + expanded = expanded, + onDismissRequest = onDismiss, + modifier = Modifier.width(MobileDesignGeometry.CompactPopoverWidth), + shape = RoundedCornerShape(MobileDesignGeometry.CompactPopoverRadius), + containerColor = MaterialTheme.colorScheme.surface, + tonalElevation = 0.dp, + shadowElevation = 18.dp, + ) { + CompactCreateMenuItem( + label = stringResource(R.string.sessions_filter_code), + enabled = true, + onClick = { onCreateAgent("code") }, + modifier = Modifier, + ) + CompactCreateMenuItem( + label = stringResource(R.string.sessions_filter_cowork), + enabled = true, + onClick = { onCreateAgent("Cowork") }, + modifier = Modifier, + ) + } + } +} + private fun sectionKey(section: SessionListSection): String = when (section) { is SessionListSection.Chat -> "chat" is SessionListSection.Project -> "project:" + section.path @@ -622,6 +744,8 @@ private fun sectionKey(section: SessionListSection): String = when (section) { is SessionListSection.Earlier -> "earlier" } +private const val ASSISTANT_WORKSPACE_KIND = "assistant" + /** * One session in the list, from `RemoteSessionList.ets#SessionRow`. * @@ -652,8 +776,9 @@ private fun SessionRow( selected: Boolean, enabled: Boolean, onOpen: () -> Unit, - onActions: () -> Unit, + onActions: (IntRect) -> Unit, ) { + var anchorBounds by remember { mutableStateOf(IntRect.Zero) } val now = remember(updatedAt) { System.currentTimeMillis() } val relative = remember(updatedAt, now) { SessionTimePresentation.relative(updatedAt, now) } val metadata = listOfNotNull( @@ -670,10 +795,13 @@ private fun SessionRow( .background( if (selected) MaterialTheme.colorScheme.secondaryContainer else Color.Transparent, ) + .onGloballyPositioned { coordinates -> + anchorBounds = coordinates.boundsInWindow().toIntRect() + } .combinedClickable( enabled = enabled, onClick = onOpen, - onLongClick = onActions, + onLongClick = { onActions(anchorBounds) }, ) .padding(start = if (projectChild) 28.dp else 10.dp, end = 4.dp), verticalAlignment = Alignment.CenterVertically, @@ -699,7 +827,7 @@ private fun SessionRow( // The overflow keeps the destructive actions one deliberate tap away, // as `SessionMoreButton` does. Two permanent buttons under every row // made destroying a session as reachable as opening one. - IconButton(onClick = onActions) { + IconButton(onClick = { onActions(anchorBounds) }) { Icon( painterResource(R.drawable.ic_symbol_ellipsis), contentDescription = stringResource(R.string.session_actions), @@ -710,6 +838,13 @@ private fun SessionRow( } } +private fun Rect.toIntRect(): IntRect = IntRect( + left = left.toInt(), + top = top.toInt(), + right = right.toInt(), + bottom = bottom.toInt(), +) + /** Null when the desktop sent nothing readable — see [SessionRowLabel]. */ @Composable internal fun relativeTimeText(relative: RelativeTime): String? = when (relative) { diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/remote/SessionActionSheet.kt b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/remote/SessionActionSheet.kt index 3be8b8156e..5abc5b4a96 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/remote/SessionActionSheet.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/remote/SessionActionSheet.kt @@ -34,6 +34,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -42,11 +43,21 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntRect +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Popup +import androidx.compose.ui.window.PopupPositionProvider +import androidx.compose.ui.window.PopupProperties import com.bitfun.mobile.app.R import com.bitfun.mobile.app.ui.settings.statusText +import com.bitfun.mobile.app.ui.common.AdaptiveModalSurface import com.bitfun.mobile.core.feature.session.SessionActionCapabilities +import com.bitfun.mobile.core.feature.layout.SettingsPlacement import com.bitfun.mobile.core.feature.session.SessionTimePresentation +import com.bitfun.mobile.app.ui.theme.generated.MobileDesignGeometry internal const val SESSION_ACTIONS_TEST_TAG: String = "session-actions" internal const val SESSION_DETAILS_TEST_TAG: String = "session-details" @@ -92,7 +103,10 @@ internal fun SessionActionSheet( ModalBottomSheet( onDismissRequest = onDismiss, sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), - shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp), + shape = RoundedCornerShape( + topStart = MobileDesignGeometry.PopoverRadius, + topEnd = MobileDesignGeometry.PopoverRadius, + ), containerColor = MaterialTheme.colorScheme.surface, tonalElevation = 0.dp, dragHandle = null, @@ -101,7 +115,10 @@ internal fun SessionActionSheet( .border( width = 1.dp, color = MaterialTheme.colorScheme.outlineVariant, - shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp), + shape = RoundedCornerShape( + topStart = MobileDesignGeometry.PopoverRadius, + topEnd = MobileDesignGeometry.PopoverRadius, + ), ), ) { Column( @@ -228,6 +245,161 @@ internal fun SessionActionSheet( } } +/** Wide-sidebar counterpart of [SessionActionSheet]. It renders the same rows + * next to the row that opened it and keeps the native popup lifecycle arrowless. */ +@Composable +internal fun SessionActionPopup( + anchorBounds: IntRect, + title: String, + status: String, + capabilities: SessionActionCapabilities, + onViewDetails: () -> Unit, + onArchive: () -> Unit, + onExport: () -> Unit, + onDelete: () -> Unit, + onDismiss: () -> Unit, +) { + var confirmingDelete by remember { mutableStateOf(false) } + val targetBounds = anchorBounds + val positionProvider = remember(targetBounds) { + object : PopupPositionProvider { + override fun calculatePosition( + anchorBounds: IntRect, + windowSize: IntSize, + layoutDirection: LayoutDirection, + popupContentSize: IntSize, + ): IntOffset { + val desiredX = targetBounds.right + 6 + val desiredY = ( + targetBounds.top + targetBounds.bottom - + popupContentSize.height + ) / 2 + return IntOffset( + x = desiredX.coerceIn( + 8, + (windowSize.width - popupContentSize.width - 8).coerceAtLeast(8), + ), + y = desiredY.coerceIn( + 8, + (windowSize.height - popupContentSize.height - 8).coerceAtLeast(8), + ), + ) + } + } + } + Popup( + popupPositionProvider = positionProvider, + onDismissRequest = onDismiss, + properties = PopupProperties(focusable = true), + ) { + Column( + modifier = Modifier + .width(300.dp) + .shadow(20.dp, RoundedCornerShape(MobileDesignGeometry.PopoverRadius)) + .clip(RoundedCornerShape(MobileDesignGeometry.PopoverRadius)) + .background(MaterialTheme.colorScheme.surface) + .border( + 1.dp, + MaterialTheme.colorScheme.outlineVariant, + RoundedCornerShape(MobileDesignGeometry.PopoverRadius), + ) + .padding(start = 16.dp, end = 16.dp, top = 10.dp, bottom = 18.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth().height(52.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text( + stringResource(R.string.session_actions), + fontSize = 13.sp, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + title.ifBlank { stringResource(R.string.sidebar_untitled) }, + fontSize = 15.sp, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Box( + modifier = Modifier.size(40.dp).clip(RoundedCornerShape(20.dp)).clickable(onClick = onDismiss), + contentAlignment = Alignment.Center, + ) { + Icon( + painterResource(R.drawable.ic_symbol_xmark), + contentDescription = stringResource(R.string.common_close), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(16.dp), + ) + } + } + HorizontalDivider(modifier = Modifier.padding(top = 6.dp, bottom = 8.dp)) + if (confirmingDelete) { + Text( + stringResource(R.string.session_delete_confirm), + fontSize = 13.sp, + lineHeight = 19.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.fillMaxWidth().padding(top = 4.dp), + ) + Row( + modifier = Modifier.fillMaxWidth().padding(top = 12.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + ConfirmationButton( + stringResource(R.string.common_cancel), + MaterialTheme.colorScheme.surfaceVariant, + MaterialTheme.colorScheme.onSurface, + Modifier.weight(1f), + ) { confirmingDelete = false } + ConfirmationButton( + stringResource(R.string.session_delete), + MaterialTheme.colorScheme.error, + MaterialTheme.colorScheme.onError, + Modifier.weight(1f), + ) { + onDelete() + onDismiss() + } + } + } else { + if (capabilities.canViewDetails) { + ActionRow(R.drawable.ic_symbol_info_circle, stringResource(R.string.session_view_details)) { + onViewDetails(); onDismiss() + } + } + if (capabilities.canArchive) { + ActionRow( + R.drawable.ic_symbol_archivebox, + stringResource( + if (status.equals("archived", true)) R.string.session_unarchive + else R.string.session_archive, + ), + ) { onArchive(); onDismiss() } + } + if (capabilities.canExport) { + ActionRow(R.drawable.ic_symbol_cloud, stringResource(R.string.general_chat_export)) { + onExport(); onDismiss() + } + } + if (capabilities.canDelete) { + if (capabilities.canArchive || capabilities.canExport) { + HorizontalDivider(modifier = Modifier.padding(vertical = 6.dp)) + } + ActionRow( + R.drawable.ic_symbol_trash, + stringResource(R.string.session_delete), + destructive = true, + ) { confirmingDelete = true } + } + } + } + } +} + @Composable private fun ConfirmationButton( label: String, @@ -324,6 +496,7 @@ internal fun SessionDetailsSheet( createdAt: String, updatedAt: String, messageCount: Int, + placement: SettingsPlacement, onDismiss: () -> Unit, ) { val now = remember(createdAt, updatedAt) { System.currentTimeMillis() } @@ -334,14 +507,14 @@ internal fun SessionDetailsSheet( remember(updatedAt, now) { SessionTimePresentation.relative(updatedAt, now) }, ) - ModalBottomSheet( + AdaptiveModalSurface( + visible = true, + placement = placement, onDismissRequest = onDismiss, - sheetState = rememberModalBottomSheetState(), - modifier = Modifier.testTag(SESSION_DETAILS_TEST_TAG), - ) { + ) { surfaceModifier -> Column( - modifier = Modifier - .fillMaxWidth() + modifier = surfaceModifier + .testTag(SESSION_DETAILS_TEST_TAG) .padding(start = 20.dp, end = 16.dp, bottom = 24.dp), ) { Row( diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/session/RenameSessionDialog.kt b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/session/RenameSessionDialog.kt deleted file mode 100644 index 72f85da0dc..0000000000 --- a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/session/RenameSessionDialog.kt +++ /dev/null @@ -1,64 +0,0 @@ -package com.bitfun.mobile.app.ui.session - -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.res.stringResource -import com.bitfun.mobile.app.R - -internal const val RENAME_SESSION_TEST_TAG: String = "rename-session" - -/** - * Retitling one conversation. - * - * Owns the edited text itself so callers only have to say which session is being - * renamed: every screen that offers this was otherwise carrying two pieces of - * state — the flag and the buffer — that only ever changed together. - * - * The buffer survives a rotation but a blank one is refused, because clearing the - * field is how a user backs out of a title they no longer want, not a request for - * a nameless session. - */ -@Composable -internal fun RenameSessionDialog( - currentTitle: String, - onConfirm: (String) -> Unit, - onDismiss: () -> Unit, -) { - var text by rememberSaveable(currentTitle) { mutableStateOf(currentTitle) } - AlertDialog( - onDismissRequest = onDismiss, - title = { Text(stringResource(R.string.session_rename)) }, - text = { - OutlinedTextField( - value = text, - onValueChange = { text = it }, - label = { Text(stringResource(R.string.session_rename_label)) }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - }, - confirmButton = { - TextButton( - enabled = text.isNotBlank(), - onClick = { - onConfirm(text) - onDismiss() - }, - ) { Text(stringResource(R.string.session_rename_confirm)) } - }, - dismissButton = { - TextButton(onClick = onDismiss) { Text(stringResource(R.string.common_cancel)) } - }, - modifier = Modifier.testTag(RENAME_SESSION_TEST_TAG), - ) -} diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/settings/GeneralSettingsScreen.kt b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/settings/GeneralSettingsScreen.kt index 34865403e6..fe2db2d1fc 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/settings/GeneralSettingsScreen.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/settings/GeneralSettingsScreen.kt @@ -1,6 +1,7 @@ package com.bitfun.mobile.app.ui.settings import androidx.annotation.DrawableRes +import androidx.activity.compose.BackHandler import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -16,6 +17,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon @@ -30,7 +32,6 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag @@ -43,6 +44,7 @@ import androidx.compose.ui.unit.sp import com.bitfun.mobile.app.R import com.bitfun.mobile.app.platform.AppLocale import com.bitfun.mobile.app.platform.AppLocaleController +import com.bitfun.mobile.app.ui.theme.generated.MobileDesignGeometry import com.bitfun.mobile.core.feature.generalchat.GeneralChatConfigFailure import com.bitfun.mobile.core.feature.generalchat.GeneralChatConfigUi import com.bitfun.mobile.core.feature.generalchat.GeneralChatConnectionTestUi @@ -101,6 +103,11 @@ internal fun GeneralSettingsScreen( val context = LocalContext.current val selectedLocale = AppLocaleController.current(LocalConfiguration.current) + BackHandler(enabled = showLanguagePicker || showModelService) { + showLanguagePicker = false + showModelService = false + } + Box(modifier = modifier.fillMaxSize().testTag(GENERAL_SETTINGS_TEST_TAG)) { Column( modifier = Modifier @@ -123,7 +130,7 @@ internal fun GeneralSettingsScreen( SettingsCard( modifier = Modifier.testTag(GENERAL_SETTINGS_PROFILE_TEST_TAG), - radius = 8, + radius = MobileDesignGeometry.SettingsCompactCardRadius, bordered = false, ) { AccountEntryRow( @@ -138,20 +145,23 @@ internal fun GeneralSettingsScreen( ) } - GeneralSectionTitle(stringResource(R.string.settings_language_section)) - SettingsCard(modifier = Modifier, radius = 8, bordered = false) { - LanguageSettingsRow( - value = when (selectedLocale) { - AppLocale.ENGLISH -> stringResource(R.string.settings_language_english) - AppLocale.SIMPLIFIED_CHINESE -> stringResource(R.string.settings_language_chinese) - }, - onClick = { showLanguagePicker = true }, - ) - } - - GeneralSectionTitle(stringResource(R.string.settings_general_chat_section)) - SettingsCard(modifier = Modifier, radius = 8, bordered = false) { + GeneralSectionTitle(stringResource(R.string.settings_general_section)) + SettingsCard( + modifier = Modifier, + radius = MobileDesignGeometry.SettingsCompactCardRadius, + bordered = false, + ) { Column(modifier = Modifier.padding(vertical = 5.dp)) { + LanguageSettingsRow( + value = when (selectedLocale) { + AppLocale.ENGLISH -> stringResource(R.string.settings_language_english) + AppLocale.SIMPLIFIED_CHINESE -> stringResource(R.string.settings_language_chinese) + }, + onClick = { showLanguagePicker = true }, + ) + HorizontalDivider( + modifier = Modifier.fillMaxWidth(0.84f).align(Alignment.CenterHorizontally), + ) GeneralSettingsRow( icon = R.drawable.ic_symbol_square_grid_2x2, title = stringResource(R.string.model_service_title), @@ -169,7 +179,11 @@ internal fun GeneralSettingsScreen( } GeneralSectionTitle(stringResource(R.string.settings_about_section)) - SettingsCard(modifier = Modifier, radius = 8, bordered = false) { + SettingsCard( + modifier = Modifier, + radius = MobileDesignGeometry.SettingsCompactCardRadius, + bordered = false, + ) { Column(modifier = Modifier.padding(vertical = 5.dp)) { StaticSettingsRow( title = stringResource(R.string.settings_about_product), @@ -284,55 +298,47 @@ private fun LanguagePickerOverlay( onDismiss: () -> Unit, onSelect: (AppLocale) -> Unit, ) { - Box( - modifier = Modifier - .fillMaxSize() - .background(Color.Black.copy(alpha = 0.55f)) - .clickable(onClick = onDismiss), - contentAlignment = Alignment.BottomCenter, + Surface( + color = MaterialTheme.colorScheme.surface, + shape = RoundedCornerShape( + topStart = MobileDesignGeometry.SelectionTopRadius, + topEnd = MobileDesignGeometry.SelectionTopRadius, + ), + modifier = Modifier.fillMaxSize(), ) { - Surface( - color = MaterialTheme.colorScheme.surface, - shape = androidx.compose.foundation.shape.RoundedCornerShape( - topStart = 28.dp, - topEnd = 28.dp, - ), - modifier = Modifier - .fillMaxWidth() - .clickable(onClick = {}) - .defaultMinSize(minHeight = 194.dp), - ) { - Column { - Row( - modifier = Modifier - .fillMaxWidth() - .height(66.dp) - .padding(start = 22.dp, end = 18.dp), - verticalAlignment = Alignment.CenterVertically, + Column(Modifier.fillMaxSize()) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(MobileDesignGeometry.SheetHeaderHeight) + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + stringResource(R.string.settings_language_choose), + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + ) + Spacer(Modifier.weight(1f)) + Surface( + onClick = onDismiss, + shape = androidx.compose.foundation.shape.CircleShape, + color = MaterialTheme.colorScheme.surface, + modifier = Modifier.size(MobileDesignGeometry.SelectionCloseSize), ) { - Text( - stringResource(R.string.settings_language_choose), - fontSize = 21.sp, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurface, - ) - Spacer(Modifier.weight(1f)) - Surface( - onClick = onDismiss, - shape = androidx.compose.foundation.shape.CircleShape, - color = MaterialTheme.colorScheme.surfaceVariant, - modifier = Modifier.size(42.dp), - ) { - Box(contentAlignment = Alignment.Center) { - Icon( - painterResource(R.drawable.ic_symbol_xmark), - contentDescription = stringResource(R.string.common_close), - modifier = Modifier.size(20.dp), - ) - } + Box(contentAlignment = Alignment.Center) { + Icon( + painterResource(R.drawable.ic_symbol_xmark), + contentDescription = stringResource(R.string.common_close), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(18.dp), + ) } } - HorizontalDivider() + } + HorizontalDivider() + Column(Modifier.padding(top = 8.dp, bottom = 28.dp)) { LanguageChoiceRow( label = stringResource(R.string.settings_language_chinese), selected = selected == AppLocale.SIMPLIFIED_CHINESE, @@ -343,7 +349,6 @@ private fun LanguagePickerOverlay( selected = selected == AppLocale.ENGLISH, onClick = { onSelect(AppLocale.ENGLISH) }, ) - Spacer(Modifier.size(28.dp)) } } } @@ -354,7 +359,7 @@ private fun LanguageChoiceRow(label: String, selected: Boolean, onClick: () -> U Row( modifier = Modifier .fillMaxWidth() - .height(64.dp) + .height(MobileDesignGeometry.SelectionRowHeight) .clickable(onClick = onClick) .padding(horizontal = 22.dp), verticalAlignment = Alignment.CenterVertically, @@ -368,7 +373,7 @@ private fun LanguageChoiceRow(label: String, selected: Boolean, onClick: () -> U ) if (selected) { Icon( - painterResource(R.drawable.ic_symbol_checkmark_circle), + painterResource(R.drawable.ic_symbol_list_checkmark), contentDescription = null, tint = MaterialTheme.colorScheme.onSurface, modifier = Modifier.size(18.dp), diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/settings/ModelServiceScreen.kt b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/settings/ModelServiceScreen.kt index c369036cea..36b2c43e34 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/settings/ModelServiceScreen.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/settings/ModelServiceScreen.kt @@ -1,6 +1,7 @@ package com.bitfun.mobile.app.ui.settings import androidx.annotation.DrawableRes +import androidx.activity.compose.BackHandler import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -23,7 +24,6 @@ import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button import androidx.compose.material3.FilledTonalButton -import androidx.compose.material3.FilledTonalIconButton import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -53,6 +53,7 @@ import androidx.compose.ui.unit.dp import com.bitfun.mobile.app.R import com.bitfun.mobile.app.ui.chat.messageRes import com.bitfun.mobile.app.ui.theme.bitFunColors +import com.bitfun.mobile.app.ui.theme.generated.MobileDesignGeometry import com.bitfun.mobile.core.feature.generalchat.GeneralChatConfigFailure import com.bitfun.mobile.core.feature.generalchat.GeneralChatConfigUi import com.bitfun.mobile.core.feature.generalchat.GeneralChatConnectionTestUi @@ -109,46 +110,58 @@ internal fun ModelServiceScreen( onClose: () -> Unit, modifier: Modifier, ) { - var editing by rememberSaveable { mutableStateOf(false) } - // Whatever the last page left behind is not an answer about this one: a - // refused save and a failed probe both belong to the form they happened in. - val leaveEditor = { - editing = false + var page by rememberSaveable { mutableStateOf(ModelServicePage.OVERVIEW) } + val leaveChild = { + page = ModelServicePage.OVERVIEW onIntent(GeneralChatIntent.ClearConfigFailure) onIntent(GeneralChatIntent.ClearConnectionTest) } + BackHandler(enabled = page != ModelServicePage.OVERVIEW, onBack = leaveChild) Column(modifier = modifier.testTag(MODEL_SERVICE_TEST_TAG)) { ModelServiceHeader( title = stringResource( - if (editing) R.string.model_service_local_title else R.string.model_service_manage_title, + when (page) { + ModelServicePage.OVERVIEW -> R.string.model_service_manage_title + ModelServicePage.ACCOUNT -> R.string.model_service_choose_account + ModelServicePage.LOCAL -> R.string.model_service_local_title + }, ), - onBack = if (editing) leaveEditor else null, + onBack = if (page == ModelServicePage.OVERVIEW) null else leaveChild, onClose = onClose, ) - if (editing) { - LocalModelEditor( + when (page) { + ModelServicePage.LOCAL -> LocalModelEditor( config = config, failure = failure, connectionTest = connectionTest, onIntent = onIntent, onSave = onSave, - onSaved = { editing = false }, - // `weight` rather than `fillMaxSize`: the body gets what the - // header left, and the centred page measures itself against that. - // Filling the parent instead would make the column taller than - // the sheet by exactly the height of the header, which pushes the - // content it is centring off the bottom edge. + onSaved = { page = ModelServicePage.OVERVIEW }, + modifier = Modifier.weight(1f), + ) + ModelServicePage.ACCOUNT -> AccountModelSelection( + models = models.filter { it.source == GeneralChatModelSource.ACCOUNT }, + activeModelId = activeModelId, + onSelect = { + onIntent(GeneralChatIntent.SelectModel(it)) + page = ModelServicePage.OVERVIEW + }, modifier = Modifier.weight(1f), ) - } else { - ModelOverview( + ModelServicePage.OVERVIEW -> ModelOverview( config = config, models = models, activeModelId = activeModelId, + onOpenAccount = { page = ModelServicePage.ACCOUNT }, + onSelectLocal = { + models.firstOrNull { it.source == GeneralChatModelSource.LOCAL }?.let { model -> + onIntent(GeneralChatIntent.SelectModel(model.id)) + } + }, onEditLocal = { - editing = true + page = ModelServicePage.LOCAL onIntent(GeneralChatIntent.ClearConfigFailure) onIntent(GeneralChatIntent.ClearConnectionTest) }, @@ -158,6 +171,8 @@ internal fun ModelServiceScreen( } } +private enum class ModelServicePage { OVERVIEW, ACCOUNT, LOCAL } + /** * Back, title, close — the one row that stays put while the page under it swaps. * @@ -170,15 +185,15 @@ private fun ModelServiceHeader(title: String, onBack: (() -> Unit)?, onClose: () Row( modifier = Modifier .fillMaxWidth() - .height(66.dp) - .padding(start = 22.dp, end = 18.dp), + .height(MobileDesignGeometry.SheetHeaderHeight) + .padding(start = 16.dp, end = 16.dp), verticalAlignment = Alignment.CenterVertically, ) { onBack?.let { back -> - FilledTonalIconButton( + IconButton( onClick = back, modifier = Modifier - .padding(end = 12.dp) + .padding(end = 8.dp) .size(42.dp) .testTag(MODEL_SERVICE_BACK_TEST_TAG), ) { @@ -191,7 +206,7 @@ private fun ModelServiceHeader(title: String, onBack: (() -> Unit)?, onClose: () } Text( title, - style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.Bold), + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), color = MaterialTheme.colorScheme.onSurface, maxLines = 1, overflow = TextOverflow.Ellipsis, @@ -200,14 +215,15 @@ private fun ModelServiceHeader(title: String, onBack: (() -> Unit)?, onClose: () // that had room to spare. modifier = Modifier.weight(1f).padding(end = 12.dp), ) - FilledTonalIconButton( + IconButton( onClick = onClose, - modifier = Modifier.size(42.dp).testTag(MODEL_SERVICE_CLOSE_TEST_TAG), + modifier = Modifier.size(MobileDesignGeometry.SelectionCloseSize) + .testTag(MODEL_SERVICE_CLOSE_TEST_TAG), ) { Icon( painterResource(R.drawable.ic_symbol_xmark), contentDescription = stringResource(R.string.common_close), - modifier = Modifier.size(20.dp), + modifier = Modifier.size(18.dp), ) } } @@ -220,21 +236,29 @@ private fun ModelOverview( config: GeneralChatConfigUi, models: List, activeModelId: String, + onOpenAccount: () -> Unit, + onSelectLocal: () -> Unit, onEditLocal: () -> Unit, modifier: Modifier, ) { - // "Complete" rather than "has a model name": a model with no endpoint or no - // key cannot answer, so naming it the current model would be a claim the - // first message disproves. val complete = config.baseUrl.isNotBlank() && config.model.isNotBlank() && config.hasApiKey val notConfigured = stringResource(R.string.model_service_not_configured) val localSource = stringResource(R.string.model_service_local_source) val accountSource = stringResource(R.string.model_service_account_source) val active = models.firstOrNull { it.id == activeModelId } - val accountModels = models.count { it.source == GeneralChatModelSource.ACCOUNT } - val accountActive = active?.source == GeneralChatModelSource.ACCOUNT + val accountModels = models.filter { it.source == GeneralChatModelSource.ACCOUNT } - CentredPage(modifier = modifier) { + Column( + modifier = modifier + .verticalScroll(rememberScrollState()) + .padding( + start = 16.dp, + end = 16.dp, + top = MobileDesignGeometry.ModelOverviewTopPadding, + bottom = MobileDesignGeometry.ModelOverviewBottomPadding, + ), + verticalArrangement = Arrangement.spacedBy(MobileDesignGeometry.ModelSectionGap), + ) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { OverviewSectionHeader(stringResource(R.string.model_service_current)) OverviewRow( @@ -254,7 +278,7 @@ private fun ModelOverview( GeneralChatModelSource.ACCOUNT -> accountSource null -> "" }, - minHeight = 68, + minHeight = MobileDesignGeometry.ModelCurrentRowHeight.value.toInt(), selected = false, chevron = false, onClick = null, @@ -263,53 +287,167 @@ private fun ModelOverview( } Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - OverviewSectionHeader(stringResource(R.string.model_service_account_section)) - OverviewRow( - icon = R.drawable.ic_symbol_cloud, - iconSize = 21, - // Dimmed to `outline` when nothing synced, which is the source's - // SUBTLE: the row is there either way, and the icon is what says - // whether it has anything behind it. - iconTint = if (accountModels > 0) { - MaterialTheme.colorScheme.onSurfaceVariant - } else { - MaterialTheme.colorScheme.outline - }, - title = stringResource(R.string.model_service_account_summary), - subtitle = if (accountModels > 0) { - stringResource(R.string.model_service_account_synced, accountModels) - } else { - stringResource(R.string.model_service_account_empty) - }, - minHeight = 62, - selected = accountActive, - chevron = false, - // Not tappable, as the source has it: these models are defined on - // the desktop and chosen in the composer, so there is nothing this - // panel could open that the user could act on. - onClick = null, - modifier = Modifier.testTag(MODEL_SERVICE_ACCOUNT_TEST_TAG), - ) + OverviewSectionHeader(stringResource(R.string.model_service_sources)) + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(MobileDesignGeometry.SettingsCompactCardRadius)) + .background(MaterialTheme.colorScheme.surfaceVariant), + ) { + ModelSourceRow( + icon = R.drawable.ic_symbol_cloud, + title = stringResource(R.string.model_service_account_summary), + subtitle = if (accountModels.isEmpty()) { + stringResource(R.string.model_service_account_empty) + } else { + stringResource(R.string.model_service_account_synced, accountModels.size) + }, + onBodyClick = onOpenAccount, + onChevronClick = onOpenAccount, + modifier = Modifier.testTag(MODEL_SERVICE_ACCOUNT_TEST_TAG), + ) + HorizontalDivider(modifier = Modifier.padding(start = 56.dp)) + ModelSourceRow( + icon = R.drawable.ic_symbol_wrench_and_screwdriver, + title = if (complete) config.model else notConfigured, + subtitle = if (complete) localSource else "", + onBodyClick = if (complete) onSelectLocal else onEditLocal, + onChevronClick = onEditLocal, + modifier = Modifier.testTag(MODEL_SERVICE_LOCAL_TEST_TAG), + ) + } } + } +} - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - OverviewSectionHeader(stringResource(R.string.model_service_local_section)) - OverviewRow( - icon = R.drawable.ic_symbol_wrench_and_screwdriver, - iconSize = 23, - iconTint = MaterialTheme.colorScheme.onSurfaceVariant, - title = if (complete) config.model else notConfigured, - subtitle = if (complete) localSource else "", - minHeight = 62, - selected = active?.source == GeneralChatModelSource.LOCAL, - chevron = true, - onClick = onEditLocal, - modifier = Modifier.testTag(MODEL_SERVICE_LOCAL_TEST_TAG), +@Composable +private fun ModelSourceRow( + @DrawableRes icon: Int, + title: String, + subtitle: String, + onBodyClick: () -> Unit, + onChevronClick: () -> Unit, + modifier: Modifier, +) { + Row(modifier = modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Row( + modifier = Modifier + .weight(1f) + .height(MobileDesignGeometry.ModelSourceRowHeight) + .clickable(onClick = onBodyClick) + .padding(start = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon( + painterResource(icon), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(22.dp), + ) + Column(verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text( + title, + style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Medium), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (subtitle.isNotEmpty()) { + Text( + subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + ) + } + } + } + IconButton( + onClick = onChevronClick, + modifier = Modifier.size(44.dp), + ) { + Icon( + painterResource(R.drawable.ic_symbol_chevron_right), + contentDescription = stringResource(R.string.common_open), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(16.dp), ) } } } +@Composable +private fun AccountModelSelection( + models: List, + activeModelId: String, + onSelect: (String) -> Unit, + modifier: Modifier, +) { + if (models.isEmpty()) { + Text( + stringResource(R.string.model_service_account_empty), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = modifier + .fillMaxWidth() + .heightIn(min = MobileDesignGeometry.ModelEmptyAccountHeight) + .padding(horizontal = 16.dp, vertical = MobileDesignGeometry.ModelListTopPadding), + ) + return + } + Column( + modifier = modifier + .verticalScroll(rememberScrollState()) + .padding( + start = 10.dp, + end = 10.dp, + top = MobileDesignGeometry.ModelListTopPadding, + bottom = MobileDesignGeometry.ModelListBottomPadding, + ), + verticalArrangement = Arrangement.spacedBy(MobileDesignGeometry.ModelAccountRowGap), + ) { + models.forEach { model -> + Row( + modifier = Modifier + .fillMaxWidth() + .height(MobileDesignGeometry.ModelAccountRowHeight) + .clip(RoundedCornerShape(9.dp)) + .background( + if (model.id == activeModelId) MaterialTheme.colorScheme.surfaceVariant + else Color.Transparent, + ) + .clickable { onSelect(model.id) } + .padding(horizontal = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Box(modifier = Modifier.size(20.dp), contentAlignment = Alignment.Center) { + if (model.id == activeModelId) { + Icon( + painterResource(R.drawable.ic_symbol_checkmark_circle), + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + } + } + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + model.label, + style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Medium), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + stringResource(R.string.model_service_account_source), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } +} + /** * The body both pages share: centred when it fits, scrolled when it does not. * diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/settings/PermissionModeCard.kt b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/settings/PermissionModeCard.kt index a7b4d721c4..31aaa5cd33 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/settings/PermissionModeCard.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/settings/PermissionModeCard.kt @@ -34,6 +34,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.bitfun.mobile.app.R +import com.bitfun.mobile.app.ui.theme.generated.MobileDesignGeometry import com.bitfun.mobile.core.feature.session.PermissionModeFailure import com.bitfun.mobile.core.feature.session.RemoteSessionIntent import com.bitfun.mobile.core.feature.session.RemoteSessionUiState @@ -91,7 +92,10 @@ internal fun PermissionSection( } } - SettingsCard(modifier = Modifier, radius = 28) { + SettingsCard( + modifier = Modifier, + radius = MobileDesignGeometry.SettingsProminentCardRadius, + ) { Text( stringResource(R.string.permission_scope), style = MaterialTheme.typography.bodySmall, diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/settings/SettingsChrome.kt b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/settings/SettingsChrome.kt index ca24ce762d..10b837a2a5 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/settings/SettingsChrome.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/settings/SettingsChrome.kt @@ -30,7 +30,9 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.Dp import com.bitfun.mobile.app.R +import com.bitfun.mobile.app.ui.theme.generated.MobileDesignGeometry /** * The pieces every section of the remote-control settings page is built from. @@ -70,12 +72,12 @@ internal fun SettingsSectionHeader(text: String, modifier: Modifier) { @Composable internal fun SettingsCard( modifier: Modifier, - radius: Int = 24, + radius: Dp = MobileDesignGeometry.SettingsCardRadius, bordered: Boolean = false, content: @Composable ColumnScope.() -> Unit, ) { Card( - shape = RoundedCornerShape(radius.dp), + shape = RoundedCornerShape(radius), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), border = if (bordered) { BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant) diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/shell/BitFunCompactDrawer.kt b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/shell/BitFunCompactDrawer.kt index ac84d8ce65..83bbe020c7 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/shell/BitFunCompactDrawer.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/shell/BitFunCompactDrawer.kt @@ -1,7 +1,7 @@ package com.bitfun.mobile.app.ui.shell import androidx.activity.compose.BackHandler -import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.foundation.background @@ -14,13 +14,16 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.TransformOrigin import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.bitfun.mobile.app.ui.theme.BitFunEaseOut @@ -29,11 +32,19 @@ import com.bitfun.mobile.app.ui.theme.MotionDrawerHideMillis import com.bitfun.mobile.app.ui.theme.MotionDrawerOpenMillis import com.bitfun.mobile.app.ui.theme.MotionDrawerRevealMillis import com.bitfun.mobile.app.ui.theme.MotionDrawerScrimMillis +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch + +private const val CONTENT_SCALE_X = 0.985f +private const val CONTENT_SCALE_Y = 0.992f +private const val CONTENT_RADIUS_DP = 28 +private const val CONTENT_ELEVATION_DP = 18 /** Compact app shell motion shared with `AppShell.ets`. */ @Composable internal fun BitFunCompactDrawer( open: Boolean, + compact: Boolean, drawerWidth: Dp, onDismiss: () -> Unit, drawerContent: @Composable BoxScope.() -> Unit, @@ -41,82 +52,130 @@ internal fun BitFunCompactDrawer( ) { BackHandler(enabled = open, onBack = onDismiss) - val drawerDuration = if (open) MotionDrawerRevealMillis else MotionDrawerHideMillis val contentDuration = if (open) MotionDrawerOpenMillis else MotionDrawerCloseMillis - val drawerProgress by animateFloatAsState( + // Translation, scale and corner radius share one progress value so they stay + // in lockstep and the whole motion runs as layer-property updates instead of + // recomposing the content once per frame. + val contentProgress by animateFloatAsState( targetValue = if (open) 1f else 0f, - animationSpec = tween(drawerDuration, easing = BitFunEaseOut), - label = "drawer-reveal", - ) - val contentOffset by animateDpAsState( - targetValue = if (open) drawerWidth else 0.dp, - animationSpec = tween(contentDuration, easing = BitFunEaseOut), - label = "drawer-content-offset", - ) - val contentScaleX by animateFloatAsState( - targetValue = if (open) 0.985f else 1f, - animationSpec = tween(contentDuration, easing = BitFunEaseOut), - label = "drawer-content-scale-x", - ) - val contentScaleY by animateFloatAsState( - targetValue = if (open) 0.992f else 1f, animationSpec = tween(contentDuration, easing = BitFunEaseOut), - label = "drawer-content-scale-y", - ) - val contentRadius by animateDpAsState( - targetValue = if (open) 28.dp else 0.dp, - animationSpec = tween(contentDuration, easing = BitFunEaseOut), - label = "drawer-content-radius", - ) - val scrimAlpha by animateFloatAsState( - targetValue = if (open) 0.62f else 0f, - animationSpec = tween(MotionDrawerScrimMillis, easing = BitFunEaseOut), - label = "drawer-scrim", + label = "drawer-content-progress", ) val density = LocalDensity.current + val drawerWidthPx = with(density) { drawerWidth.toPx() } + val contentRadiusPx = with(density) { CONTENT_RADIUS_DP.dp.toPx() } + val contentElevationPx = with(density) { CONTENT_ELEVATION_DP.dp.toPx() } val interactionSource = remember { MutableInteractionSource() } + // The drawer and scrim run on `Animatable` so their reveal/hide values are + // read only inside `graphicsLayer` blocks (layer-only updates, no relayout or + // recomposition). Composition is gated by plain booleans that flip only at + // open/close boundaries, so the screen is not recomposed every frame. + // + // The drawer's content (sessions, devices, workspaces) is the expensive half + // of the open animation, not the geometry. Re-composing it on every open is + // the connected-state jank, so once it has been shown it stays composed and is + // hidden off-screen with its semantics cleared instead of being disposed. That + // is only safe while this compact drawer owns the sidebar; a wide layout has a + // permanent sidebar, so the resident copy is dropped when `compact` turns off. + val drawerProgress = remember { Animatable(0f) } + val scrimProgress = remember { Animatable(0f) } + var drawerComposed by remember { mutableStateOf(open) } + var drawerRevealed by remember { mutableStateOf(open) } + var scrimComposed by remember { mutableStateOf(open) } + + LaunchedEffect(open, compact) { + if (!compact) { + // Wide layout: the permanent sidebar owns this role. Drop any resident + // copy so the window does not end up with a second, hidden Sidebar. + drawerProgress.snapTo(0f) + scrimProgress.snapTo(0f) + drawerComposed = false + drawerRevealed = false + scrimComposed = false + } else if (open) { + drawerComposed = true + drawerRevealed = true + scrimComposed = true + coroutineScope { + launch { + drawerProgress.animateTo( + targetValue = 1f, + animationSpec = tween(MotionDrawerRevealMillis, easing = BitFunEaseOut), + ) + } + launch { + scrimProgress.animateTo( + targetValue = 0.62f, + animationSpec = tween(MotionDrawerScrimMillis, easing = BitFunEaseOut), + ) + } + } + } else if (drawerComposed) { + coroutineScope { + launch { + drawerProgress.animateTo( + targetValue = 0f, + animationSpec = tween(MotionDrawerHideMillis, easing = BitFunEaseOut), + ) + } + launch { + scrimProgress.animateTo( + targetValue = 0f, + animationSpec = tween(MotionDrawerScrimMillis, easing = BitFunEaseOut), + ) + } + } + drawerRevealed = false + scrimComposed = false + } + } + Box(Modifier.fillMaxSize()) { - if (open || drawerProgress > 0.001f) { + if (compact && drawerComposed) { Box( modifier = Modifier .width(drawerWidth) .fillMaxHeight() .graphicsLayer { - alpha = drawerProgress - translationX = with(density) { drawerWidth.toPx() } * -0.1f * (1f - drawerProgress) - }, + alpha = drawerProgress.value + translationX = if (drawerRevealed) { + drawerWidthPx * -0.1f * (1f - drawerProgress.value) + } else { + -(drawerWidthPx + 1f) + } + } + .then( + if (drawerRevealed) { + Modifier + } else { + Modifier.clearAndSetSemantics {} + }, + ), content = drawerContent, ) } - val shape = RoundedCornerShape(contentRadius) Box( modifier = Modifier .fillMaxSize() .graphicsLayer { - translationX = with(density) { contentOffset.toPx() } - scaleX = contentScaleX - scaleY = contentScaleY - transformOrigin = androidx.compose.ui.graphics.TransformOrigin(0f, 0.5f) - } - .shadow( - elevation = if (open || contentOffset > 0.dp) 18.dp else 0.dp, - shape = shape, - clip = false, - ) - .clip(shape), + translationX = drawerWidthPx * contentProgress + scaleX = 1f - (1f - CONTENT_SCALE_X) * contentProgress + scaleY = 1f - (1f - CONTENT_SCALE_Y) * contentProgress + transformOrigin = TransformOrigin(0f, 0.5f) + shape = RoundedCornerShape(contentRadiusPx * contentProgress) + clip = true + shadowElevation = if (open || contentProgress > 0f) contentElevationPx else 0f + }, ) { content() - if (open || scrimAlpha > 0.001f) { + if (scrimComposed) { Box( Modifier .fillMaxSize() - .background( - androidx.compose.material3.MaterialTheme.colorScheme.background.copy( - alpha = scrimAlpha, - ), - ) + .background(androidx.compose.material3.MaterialTheme.colorScheme.background) + .graphicsLayer { alpha = scrimProgress.value } .clickable( enabled = open, interactionSource = interactionSource, diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/shell/MobileScreen.kt b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/shell/MobileScreen.kt index 8819464087..2aaa7bef74 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/shell/MobileScreen.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/shell/MobileScreen.kt @@ -2,13 +2,6 @@ package com.bitfun.mobile.app.ui.shell import android.content.Intent import androidx.activity.compose.BackHandler -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.FastOutSlowInEasing -import androidx.compose.animation.core.tween -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.scaleIn -import androidx.compose.animation.scaleOut import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box @@ -24,11 +17,8 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawingPadding import androidx.compose.foundation.layout.union import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.PermanentDrawerSheet import androidx.compose.material3.Scaffold import androidx.compose.material3.ScaffoldDefaults @@ -43,11 +33,12 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.LayoutDirection import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.bitfun.mobile.app.R @@ -57,8 +48,10 @@ import com.bitfun.mobile.app.state.SettingsMode import com.bitfun.mobile.app.state.rememberAppShellState import com.bitfun.mobile.app.ui.account.AccountScreen import com.bitfun.mobile.app.ui.chat.GeneralChatScreen -import com.bitfun.mobile.app.ui.remote.FilePreviewSurface +import com.bitfun.mobile.app.ui.common.AdaptiveModalSurface import com.bitfun.mobile.app.ui.remote.AccountRemoteScreen +import com.bitfun.mobile.app.ui.remote.ConnectAccountDeviceScreen +import com.bitfun.mobile.app.ui.remote.FilePreviewSurface import com.bitfun.mobile.app.ui.remote.PairingScreen import com.bitfun.mobile.app.ui.settings.GeneralSettingsScreen import com.bitfun.mobile.app.ui.settings.SettingsScreen @@ -75,8 +68,11 @@ import com.bitfun.mobile.core.feature.connection.allowsRemoteCommands import com.bitfun.mobile.core.feature.connection.connectionPhase import com.bitfun.mobile.core.feature.generalchat.GeneralChatIntent import com.bitfun.mobile.core.feature.layout.ConversationLayoutPolicy +import com.bitfun.mobile.core.feature.layout.AdaptiveLayoutInput import com.bitfun.mobile.core.feature.layout.FilePreviewPlacement import com.bitfun.mobile.core.feature.layout.FilePreviewPlacementPolicy +import com.bitfun.mobile.core.feature.layout.SettingsPlacementPolicy +import com.bitfun.mobile.core.feature.layout.SettingsSheetKind import com.bitfun.mobile.core.feature.pairing.PairingIntent import com.bitfun.mobile.core.feature.pairing.PairingUiState import com.bitfun.mobile.core.feature.session.RemoteSessionUiState @@ -155,6 +151,9 @@ internal fun MobileScreen() { val pairingPhase: ConnectionPhase = pairingState.connectionPhase() val readyAccount = accountState as? AccountUiState.Ready val accountUserId = readyAccount?.userId + LaunchedEffect(pairingState) { + if (pairingState is PairingUiState.Paired) shell.closeRemoteScanner() + } // Which desktop this phone is driving is the one fact neither store holds on // its own: the pairing store knows a room, the account store knows a device, @@ -279,6 +278,29 @@ internal fun MobileScreen() { isHover = window.isHoverLayout, ) val geometry = ConversationLayoutPolicy.resolveWideGeometry(window.widthDp, layoutCreases) + val adaptiveLayoutInput = AdaptiveLayoutInput( + viewportWidth = window.widthDp, + viewportHeight = window.heightDp, + isFolded = window.isFolded, + isExpandedFoldable = window.isExpandedFoldable, + isHoverOperate = window.isHoverLayout, + wideLayoutMatched = window.wideViewportMatched, + verticalCreases = window.creases, + horizontalCreases = window.horizontalCreases, + isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl, + ) + val settingsPlacement = SettingsPlacementPolicy.resolve( + adaptiveLayoutInput, + SettingsSheetKind.SETTINGS, + ) + val sessionDetailsPlacement = SettingsPlacementPolicy.resolve( + adaptiveLayoutInput, + SettingsSheetKind.SESSION_DETAILS, + ) + val remoteViewSettingsPlacement = SettingsPlacementPolicy.resolve( + adaptiveLayoutInput, + SettingsSheetKind.REMOTE_VIEW_SETTINGS, + ) // A file the agent referenced is the third thing that wants the window, and // the one that decides whether the other two still fit. Only the remote @@ -311,13 +333,12 @@ internal fun MobileScreen() { val sidebar: @Composable () -> Unit = { AppSidebar( + permanent = sidebarWidth > 0, + sessionDetailsPlacement = sessionDetailsPlacement, accountUserId = accountUserId, connectionPhase = phase, - remoteDevices = if (controlSummary.source == RemoteControlSource.ACCOUNT_DEVICE) { - readyAccount?.devices.orEmpty() - } else { - emptyList() - }, + remoteControlSource = controlSummary.source, + remoteDevices = readyAccount?.devices.orEmpty(), remoteSelectedDeviceId = readyAccount?.selectedDeviceId .takeIf { controlSummary.source == RemoteControlSource.ACCOUNT_DEVICE }, remoteDeviceName = controlSummary.desktopName, @@ -335,9 +356,9 @@ internal fun MobileScreen() { searchOpen = shell.searchOpen, onQueryChange = shell::search, onToggleSearch = shell::toggleSearch, - onEnterCode = { - shell.show(MobileSurface.REMOTE) - shell.closeRemoteSession() + onScanDesktop = { + pairingViewModel.dispatch(PairingIntent.Disconnect) + shell.openRemoteScanner() closeDrawer() }, onRetryRemoteDevice = { @@ -355,8 +376,16 @@ internal fun MobileScreen() { closeDrawer() }, onCreateRemoteInWorkspace = { path -> - dispatchActiveWorkspace(RemoteWorkspaceIntent.SelectWorkspace(path)) - shell.createRemoteSession() + dispatchActiveSession( + RemoteSessionIntent.CreateSession( + agentType = "code", + title = "", + instruction = "", + modelId = null, + workspacePath = path, + ), + ) + shell.show(MobileSurface.REMOTE) closeDrawer() }, onOpenRemoteWorkspace = { path -> @@ -425,20 +454,57 @@ internal fun MobileScreen() { when (shell.surface) { MobileSurface.GENERAL_CHAT -> GeneralChatScreen( modifier = Modifier, + modelServicePlacement = settingsPlacement, onOpenSidebar = if (showMenu) { { compactDrawerOpen = true } } else { null }, ) - MobileSurface.REMOTE -> when (controlSummary.source) { + MobileSurface.REMOTE -> if (shell.remoteScanRequested) { + PairingScreen( + modifier = Modifier, + settingsPlacement = settingsPlacement, + sessionDetailsPlacement = sessionDetailsPlacement, + viewSettingsPlacement = remoteViewSettingsPlacement, + onOpenRemoteSettings = { shell.openSettings(SettingsMode.REMOTE) }, + onOpenSidebar = if (showMenu) { + { compactDrawerOpen = true } + } else { + null + }, + onBack = { + shell.closeRemoteScanner() + shell.show(MobileSurface.GENERAL_CHAT) + }, + onOpenAccount = { + shell.closeRemoteScanner() + shell.openAccount() + }, + compact = !wide, + startScanning = true, + ) + } else when (controlSummary.source) { RemoteControlSource.ACCOUNT_DEVICE -> AccountRemoteScreen( remoteState = accountRemoteState, workspaceState = accountWorkspaceState, deviceId = readyAccount?.selectedDeviceId.orEmpty(), deviceName = controlSummary.desktopName, + createDevices = readyAccount?.devices.orEmpty().map { device -> + com.bitfun.mobile.app.ui.remote.CreateDeviceChoice( + id = device.id, + name = device.name, + online = device.online, + selected = device.id == readyAccount?.selectedDeviceId, + ) + }, accountUsername = readyAccount?.username.orEmpty(), phase = accountPhase, + settingsPlacement = settingsPlacement, + sessionDetailsPlacement = sessionDetailsPlacement, + viewSettingsPlacement = remoteViewSettingsPlacement, + onOpenRemoteSettings = { shell.openSettings(SettingsMode.REMOTE) }, + onCreateDevicePick = accountViewModel::selectDevice, onSessionIntent = accountViewModel::dispatchSession, onWorkspaceIntent = accountViewModel::dispatchWorkspace, onOpenSidebar = if (showMenu) { @@ -455,16 +521,19 @@ internal fun MobileScreen() { modifier = Modifier, ) - RemoteControlSource.QR_PAIRING, - RemoteControlSource.NONE, - -> PairingScreen( + RemoteControlSource.QR_PAIRING -> PairingScreen( modifier = Modifier, + settingsPlacement = settingsPlacement, + sessionDetailsPlacement = sessionDetailsPlacement, + viewSettingsPlacement = remoteViewSettingsPlacement, + onOpenRemoteSettings = { shell.openSettings(SettingsMode.REMOTE) }, onOpenSidebar = if (showMenu) { { compactDrawerOpen = true } } else { null }, onBack = { shell.show(MobileSurface.GENERAL_CHAT) }, + onOpenAccount = { shell.openAccount() }, compact = !wide, requestedSessionId = shell.remoteSessionId, creatingSession = shell.remoteCreating, @@ -472,6 +541,41 @@ internal fun MobileScreen() { onCreateSession = shell::createRemoteSession, onRemoteHome = shell::closeRemoteSession, ) + + RemoteControlSource.NONE -> if (readyAccount != null) { + ConnectAccountDeviceScreen( + state = readyAccount, + onBack = { shell.show(MobileSurface.GENERAL_CHAT) }, + onRefresh = { accountViewModel.dispatch(AccountIntent.RefreshDevices) }, + onSelect = accountViewModel::selectDevice, + onOpenScanner = { + pairingViewModel.dispatch(PairingIntent.Disconnect) + shell.openRemoteScanner() + }, + modifier = Modifier, + ) + } else { + PairingScreen( + modifier = Modifier, + settingsPlacement = settingsPlacement, + sessionDetailsPlacement = sessionDetailsPlacement, + viewSettingsPlacement = remoteViewSettingsPlacement, + onOpenRemoteSettings = { shell.openSettings(SettingsMode.REMOTE) }, + onOpenSidebar = if (showMenu) { + { compactDrawerOpen = true } + } else { + null + }, + onBack = { shell.show(MobileSurface.GENERAL_CHAT) }, + onOpenAccount = { shell.openAccount() }, + compact = !wide, + requestedSessionId = shell.remoteSessionId, + creatingSession = shell.remoteCreating, + onOpenSession = shell::openRemoteSession, + onCreateSession = shell::createRemoteSession, + onRemoteHome = shell::closeRemoteSession, + ) + } } } } @@ -499,6 +603,7 @@ internal fun MobileScreen() { } BitFunCompactDrawer( open = showMenu && compactDrawerOpen, + compact = showMenu, drawerWidth = compactDrawerWidth.dp, onDismiss = ::closeDrawer, drawerContent = { @@ -626,69 +731,26 @@ internal fun MobileScreen() { }, onConnectByLink = { shell.dismissSettings() - shell.show(MobileSurface.REMOTE) + pairingViewModel.dispatch(PairingIntent.Disconnect) + shell.openRemoteScanner() }, ) } } - if (wide) { - AnimatedVisibility( - visible = shell.showSettings, - enter = fadeIn(tween(180, easing = FastOutSlowInEasing)) + - scaleIn(tween(180, easing = FastOutSlowInEasing), initialScale = 0.97f), - exit = fadeOut(tween(160, easing = FastOutSlowInEasing)) + - scaleOut(tween(160, easing = FastOutSlowInEasing), targetScale = 0.97f), - ) { - val sheetWidth = minOf(680, maxOf(540, (window.widthDp * 0.48f).toInt())) - val sheetHeight = minOf(760, maxOf(560, window.heightDp - 80)) - BackHandler(onBack = shell::dismissSettings) - Box( - modifier = Modifier.fillMaxSize().safeDrawingPadding(), - contentAlignment = Alignment.Center, - ) { - Box( - modifier = Modifier - .fillMaxSize() - .background(Color.Black.copy(alpha = 0.27f)) - .clickable(onClick = shell::dismissSettings), - ) - Surface( - color = MaterialTheme.colorScheme.background, - shape = RoundedCornerShape(30.dp), - shadowElevation = 12.dp, - modifier = Modifier - .width(sheetWidth.dp) - .height(sheetHeight.dp) - .clickable(interactionSource = null, indication = null, onClick = {}), - ) { - settingsContent(Modifier.fillMaxSize()) - } - } - } - } else if (shell.showSettings) { - ModalBottomSheet( - onDismissRequest = shell::dismissSettings, - // Compact sheets open fully: the page gives no indication that - // lower controls are hidden behind another upward drag. - sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), - containerColor = MaterialTheme.colorScheme.background, - shape = RoundedCornerShape(topStart = 34.dp, topEnd = 34.dp), - dragHandle = null, - ) { - settingsContent(Modifier.fillMaxWidth().fillMaxHeight(0.94f)) - } - } - if (shell.showAccount) { - ModalBottomSheet( - onDismissRequest = shell::dismissAccount, - sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), - containerColor = MaterialTheme.colorScheme.background, - shape = RoundedCornerShape(topStart = 34.dp, topEnd = 34.dp), - dragHandle = null, - ) { - AccountScreen( - modifier = Modifier.fillMaxWidth().fillMaxHeight(0.94f), + AdaptiveModalSurface( + visible = shell.showSettings, + placement = settingsPlacement, + onDismissRequest = shell::dismissSettings, + content = settingsContent, + ) + AdaptiveModalSurface( + visible = shell.showAccount, + placement = settingsPlacement, + onDismissRequest = shell::dismissAccount, + ) { modifier -> + AccountScreen( + modifier = modifier, onBack = shell::dismissAccount, onDeviceSelected = { shell.dismissAccount() @@ -698,6 +760,5 @@ internal fun MobileScreen() { }, viewModel = accountViewModel, ) - } } } diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/shell/sidebar/AppSidebar.kt b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/shell/sidebar/AppSidebar.kt index f35964619e..263d3e3a48 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/shell/sidebar/AppSidebar.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/shell/sidebar/AppSidebar.kt @@ -25,6 +25,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.IntRect import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -32,13 +33,16 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.bitfun.mobile.app.R +import com.bitfun.mobile.app.ui.remote.SessionActionPopup import com.bitfun.mobile.app.ui.remote.SessionActionSheet import com.bitfun.mobile.app.ui.remote.SessionDetailsSheet import com.bitfun.mobile.core.feature.account.AccountDeviceUi import com.bitfun.mobile.core.feature.connection.ConnectionPhase +import com.bitfun.mobile.core.feature.connection.RemoteControlSource import com.bitfun.mobile.core.feature.session.SessionActionPolicy import com.bitfun.mobile.core.feature.session.SessionActionScope import com.bitfun.mobile.core.feature.session.RemoteSessionUiState +import com.bitfun.mobile.core.feature.layout.SettingsPlacement import com.bitfun.mobile.core.feature.shell.SidebarPresentation import com.bitfun.mobile.core.feature.shell.SidebarSessionRow import com.bitfun.mobile.core.feature.workspace.RemoteWorkspaceUiState @@ -62,8 +66,11 @@ internal const val SIDEBAR_CODE_TEST_TAG: String = "app-sidebar-code" */ @Composable internal fun AppSidebar( + permanent: Boolean, + sessionDetailsPlacement: SettingsPlacement, accountUserId: String?, connectionPhase: ConnectionPhase, + remoteControlSource: RemoteControlSource, remoteDevices: List, remoteSelectedDeviceId: String?, remoteDeviceName: String, @@ -77,7 +84,7 @@ internal fun AppSidebar( searchOpen: Boolean, onQueryChange: (String) -> Unit, onToggleSearch: () -> Unit, - onEnterCode: () -> Unit, + onScanDesktop: () -> Unit, onRetryRemoteDevice: () -> Unit, onSelectRemoteDevice: (String) -> Unit, onOpenRemoteSession: (String) -> Unit, @@ -98,6 +105,7 @@ internal fun AppSidebar( // Ids rather than rows: the list behind these sheets keeps updating while // they are open, and a captured row would go stale the moment a reply lands. var actionSessionId by rememberSaveable { mutableStateOf(null) } + var actionAnchor by remember { mutableStateOf(IntRect.Zero) } var detailsSessionId by rememberSaveable { mutableStateOf(null) } var archivedExpanded by rememberSaveable { mutableStateOf(false) } @@ -122,17 +130,21 @@ internal fun AppSidebar( archivedExpanded = archivedExpanded, onToggleArchived = { archivedExpanded = !archivedExpanded }, onOpenSession = onOpenSession, - onOpenActions = { actionSessionId = it.id }, + onOpenActions = { session, anchor -> + actionAnchor = anchor + actionSessionId = session.id + }, workspaceContent = { SidebarRemoteWorkspaceSection( connectionPhase = connectionPhase, + controlSource = remoteControlSource, devices = remoteDevices, selectedDeviceId = remoteSelectedDeviceId, deviceName = remoteDeviceName, remoteState = remoteState, workspaceState = workspaceState, selectedSessionId = remoteSelectedSessionId.takeIf { remoteActive }, - onConnect = onEnterCode, + onConnect = onScanDesktop, onRetryActive = onRetryRemoteDevice, onSelectDevice = onSelectRemoteDevice, onOpenSession = onOpenRemoteSession, @@ -140,6 +152,11 @@ internal fun AppSidebar( onOpenWorkspace = onOpenRemoteWorkspace, ) }, + footerRoom = if (!signedIn && connectionPhase != ConnectionPhase.CONNECTED) { + 142.dp + } else { + 84.dp + }, modifier = Modifier.weight(1f), ) } @@ -155,7 +172,11 @@ internal fun AppSidebar( if (signedIn) { SidebarAuthenticatedFooter(onNewChat, onOpenSettings) } else { - SidebarSignedOutFooter(onOpenAccount) + SidebarSignedOutFooter( + showScan = connectionPhase != ConnectionPhase.CONNECTED, + onScanDesktop = onScanDesktop, + onOpenAccount = onOpenAccount, + ) } } } @@ -166,7 +187,25 @@ internal fun AppSidebar( actionSessionId = null return@let } - SessionActionSheet( + val actionSurface: @Composable () -> Unit = { + if (permanent) { + SessionActionPopup( + anchorBounds = actionAnchor, + title = session.title, + status = session.status, + capabilities = SessionActionPolicy.resolve( + SessionActionScope.GENERAL, + GENERAL_CHAT_AGENT_TYPE, + false, + ), + onViewDetails = { detailsSessionId = id }, + onArchive = { onArchiveSession(id, !session.status.equals(ARCHIVED, ignoreCase = true)) }, + onExport = { onExportSession(session) }, + onDelete = { onDeleteSession(id) }, + onDismiss = { actionSessionId = null }, + ) + } else { + SessionActionSheet( title = session.title, status = session.status, // Every sidebar row is a local general chat, so the policy is asked @@ -183,7 +222,10 @@ internal fun AppSidebar( onExport = { onExportSession(session) }, onDelete = { onDeleteSession(id) }, onDismiss = { actionSessionId = null }, - ) + ) + } + } + actionSurface() } detailsSessionId?.let { id -> @@ -202,6 +244,7 @@ internal fun AppSidebar( createdAt = session.createdAt, updatedAt = session.updatedAt, messageCount = session.messageCount, + placement = sessionDetailsPlacement, onDismiss = { detailsSessionId = null }, ) } diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/shell/sidebar/SidebarFooter.kt b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/shell/sidebar/SidebarFooter.kt index 6c8dfecf7e..591291777e 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/shell/sidebar/SidebarFooter.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/shell/sidebar/SidebarFooter.kt @@ -26,6 +26,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.bitfun.mobile.app.R +import com.bitfun.mobile.app.ui.common.SignedOutConnectionActions internal const val SIDEBAR_NEW_CHAT_TEST_TAG: String = "app-sidebar-new-chat" internal const val SIDEBAR_SETTINGS_TEST_TAG: String = "app-sidebar-settings" @@ -85,21 +86,16 @@ internal fun SidebarAuthenticatedFooter(onNewChat: () -> Unit, onOpenSettings: ( * draws it. Filled rather than carded because it is the only thing to press here. */ @Composable -internal fun SidebarSignedOutFooter(onOpenAccount: () -> Unit) { - Box( - modifier = Modifier - .fillMaxWidth() - .height(48.dp) - .clip(RoundedCornerShape(16.dp)) - .background(MaterialTheme.colorScheme.primary) - .clickable(onClick = onOpenAccount), - contentAlignment = Alignment.Center, - ) { - Text( - stringResource(R.string.sidebar_sign_in), - fontSize = 16.sp, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onPrimary, - ) - } +internal fun SidebarSignedOutFooter( + showScan: Boolean, + onScanDesktop: () -> Unit, + onOpenAccount: () -> Unit, +) { + SignedOutConnectionActions( + scanLabel = stringResource(R.string.sidebar_scan_to_connect), + accountLabel = stringResource(R.string.sidebar_sign_in), + onScan = onScanDesktop, + onOpenAccount = onOpenAccount, + showScan = showScan, + ) } diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/shell/sidebar/SidebarRemoteWorkspaceSection.kt b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/shell/sidebar/SidebarRemoteWorkspaceSection.kt index ecd85799d1..caa81809de 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/shell/sidebar/SidebarRemoteWorkspaceSection.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/shell/sidebar/SidebarRemoteWorkspaceSection.kt @@ -40,6 +40,7 @@ import com.bitfun.mobile.app.R import com.bitfun.mobile.core.feature.account.AccountDeviceUi import com.bitfun.mobile.core.feature.connection.ConnectionPhase import com.bitfun.mobile.core.feature.connection.ConnectionStatusPresenter +import com.bitfun.mobile.core.feature.connection.RemoteControlSource import com.bitfun.mobile.core.feature.shell.RemoteSidebarPresentation import com.bitfun.mobile.core.feature.shell.RemoteSidebarSessionRow import com.bitfun.mobile.core.feature.session.RemoteSessionUiState @@ -55,6 +56,7 @@ internal const val SIDEBAR_REMOTE_SESSION_TEST_TAG: String = "app-sidebar-remote @Composable internal fun SidebarRemoteWorkspaceSection( connectionPhase: ConnectionPhase, + controlSource: RemoteControlSource, devices: List, selectedDeviceId: String?, deviceName: String, @@ -69,6 +71,25 @@ internal fun SidebarRemoteWorkspaceSection( onOpenWorkspace: (String) -> Unit, ) { val connected = ConnectionStatusPresenter.canReachSessions(connectionPhase) + val transientDeviceKey = remember(deviceName) { "qr:$deviceName" } + val projectedDevices = remember(devices, controlSource, deviceName) { + if ( + controlSource == RemoteControlSource.QR_PAIRING && + deviceName.isNotBlank() && + devices.none { it.name == deviceName } + ) { + listOf(AccountDeviceUi(transientDeviceKey, deviceName, true, null)) + devices + } else { + devices + } + } + val activeDeviceId = when (controlSource) { + RemoteControlSource.QR_PAIRING -> projectedDevices.firstOrNull { + it.id == transientDeviceKey || it.name == deviceName + }?.id + RemoteControlSource.ACCOUNT_DEVICE -> selectedDeviceId + RemoteControlSource.NONE -> null + } var expandedDeviceIds by rememberSaveable { mutableStateOf(emptyList()) } var visibleDeviceCount by rememberSaveable { mutableStateOf(DEVICES_PER_BATCH) } var cachedRemoteStates by remember { @@ -78,8 +99,8 @@ internal fun SidebarRemoteWorkspaceSection( mutableStateOf(emptyMap()) } - LaunchedEffect(selectedDeviceId, remoteState, workspaceState) { - selectedDeviceId?.let { id -> + LaunchedEffect(activeDeviceId, remoteState, workspaceState) { + activeDeviceId?.let { id -> if (id !in expandedDeviceIds) expandedDeviceIds = expandedDeviceIds + id (remoteState as? RemoteSessionUiState.Ready)?.let { ready -> cachedRemoteStates = cachedRemoteStates + (id to ready) @@ -102,7 +123,7 @@ internal fun SidebarRemoteWorkspaceSection( color = MaterialTheme.colorScheme.onSurfaceVariant, ) Box(Modifier.weight(1f)) - if (!connected && devices.isEmpty()) { + if (!connected && projectedDevices.isEmpty()) { Text( stringResource(R.string.sidebar_workspaces_offline), fontSize = 12.sp, @@ -123,7 +144,7 @@ internal fun SidebarRemoteWorkspaceSection( } } - if (devices.isEmpty()) { + if (projectedDevices.isEmpty()) { SidebarActiveDeviceBody( connected = connected, loading = remoteState is RemoteSessionUiState.Loading || @@ -141,9 +162,11 @@ internal fun SidebarRemoteWorkspaceSection( onOpenWorkspace = onOpenWorkspace, ) } else { - devices.take(visibleDeviceCount).forEach { device -> + projectedDevices.take(visibleDeviceCount).forEach { device -> val expanded = device.id in expandedDeviceIds - val active = device.id == selectedDeviceId + val active = device.id == activeDeviceId + val transient = controlSource == RemoteControlSource.QR_PAIRING && + device.id == activeDeviceId val cachedRemote = cachedRemoteStates[device.id] val cachedWorkspace = cachedWorkspaceStates[device.id] val shownRemote = if (active) { @@ -178,8 +201,8 @@ internal fun SidebarRemoteWorkspaceSection( } else { expandedDeviceIds + device.id } - } else if (device.online) { - selectedDeviceId?.let { currentId -> + } else if (device.online && !transient) { + activeDeviceId?.let { currentId -> (remoteState as? RemoteSessionUiState.Ready)?.let { ready -> cachedRemoteStates = cachedRemoteStates + (currentId to ready) } @@ -209,9 +232,9 @@ internal fun SidebarRemoteWorkspaceSection( ) } } - if (visibleDeviceCount < devices.size) { + if (visibleDeviceCount < projectedDevices.size) { MoreRow( - hidden = devices.size - visibleDeviceCount, + hidden = projectedDevices.size - visibleDeviceCount, startPadding = 10, onClick = { visibleDeviceCount += DEVICES_PER_BATCH }, devices = true, diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/shell/sidebar/SidebarSessionList.kt b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/shell/sidebar/SidebarSessionList.kt index 5a1e5a4f73..73dc3fa71f 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/shell/sidebar/SidebarSessionList.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/shell/sidebar/SidebarSessionList.kt @@ -19,16 +19,25 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntRect import androidx.compose.ui.unit.sp import com.bitfun.mobile.app.R import com.bitfun.mobile.core.feature.shell.SidebarSections @@ -55,8 +64,9 @@ internal fun SidebarSessionList( archivedExpanded: Boolean, onToggleArchived: () -> Unit, onOpenSession: (SidebarSessionRow) -> Unit, - onOpenActions: (SidebarSessionRow) -> Unit, + onOpenActions: (SidebarSessionRow, IntRect) -> Unit, workspaceContent: @Composable () -> Unit, + footerRoom: Dp = 84.dp, modifier: Modifier, ) { LazyColumn( @@ -135,7 +145,7 @@ internal fun SidebarSessionList( // Room for the footer, which floats over the bottom of this list rather // than pushing it up — the same 84dp the source reserves. - item(key = "footer-room") { Box(Modifier.height(84.dp)) } + item(key = "footer-room") { Box(Modifier.height(footerRoom)) } } } @@ -164,18 +174,22 @@ private fun SessionRow( pinned: Boolean, selected: Boolean, onOpen: (SidebarSessionRow) -> Unit, - onOpenActions: (SidebarSessionRow) -> Unit, + onOpenActions: (SidebarSessionRow, IntRect) -> Unit, ) { val title = session.title.ifBlank { stringResource(R.string.sidebar_untitled) } + var anchorBounds by remember { mutableStateOf(IntRect.Zero) } Row( modifier = Modifier .fillMaxWidth() .height(44.dp) .clip(RoundedCornerShape(10.dp)) .background(if (selected) MaterialTheme.colorScheme.surfaceVariant else Color.Transparent) + .onGloballyPositioned { coordinates -> + anchorBounds = coordinates.boundsInWindow().toIntRect() + } .combinedClickable( onClick = { onOpen(session) }, - onLongClick = { onOpenActions(session) }, + onLongClick = { onOpenActions(session, anchorBounds) }, ) .padding(start = 12.dp, end = 4.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), @@ -202,7 +216,7 @@ private fun SessionRow( .width(34.dp) .height(40.dp) .clip(RoundedCornerShape(8.dp)) - .clickable { onOpenActions(session) } + .clickable { onOpenActions(session, anchorBounds) } .testTag(SIDEBAR_MORE_TEST_TAG), contentAlignment = Alignment.Center, ) { @@ -216,6 +230,13 @@ private fun SessionRow( } } +private fun Rect.toIntRect(): IntRect = IntRect( + left = left.toInt(), + top = top.toInt(), + right = right.toInt(), + bottom = bottom.toInt(), +) + /** The archive, shown as how much is in it rather than as what is in it. */ @Composable private fun ArchivedDisclosureRow(count: Int, expanded: Boolean, onToggle: () -> Unit) { diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/theme/Theme.kt b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/theme/Theme.kt index 26934c7015..1012bb44ed 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/theme/Theme.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/theme/Theme.kt @@ -124,6 +124,7 @@ private val DarkScheme = darkColorScheme( */ internal data class BitFunColors( val success: Color, + val modalScrim: Color, val heroBackground: Color, val heroSurface: Color, val heroAccent: Color, @@ -154,6 +155,7 @@ internal data class CodeSyntaxColors( private val LightExtras = BitFunColors( success = LightTokens.Green, + modalScrim = LightTokens.ModalScrim, heroBackground = LightTokens.ConnectHeroBg, heroSurface = LightTokens.ConnectHeroSurface, heroAccent = LightTokens.ConnectHeroAccent, @@ -174,6 +176,7 @@ private val LightExtras = BitFunColors( private val DarkExtras = BitFunColors( success = DarkTokens.Green, + modalScrim = DarkTokens.ModalScrim, heroBackground = DarkTokens.ConnectHeroBg, heroSurface = DarkTokens.ConnectHeroSurface, heroAccent = DarkTokens.ConnectHeroAccent, diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/theme/generated/MobileDesignTokens.kt b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/theme/generated/MobileDesignTokens.kt index 5fc01d3500..8d66e0a1f0 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/theme/generated/MobileDesignTokens.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/theme/generated/MobileDesignTokens.kt @@ -26,7 +26,7 @@ internal object MobileDesignColors { val ConnectHeroSecondary = Color(0xFFC9C5FF) val ConnectHeroSurface = Color(0xFFF8FAFF) val ConnectScanAccent = Color(0xFFFFD021) - val ModalScrim = Color(0x99000000) + val ModalScrim = Color(0x44000000) val Soft = Color(0xFFF4F3F0) val FloatingPanelBg = Color(0xFFF7F7F5) val Green = Color(0xFF27C46A) @@ -61,7 +61,7 @@ internal object MobileDesignColors { val ConnectHeroSecondary = Color(0xFF3C3B38) val ConnectHeroSurface = Color(0xFF252522) val ConnectScanAccent = Color(0xFFFFD021) - val ModalScrim = Color(0x99000000) + val ModalScrim = Color(0x44000000) val Soft = Color(0xFF2D2C28) val FloatingPanelBg = Color(0xFF1E1E1C) val Green = Color(0xFF3BD47B) @@ -119,6 +119,42 @@ internal object MobileDesignGeometry { val ComposerExpandedHeight = 126.dp val ComposerCollapsedRadius = 26.dp val ComposerExpandedRadius = 18.dp + val ComposerModelSelectorWidth = 330.dp + val ComposerModelSelectorRadius = 14.dp + val ComposerModelSelectorRowHeight = 48.dp + val ComposerModelSelectorRowRadius = 9.dp + val ComposerModelSelectorRowGap = 6.dp + val SheetTopRadius = 34.dp + val SheetSideRadius = 34.dp + val SheetHorizontalPadding = 20.dp + val SheetHeaderHeight = 56.dp + val SheetActionHeight = 46.dp + val SelectionTopRadius = 20.dp + val SelectionRowHeight = 64.dp + val SelectionCloseSize = 32.dp + val PopoverWidth = 292.dp + val PopoverRadius = 16.dp + val PopoverPadding = 12.dp + val PopoverVerticalPadding = 10.dp + val PopoverActionHeight = 48.dp + val PopoverShadowRadius = 18.dp + val CompactPopoverWidth = 150.dp + val CompactPopoverRadius = 14.dp + val CompactPopoverActionHeight = 42.dp + val SettingsCompactCardRadius = 8.dp + val SettingsCardRadius = 24.dp + val SettingsProminentCardRadius = 28.dp + val ModelCurrentRowHeight = 64.dp + val ModelSourceRowHeight = 62.dp + val ModelAccountRowHeight = 56.dp + val ModelAccountRowGap = 6.dp + val ModelSectionGap = 20.dp + val ModelOverviewTopPadding = 16.dp + val ModelOverviewBottomPadding = 24.dp + val ModelListTopPadding = 10.dp + val ModelListBottomPadding = 16.dp + val ModelEmptyAccountHeight = 80.dp + val ModelEditorHeight = 560.dp } internal object MobileDesignBreakpoints { 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 540497108f..806927dfb5 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 @@ -9,6 +9,7 @@ 账号 未登录 语言 + 通用 语言 选择语言 English @@ -48,6 +49,11 @@ 重试 正在加载设备… 账号下还没有其他已注册设备。 + 选择桌面设备 + 远程 + 选择一台在线桌面继续工作。 + 账号设备 + 扫描二维码连接 当前控制 · %1$s 请输入有效的用户名和密码。 用户名或密码被拒绝。 @@ -98,6 +104,8 @@ 请输入 API Key。 无法访问系统安全存储,请稍后重试。 普通对话模型 + 选择账号模型 + 模型来源 本机自定义 测试连接 测试中… @@ -213,6 +221,7 @@ 就绪 关闭 返回 + 打开 消息 向 BitFun 提问 发送 @@ -316,6 +325,8 @@ 聊天 新聊天 登录 BitFun 账号 + 扫码连接 + 选择连接方式 打开侧边栏 已连接 正在重连 @@ -368,6 +379,7 @@ 停止 你的回答 发送回答 + 其他 智能体正在等待你的回答。 正在运行“%1$s” %1$s失败 @@ -412,4 +424,5 @@ 本机设备 ID BitFun 用户 已认证 + 加载更早消息 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 32fa78697f..f436a3bcbd 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 @@ -9,6 +9,7 @@ Account Not signed in Language + General Language Choose language English @@ -49,6 +50,11 @@ Retry Loading… This account has no other registered devices yet. + Choose a desktop + Remote + Choose an online desktop to continue. + Account devices + Scan QR code to connect Controlling · %1$s Enter a valid username and password. @@ -73,6 +79,7 @@ The model service is temporarily unavailable. Try again later. The model service returned an unrecognized response. Could not reach the provider. + Load earlier messages Model Local custom model @@ -100,6 +107,8 @@ Enter an API key. The system secure storage is unavailable. Try again later. Chat model + Choose account model + Model sources Local custom Test connection Testing… @@ -218,6 +227,7 @@ Ready Close Back + Open Messages Ask BitFun Send @@ -328,6 +338,8 @@ Chat New chat Sign in to BitFun account + Scan to connect + Choose how to connect Open sidebar Connected Reconnecting @@ -383,6 +395,7 @@ Stop Your answer Send answer + Other The agent is waiting for an answer. Running \"%1$s\" %1$s failed diff --git a/src/apps/mobile/android/app/src/test/kotlin/com/bitfun/mobile/app/ui/chat/tool/ToolInteractionPanelsTest.kt b/src/apps/mobile/android/app/src/test/kotlin/com/bitfun/mobile/app/ui/chat/tool/ToolInteractionPanelsTest.kt new file mode 100644 index 0000000000..23e63c694c --- /dev/null +++ b/src/apps/mobile/android/app/src/test/kotlin/com/bitfun/mobile/app/ui/chat/tool/ToolInteractionPanelsTest.kt @@ -0,0 +1,81 @@ +package com.bitfun.mobile.app.ui.chat.tool + +import com.bitfun.mobile.core.feature.session.QuestionAnswerValue +import com.bitfun.mobile.core.feature.session.QuestionOption +import com.bitfun.mobile.core.feature.session.ToolQuestion +import org.junit.Assert.assertEquals +import org.junit.Test + +class ToolInteractionPanelsTest { + private val questions = listOf( + ToolQuestion(0, "", "Pick one", listOf(QuestionOption("A", null), QuestionOption("B", null)), false), + ToolQuestion(1, "", "Pick many", listOf(QuestionOption("X", null)), true), + ) + + @Test + fun buildStructuredAnswers_mapsSelectionsAndReplacesOther() { + val answers = buildStructuredAnswers( + questions = questions, + selectedByIndex = mapOf(0 to setOf("B"), 1 to setOf("X", "Other")), + customByIndex = mapOf(1 to "custom value"), + otherLabel = "Other", + ) + + assertEquals(QuestionAnswerValue.Text("B"), answers[0].value) + assertEquals(QuestionAnswerValue.Choice(listOf("X", "custom value")), answers[1].value) + } + + @Test + fun buildStructuredAnswers_recognizesWireOtherWithLocalizedOtherLabel() { + val question = ToolQuestion( + 0, + "", + "Pick one", + listOf(QuestionOption("Other", null)), + false, + ) + + val answers = buildStructuredAnswers( + questions = listOf(question), + selectedByIndex = mapOf(0 to setOf("Other")), + customByIndex = mapOf(0 to "custom value"), + otherLabel = "其他", + ) + + assertEquals(QuestionAnswerValue.Text("custom value"), answers.single().value) + val options = effectiveOptions(question, "其他") + assertEquals(1, options.size) + assertEquals(1, options.count { isOtherOption(it.label, "其他") }) + } + + @Test + fun buildStructuredAnswers_recognizesChineseWireOtherWithEnglishOtherLabel() { + val question = ToolQuestion( + 0, + "", + "Pick one", + listOf(QuestionOption("其他", null)), + false, + ) + + val answers = buildStructuredAnswers( + questions = listOf(question), + selectedByIndex = mapOf(0 to setOf("其他")), + customByIndex = mapOf(0 to "custom value"), + otherLabel = "Other", + ) + + assertEquals(QuestionAnswerValue.Text("custom value"), answers.single().value) + val options = effectiveOptions(question, "Other") + assertEquals(1, options.size) + assertEquals(1, options.count { isOtherOption(it.label, "Other") }) + } + + @Test + fun buildStructuredAnswers_keepsEmptyAnswersForUnselectedQuestions() { + val answers = buildStructuredAnswers(questions, emptyMap(), emptyMap(), "Other") + + assertEquals(QuestionAnswerValue.Text(""), answers[0].value) + assertEquals(QuestionAnswerValue.Choice(emptyList()), answers[1].value) + } +} diff --git a/src/apps/mobile/design-system/components/mobile-components.json b/src/apps/mobile/design-system/components/mobile-components.json index f4436b8a17..c44b67a1e2 100644 --- a/src/apps/mobile/design-system/components/mobile-components.json +++ b/src/apps/mobile/design-system/components/mobile-components.json @@ -28,6 +28,97 @@ "states": ["empty", "draft", "focused", "streaming", "disconnected", "with_attachments"], "tokens": ["composer_action_size", "composer_collapsed_height", "composer_expanded_height", "composer_collapsed_radius", "composer_expanded_radius"], "platformNotes": "Keyboard, dictation, and attachment pickers remain native adapters." + }, + "composer_model_selector": { + "purpose": "Changes the model for the message being composed without leaving the conversation or creation flow.", + "anatomy": ["optional_modal_header", "selected_mark", "primary_label", "supporting_label", "scrolling_model_rows"], + "states": ["compact_bottom", "wide_popover", "selected", "empty", "scrolling"], + "tokens": ["composer_model_selector_width", "composer_model_selector_radius", "composer_model_selector_row_height", "composer_model_selector_row_radius", "composer_model_selector_row_gap", "selection_top_radius", "selection_close_size", "floating_panel_bg", "card", "soft", "line", "ink", "muted"], + "platformNotes": "Compact layouts use a native bottom surface with a closeable header; wide layouts use an arrowless anchored surface without a redundant header. The selected model is first, uses the soft surface and a leading check mark, and all platform-specific provider glyphs and row dividers are omitted." + }, + "adaptive_modal_surface": { + "purpose": "Hosts settings, connection, creation, selection, and detail flows without crossing a physical fold or obscuring more of the workspace than necessary.", + "anatomy": ["modal_scrim", "paper_surface", "header", "scroll_content", "action_rail"], + "states": ["compact_bottom", "wide_side", "fold_operate", "busy", "error"], + "tokens": ["modal_scrim", "sheet_top_radius", "sheet_side_radius", "sheet_horizontal_padding", "sheet_header_height", "sheet_action_height", "card", "line", "structure"], + "platformNotes": "Use each platform's native modal lifecycle, accessibility focus, and back gesture; placement comes from the shared adaptive policy and visual geometry comes from these tokens." + }, + "action_popover": { + "purpose": "Presents a short, anchored set of conversation, session, model, or project actions.", + "anatomy": ["section_label", "action_rows", "optional_divider", "anchored_paper_surface"], + "states": ["open", "compact_two_action", "selected", "destructive", "disabled"], + "tokens": ["popover_width", "popover_radius", "popover_padding", "popover_vertical_padding", "popover_action_height", "compact_popover_width", "compact_popover_radius", "compact_popover_action_height", "floating_panel_bg", "line", "quick"], + "platformNotes": "Wide action sets use the standard arrowless anchored paper surface. The project-row Code/Cowork shortcut remains an anchored two-action popover on every size because its trigger already lives inside the session list; it uses the compact popover geometry." + }, + "session_action_surface": { + "purpose": "Presents actions for a session row without stacking confirmation UI.", + "anatomy": ["optional_drag_handle", "session_identity", "action_rows", "in_place_confirmation"], + "states": ["compact_bottom", "wide_popover", "confirming_delete", "busy"], + "tokens": ["sheet_action_height", "popover_radius", "card", "line", "soft", "red"], + "platformNotes": "Compact session lists use a transparent native sheet hosting the same 16-unit paper surface that wide lists anchor as a popover; deletion confirmation replaces the action rows in place. This is distinct from the active conversation header's action_popover." + }, + "selection_surface": { + "purpose": "Lets the user choose a language, model, device, workspace, or assistant from one consistent modal pattern.", + "anatomy": ["modal_header", "selection_rows", "selected_mark", "optional_supporting_text"], + "states": ["loading", "ready", "selected", "empty", "failed"], + "tokens": ["selection_top_radius", "selection_row_height", "selection_close_size", "sheet_header_height", "sheet_horizontal_padding", "card", "soft", "line"], + "platformNotes": "A compact standalone picker uses one native bottom surface and a wide picker anchors one arrowless paper popover to its trigger. When selection starts inside another modal, it replaces that modal's content rather than stacking another modal or scrim. The picker mechanism may be native, but row height, selected treatment, header chrome, and dismissal affordance stay aligned." + }, + "confirmation_surface": { + "purpose": "Confirms destructive or high-impact actions inside the surface that initiated them.", + "anatomy": ["specific_consequence", "cancel_action", "destructive_action"], + "states": ["idle", "confirming", "submitting", "failed"], + "tokens": ["sheet_action_height", "red", "soft", "line"], + "platformNotes": "Prefer an in-place confirmation step over stacking a second modal and a second scrim." + }, + "settings_card": { + "purpose": "Groups related settings into a stable paper card within an adaptive modal surface.", + "anatomy": ["section_heading", "card", "entry_rows", "dividers", "inline_controls"], + "states": ["navigable", "editable", "loading", "failed", "disabled"], + "tokens": ["settings_compact_card_radius", "settings_card_radius", "settings_prominent_card_radius", "card", "line", "muted", "body_medium"], + "platformNotes": "A chevron means navigation; controls that mutate in place do not use one." + }, + "session_view_settings_surface": { + "purpose": "Controls how the connected desktop's sessions are grouped, filtered, and annotated without changing remote session data.", + "anatomy": ["modal_header", "grouping_card", "workspace_filter_card", "agent_filter_card", "status_filter_card", "metadata_card"], + "states": ["project_grouping", "time_grouping", "chat_first", "filtered", "metadata_visible"], + "tokens": ["sheet_header_height", "settings_compact_card_radius", "card", "soft", "line", "muted", "body_medium", "label_small"], + "platformNotes": "Open from the session-list overflow inside the shared adaptive modal. Choices update local view state only; compact, wide, and fold-operate placements use the same cards and never stack native pickers or alerts." + }, + "model_service_surface": { + "purpose": "Manages the active chat model, account-synced choices, and the local provider editor as one replace-in-place settings flow.", + "anatomy": ["modal_header", "current_model_card", "source_group", "account_model_rows", "local_provider_form", "feedback", "actions"], + "states": ["overview", "account_selection", "local_editor", "testing", "saving", "failed"], + "tokens": ["sheet_header_height", "model_current_row_height", "model_source_row_height", "model_account_row_height", "model_section_gap", "settings_compact_card_radius", "card", "soft", "line"], + "platformNotes": "Overview, account selection, and local editing replace each other inside the existing adaptive modal. Never stack another sheet or system picker; account rows select immediately, while the local row selects a complete configuration and its chevron always opens the editor." + }, + "pairing_surface": { + "purpose": "Guides desktop pairing from preparation through scanning, with a manual-code fallback that stays inside the connect surface.", + "anatomy": ["hero_wash", "back_control", "desktop_or_scanner_glyph", "instructions", "primary_action", "manual_pairing_overlay", "conditional_account_credentials", "status_feedback"], + "states": ["intro", "scan", "manual", "account_auth", "connecting", "failed", "paired"], + "tokens": ["connect_hero_bg", "connect_hero_surface", "modal_scrim", "sheet_side_radius", "primary_action", "card", "soft", "line"], + "platformNotes": "Camera capture and permission prompts remain native. The manual form is an in-surface overlay with the shared scrim and 34-unit card, not a second system sheet. Account-protected links add username and password fields in that same card; passwords remain transient view state and are cleared on submit or dismiss." + }, + "remote_create_surface": { + "purpose": "Creates a remote session from one instruction while making the target desktop, workspace, and model explicit.", + "anatomy": ["back_control", "empty_focus_region", "device_context", "workspace_context", "create_composer", "selection_surface"], + "states": ["ready", "selecting_device", "selecting_workspace", "selecting_model", "listening", "submitting", "disconnected"], + "tokens": ["composer_expanded_height", "composer_expanded_radius", "selection_row_height", "popover_width", "card", "soft", "line", "accent"], + "platformNotes": "This is a conversation route, not a form modal: title and agent type are inferred from the first instruction and selected workspace. Compact selectors use one bottom selection surface; wide selectors use the same rows in an arrowless anchored popover." + }, + "remote_control_settings_surface": { + "purpose": "Explains which desktop is being controlled, how it is connected, and which permission policy that desktop applies.", + "anatomy": ["modal_header", "profile_entry", "current_control_card", "connection_source", "alternate_connection_entry", "permission_mode_card", "in_place_full_access_confirmation"], + "states": ["disconnected", "connecting", "connected_by_account", "connected_by_pairing", "permission_loading", "permission_failed", "confirming_full_access", "account_page"], + "tokens": ["sheet_header_height", "settings_prominent_card_radius", "settings_compact_card_radius", "modal_scrim", "card", "soft", "line", "red"], + "platformNotes": "The account page replaces this page inside the same adaptive modal. Full-access confirmation replaces content inside the permission card; it never opens a system alert or a second sheet." + }, + "file_preview_surface": { + "purpose": "Shows a remote file as a compact full-page modal or as a wide companion pane.", + "anatomy": ["file_header", "download_action", "close_action", "content_viewport", "truncation_notice"], + "states": ["loading", "text", "markdown", "image", "unsupported", "failed", "truncated"], + "tokens": ["sheet_header_height", "file_link", "card", "line", "soft"], + "platformNotes": "Placement is shared policy; rendering, file export, and platform-safe dismissal remain native adapters." } } } diff --git a/src/apps/mobile/design-system/preview/generated/mobile-design-data.js b/src/apps/mobile/design-system/preview/generated/mobile-design-data.js index b04f70be56..1feaaa2b9c 100644 --- a/src/apps/mobile/design-system/preview/generated/mobile-design-data.js +++ b/src/apps/mobile/design-system/preview/generated/mobile-design-data.js @@ -75,8 +75,8 @@ export const mobileTokens = { "dark": "#FFD021" }, "modal_scrim": { - "light": "#99000000", - "dark": "#99000000" + "light": "#44000000", + "dark": "#44000000" }, "soft": { "light": "#F4F3F0", @@ -237,7 +237,43 @@ export const mobileTokens = { "composer_expanded_action_row_height": 44, "composer_expanded_height": 126, "composer_collapsed_radius": 26, - "composer_expanded_radius": 18 + "composer_expanded_radius": 18, + "composer_model_selector_width": 330, + "composer_model_selector_radius": 14, + "composer_model_selector_row_height": 48, + "composer_model_selector_row_radius": 9, + "composer_model_selector_row_gap": 6, + "sheet_top_radius": 34, + "sheet_side_radius": 34, + "sheet_horizontal_padding": 20, + "sheet_header_height": 56, + "sheet_action_height": 46, + "selection_top_radius": 20, + "selection_row_height": 64, + "selection_close_size": 32, + "popover_width": 292, + "popover_radius": 16, + "popover_padding": 12, + "popover_vertical_padding": 10, + "popover_action_height": 48, + "popover_shadow_radius": 18, + "compact_popover_width": 150, + "compact_popover_radius": 14, + "compact_popover_action_height": 42, + "settings_compact_card_radius": 8, + "settings_card_radius": 24, + "settings_prominent_card_radius": 28, + "model_current_row_height": 64, + "model_source_row_height": 62, + "model_account_row_height": 56, + "model_account_row_gap": 6, + "model_section_gap": 20, + "model_overview_top_padding": 16, + "model_overview_bottom_padding": 24, + "model_list_top_padding": 10, + "model_list_bottom_padding": 16, + "model_empty_account_height": 80, + "model_editor_height": 560 }, "breakpoints": { "wide": 600, @@ -343,6 +379,382 @@ export const mobileComponents = { "composer_expanded_radius" ], "platformNotes": "Keyboard, dictation, and attachment pickers remain native adapters." + }, + "composer_model_selector": { + "purpose": "Changes the model for the message being composed without leaving the conversation or creation flow.", + "anatomy": [ + "optional_modal_header", + "selected_mark", + "primary_label", + "supporting_label", + "scrolling_model_rows" + ], + "states": [ + "compact_bottom", + "wide_popover", + "selected", + "empty", + "scrolling" + ], + "tokens": [ + "composer_model_selector_width", + "composer_model_selector_radius", + "composer_model_selector_row_height", + "composer_model_selector_row_radius", + "composer_model_selector_row_gap", + "selection_top_radius", + "selection_close_size", + "floating_panel_bg", + "card", + "soft", + "line", + "ink", + "muted" + ], + "platformNotes": "Compact layouts use a native bottom surface with a closeable header; wide layouts use an arrowless anchored surface without a redundant header. The selected model is first, uses the soft surface and a leading check mark, and all platform-specific provider glyphs and row dividers are omitted." + }, + "adaptive_modal_surface": { + "purpose": "Hosts settings, connection, creation, selection, and detail flows without crossing a physical fold or obscuring more of the workspace than necessary.", + "anatomy": [ + "modal_scrim", + "paper_surface", + "header", + "scroll_content", + "action_rail" + ], + "states": [ + "compact_bottom", + "wide_side", + "fold_operate", + "busy", + "error" + ], + "tokens": [ + "modal_scrim", + "sheet_top_radius", + "sheet_side_radius", + "sheet_horizontal_padding", + "sheet_header_height", + "sheet_action_height", + "card", + "line", + "structure" + ], + "platformNotes": "Use each platform's native modal lifecycle, accessibility focus, and back gesture; placement comes from the shared adaptive policy and visual geometry comes from these tokens." + }, + "action_popover": { + "purpose": "Presents a short, anchored set of conversation, session, model, or project actions.", + "anatomy": [ + "section_label", + "action_rows", + "optional_divider", + "anchored_paper_surface" + ], + "states": [ + "open", + "compact_two_action", + "selected", + "destructive", + "disabled" + ], + "tokens": [ + "popover_width", + "popover_radius", + "popover_padding", + "popover_vertical_padding", + "popover_action_height", + "compact_popover_width", + "compact_popover_radius", + "compact_popover_action_height", + "floating_panel_bg", + "line", + "quick" + ], + "platformNotes": "Wide action sets use the standard arrowless anchored paper surface. The project-row Code/Cowork shortcut remains an anchored two-action popover on every size because its trigger already lives inside the session list; it uses the compact popover geometry." + }, + "session_action_surface": { + "purpose": "Presents actions for a session row without stacking confirmation UI.", + "anatomy": [ + "optional_drag_handle", + "session_identity", + "action_rows", + "in_place_confirmation" + ], + "states": [ + "compact_bottom", + "wide_popover", + "confirming_delete", + "busy" + ], + "tokens": [ + "sheet_action_height", + "popover_radius", + "card", + "line", + "soft", + "red" + ], + "platformNotes": "Compact session lists use a transparent native sheet hosting the same 16-unit paper surface that wide lists anchor as a popover; deletion confirmation replaces the action rows in place. This is distinct from the active conversation header's action_popover." + }, + "selection_surface": { + "purpose": "Lets the user choose a language, model, device, workspace, or assistant from one consistent modal pattern.", + "anatomy": [ + "modal_header", + "selection_rows", + "selected_mark", + "optional_supporting_text" + ], + "states": [ + "loading", + "ready", + "selected", + "empty", + "failed" + ], + "tokens": [ + "selection_top_radius", + "selection_row_height", + "selection_close_size", + "sheet_header_height", + "sheet_horizontal_padding", + "card", + "soft", + "line" + ], + "platformNotes": "A compact standalone picker uses one native bottom surface and a wide picker anchors one arrowless paper popover to its trigger. When selection starts inside another modal, it replaces that modal's content rather than stacking another modal or scrim. The picker mechanism may be native, but row height, selected treatment, header chrome, and dismissal affordance stay aligned." + }, + "confirmation_surface": { + "purpose": "Confirms destructive or high-impact actions inside the surface that initiated them.", + "anatomy": [ + "specific_consequence", + "cancel_action", + "destructive_action" + ], + "states": [ + "idle", + "confirming", + "submitting", + "failed" + ], + "tokens": [ + "sheet_action_height", + "red", + "soft", + "line" + ], + "platformNotes": "Prefer an in-place confirmation step over stacking a second modal and a second scrim." + }, + "settings_card": { + "purpose": "Groups related settings into a stable paper card within an adaptive modal surface.", + "anatomy": [ + "section_heading", + "card", + "entry_rows", + "dividers", + "inline_controls" + ], + "states": [ + "navigable", + "editable", + "loading", + "failed", + "disabled" + ], + "tokens": [ + "settings_compact_card_radius", + "settings_card_radius", + "settings_prominent_card_radius", + "card", + "line", + "muted", + "body_medium" + ], + "platformNotes": "A chevron means navigation; controls that mutate in place do not use one." + }, + "session_view_settings_surface": { + "purpose": "Controls how the connected desktop's sessions are grouped, filtered, and annotated without changing remote session data.", + "anatomy": [ + "modal_header", + "grouping_card", + "workspace_filter_card", + "agent_filter_card", + "status_filter_card", + "metadata_card" + ], + "states": [ + "project_grouping", + "time_grouping", + "chat_first", + "filtered", + "metadata_visible" + ], + "tokens": [ + "sheet_header_height", + "settings_compact_card_radius", + "card", + "soft", + "line", + "muted", + "body_medium", + "label_small" + ], + "platformNotes": "Open from the session-list overflow inside the shared adaptive modal. Choices update local view state only; compact, wide, and fold-operate placements use the same cards and never stack native pickers or alerts." + }, + "model_service_surface": { + "purpose": "Manages the active chat model, account-synced choices, and the local provider editor as one replace-in-place settings flow.", + "anatomy": [ + "modal_header", + "current_model_card", + "source_group", + "account_model_rows", + "local_provider_form", + "feedback", + "actions" + ], + "states": [ + "overview", + "account_selection", + "local_editor", + "testing", + "saving", + "failed" + ], + "tokens": [ + "sheet_header_height", + "model_current_row_height", + "model_source_row_height", + "model_account_row_height", + "model_section_gap", + "settings_compact_card_radius", + "card", + "soft", + "line" + ], + "platformNotes": "Overview, account selection, and local editing replace each other inside the existing adaptive modal. Never stack another sheet or system picker; account rows select immediately, while the local row selects a complete configuration and its chevron always opens the editor." + }, + "pairing_surface": { + "purpose": "Guides desktop pairing from preparation through scanning, with a manual-code fallback that stays inside the connect surface.", + "anatomy": [ + "hero_wash", + "back_control", + "desktop_or_scanner_glyph", + "instructions", + "primary_action", + "manual_pairing_overlay", + "conditional_account_credentials", + "status_feedback" + ], + "states": [ + "intro", + "scan", + "manual", + "account_auth", + "connecting", + "failed", + "paired" + ], + "tokens": [ + "connect_hero_bg", + "connect_hero_surface", + "modal_scrim", + "sheet_side_radius", + "primary_action", + "card", + "soft", + "line" + ], + "platformNotes": "Camera capture and permission prompts remain native. The manual form is an in-surface overlay with the shared scrim and 34-unit card, not a second system sheet. Account-protected links add username and password fields in that same card; passwords remain transient view state and are cleared on submit or dismiss." + }, + "remote_create_surface": { + "purpose": "Creates a remote session from one instruction while making the target desktop, workspace, and model explicit.", + "anatomy": [ + "back_control", + "empty_focus_region", + "device_context", + "workspace_context", + "create_composer", + "selection_surface" + ], + "states": [ + "ready", + "selecting_device", + "selecting_workspace", + "selecting_model", + "listening", + "submitting", + "disconnected" + ], + "tokens": [ + "composer_expanded_height", + "composer_expanded_radius", + "selection_row_height", + "popover_width", + "card", + "soft", + "line", + "accent" + ], + "platformNotes": "This is a conversation route, not a form modal: title and agent type are inferred from the first instruction and selected workspace. Compact selectors use one bottom selection surface; wide selectors use the same rows in an arrowless anchored popover." + }, + "remote_control_settings_surface": { + "purpose": "Explains which desktop is being controlled, how it is connected, and which permission policy that desktop applies.", + "anatomy": [ + "modal_header", + "profile_entry", + "current_control_card", + "connection_source", + "alternate_connection_entry", + "permission_mode_card", + "in_place_full_access_confirmation" + ], + "states": [ + "disconnected", + "connecting", + "connected_by_account", + "connected_by_pairing", + "permission_loading", + "permission_failed", + "confirming_full_access", + "account_page" + ], + "tokens": [ + "sheet_header_height", + "settings_prominent_card_radius", + "settings_compact_card_radius", + "modal_scrim", + "card", + "soft", + "line", + "red" + ], + "platformNotes": "The account page replaces this page inside the same adaptive modal. Full-access confirmation replaces content inside the permission card; it never opens a system alert or a second sheet." + }, + "file_preview_surface": { + "purpose": "Shows a remote file as a compact full-page modal or as a wide companion pane.", + "anatomy": [ + "file_header", + "download_action", + "close_action", + "content_viewport", + "truncation_notice" + ], + "states": [ + "loading", + "text", + "markdown", + "image", + "unsupported", + "failed", + "truncated" + ], + "tokens": [ + "sheet_header_height", + "file_link", + "card", + "line", + "soft" + ], + "platformNotes": "Placement is shared policy; rendering, file export, and platform-safe dismissal remain native adapters." } } }; diff --git a/src/apps/mobile/design-system/tokens/mobile-tokens.json b/src/apps/mobile/design-system/tokens/mobile-tokens.json index cc06b2e1ae..6cd388dc5d 100644 --- a/src/apps/mobile/design-system/tokens/mobile-tokens.json +++ b/src/apps/mobile/design-system/tokens/mobile-tokens.json @@ -22,7 +22,7 @@ "connect_hero_secondary": { "light": "#C9C5FF", "dark": "#3C3B38" }, "connect_hero_surface": { "light": "#F8FAFF", "dark": "#252522" }, "connect_scan_accent": { "light": "#FFD021", "dark": "#FFD021" }, - "modal_scrim": { "light": "#99000000", "dark": "#99000000" }, + "modal_scrim": { "light": "#44000000", "dark": "#44000000" }, "soft": { "light": "#F4F3F0", "dark": "#2D2C28" }, "floating_panel_bg": { "light": "#F7F7F5", "dark": "#1E1E1C" }, "green": { "light": "#27C46A", "dark": "#3BD47B" }, @@ -76,7 +76,43 @@ "composer_expanded_action_row_height": 44, "composer_expanded_height": 126, "composer_collapsed_radius": 26, - "composer_expanded_radius": 18 + "composer_expanded_radius": 18, + "composer_model_selector_width": 330, + "composer_model_selector_radius": 14, + "composer_model_selector_row_height": 48, + "composer_model_selector_row_radius": 9, + "composer_model_selector_row_gap": 6, + "sheet_top_radius": 34, + "sheet_side_radius": 34, + "sheet_horizontal_padding": 20, + "sheet_header_height": 56, + "sheet_action_height": 46, + "selection_top_radius": 20, + "selection_row_height": 64, + "selection_close_size": 32, + "popover_width": 292, + "popover_radius": 16, + "popover_padding": 12, + "popover_vertical_padding": 10, + "popover_action_height": 48, + "popover_shadow_radius": 18, + "compact_popover_width": 150, + "compact_popover_radius": 14, + "compact_popover_action_height": 42, + "settings_compact_card_radius": 8, + "settings_card_radius": 24, + "settings_prominent_card_radius": 28, + "model_current_row_height": 64, + "model_source_row_height": 62, + "model_account_row_height": 56, + "model_account_row_gap": 6, + "model_section_gap": 20, + "model_overview_top_padding": 16, + "model_overview_bottom_padding": 24, + "model_list_top_padding": 10, + "model_list_bottom_padding": 16, + "model_empty_account_height": 80, + "model_editor_height": 560 }, "breakpoints": { "wide": 600, diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/generated/MobileDesignTokens.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/generated/MobileDesignTokens.ets index b476885024..1da85820b5 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/generated/MobileDesignTokens.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/generated/MobileDesignTokens.ets @@ -52,6 +52,42 @@ export class MobileDesignGeometry { static readonly composerExpandedHeight: number = 126; static readonly composerCollapsedRadius: number = 26; static readonly composerExpandedRadius: number = 18; + static readonly composerModelSelectorWidth: number = 330; + static readonly composerModelSelectorRadius: number = 14; + static readonly composerModelSelectorRowHeight: number = 48; + static readonly composerModelSelectorRowRadius: number = 9; + static readonly composerModelSelectorRowGap: number = 6; + static readonly sheetTopRadius: number = 34; + static readonly sheetSideRadius: number = 34; + static readonly sheetHorizontalPadding: number = 20; + static readonly sheetHeaderHeight: number = 56; + static readonly sheetActionHeight: number = 46; + static readonly selectionTopRadius: number = 20; + static readonly selectionRowHeight: number = 64; + static readonly selectionCloseSize: number = 32; + static readonly popoverWidth: number = 292; + static readonly popoverRadius: number = 16; + static readonly popoverPadding: number = 12; + static readonly popoverVerticalPadding: number = 10; + static readonly popoverActionHeight: number = 48; + static readonly popoverShadowRadius: number = 18; + static readonly compactPopoverWidth: number = 150; + static readonly compactPopoverRadius: number = 14; + static readonly compactPopoverActionHeight: number = 42; + static readonly settingsCompactCardRadius: number = 8; + static readonly settingsCardRadius: number = 24; + static readonly settingsProminentCardRadius: number = 28; + static readonly modelCurrentRowHeight: number = 64; + static readonly modelSourceRowHeight: number = 62; + static readonly modelAccountRowHeight: number = 56; + static readonly modelAccountRowGap: number = 6; + static readonly modelSectionGap: number = 20; + static readonly modelOverviewTopPadding: number = 16; + static readonly modelOverviewBottomPadding: number = 24; + static readonly modelListTopPadding: number = 10; + static readonly modelListBottomPadding: number = 16; + static readonly modelEmptyAccountHeight: number = 80; + static readonly modelEditorHeight: number = 560; } export class MobileDesignBreakpoints { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ComposerBar.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ComposerBar.ets index 55a0badfd0..554d317094 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ComposerBar.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ComposerBar.ets @@ -233,7 +233,7 @@ export struct ComposerBar { .width('100%') } - List({ space: 6 }) { + List({ space: MobileDesignGeometry.composerModelSelectorRowGap }) { ForEach(this.selectorModels(), (model: ConversationUiModel) => { ListItem() { this.ModelRow(model) @@ -246,10 +246,11 @@ export struct ComposerBar { .edgeEffect(EdgeEffect.Spring) .divider(null) } - .width(asSheet ? '100%' : 330) + .width(asSheet ? '100%' : MobileDesignGeometry.composerModelSelectorWidth) .padding({ left: 10, right: 10, top: 10, bottom: 10 }) .backgroundColor(asSheet ? CARD : FLOATING_PANEL_BG) - .borderRadius(asSheet ? { topLeft: 20, topRight: 20 } : 14) + .borderRadius(asSheet ? { topLeft: 20, topRight: 20 } : + MobileDesignGeometry.composerModelSelectorRadius) .border({ width: asSheet ? 0 : 1, color: asSheet ? '#00000000' : LINE }) .shadow({ radius: asSheet ? 0 : 18, color: asSheet ? '#00000000' : '#18000000', offsetY: 7 }) } @@ -284,10 +285,10 @@ export struct ComposerBar { .alignItems(HorizontalAlign.Start) } .width('100%') - .height(48) + .height(MobileDesignGeometry.composerModelSelectorRowHeight) .padding({ left: 10, right: 10 }) .backgroundColor(this.isSelectedModel(model) ? SOFT : '#00000000') - .borderRadius(9) + .borderRadius(MobileDesignGeometry.composerModelSelectorRowRadius) .onClick(() => { this.closeModelSelector(); this.onSelectModel(model.id); @@ -586,7 +587,8 @@ export struct ComposerBar { private modelListHeight(): number { const visibleRows = Math.min(this.enabledModels().length, 7); - return visibleRows * 48 + Math.max(0, visibleRows - 1) * 6; + return visibleRows * MobileDesignGeometry.composerModelSelectorRowHeight + + Math.max(0, visibleRows - 1) * MobileDesignGeometry.composerModelSelectorRowGap; } private selectorModels(): ConversationUiModel[] { diff --git a/src/apps/mobile/harmonyos/entry/src/main/resources/base/element/color.json b/src/apps/mobile/harmonyos/entry/src/main/resources/base/element/color.json index 405eff8832..beabe161cf 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/resources/base/element/color.json +++ b/src/apps/mobile/harmonyos/entry/src/main/resources/base/element/color.json @@ -70,7 +70,7 @@ }, { "name": "modal_scrim", - "value": "#99000000" + "value": "#44000000" }, { "name": "soft", diff --git a/src/apps/mobile/harmonyos/entry/src/main/resources/dark/element/color.json b/src/apps/mobile/harmonyos/entry/src/main/resources/dark/element/color.json index 43cf074675..e29074a8f5 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/resources/dark/element/color.json +++ b/src/apps/mobile/harmonyos/entry/src/main/resources/dark/element/color.json @@ -70,7 +70,7 @@ }, { "name": "modal_scrim", - "value": "#99000000" + "value": "#44000000" }, { "name": "soft", diff --git a/src/apps/mobile/ios/BitFun.xcodeproj/project.pbxproj b/src/apps/mobile/ios/BitFun.xcodeproj/project.pbxproj index 17906867fc..1a9b7503fb 100644 --- a/src/apps/mobile/ios/BitFun.xcodeproj/project.pbxproj +++ b/src/apps/mobile/ios/BitFun.xcodeproj/project.pbxproj @@ -21,6 +21,10 @@ A10000000000000000000013 /* GeneratedMobileDesignTokens.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000013 /* GeneratedMobileDesignTokens.swift */; }; A10000000000000000000014 /* GeneratedMobilePreviewScenarios.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000014 /* GeneratedMobilePreviewScenarios.swift */; }; A10000000000000000000015 /* MobileDesignGallery.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000015 /* MobileDesignGallery.swift */; }; + A10000000000000000000016 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000016 /* Localizable.xcstrings */; }; + A10000000000000000000017 /* MobileLocalization.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000017 /* MobileLocalization.swift */; }; + A10000000000000000000018 /* AdaptiveModalComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000018 /* AdaptiveModalComponents.swift */; }; + A10000000000000000000019 /* SessionActionComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000019 /* SessionActionComponents.swift */; }; A10000000000000000000009 /* Resources.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000009 /* Resources.xcassets */; }; /* End PBXBuildFile section */ @@ -40,6 +44,10 @@ B10000000000000000000013 /* GeneratedMobileDesignTokens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeneratedMobileDesignTokens.swift; sourceTree = ""; }; B10000000000000000000014 /* GeneratedMobilePreviewScenarios.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeneratedMobilePreviewScenarios.swift; sourceTree = ""; }; B10000000000000000000015 /* MobileDesignGallery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MobileDesignGallery.swift; sourceTree = ""; }; + B10000000000000000000016 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; + B10000000000000000000017 /* MobileLocalization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MobileLocalization.swift; sourceTree = ""; }; + B10000000000000000000018 /* AdaptiveModalComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdaptiveModalComponents.swift; sourceTree = ""; }; + B10000000000000000000019 /* SessionActionComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionActionComponents.swift; sourceTree = ""; }; B10000000000000000000009 /* Resources.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Resources.xcassets; sourceTree = ""; }; /* End PBXFileReference section */ @@ -49,14 +57,15 @@ /* Begin PBXGroup section */ D10000000000000000000000 = {isa = PBXGroup; children = (D10000000000000000000001 /* BitFun */, D10000000000000000000009 /* Products */); sourceTree = ""; }; - D10000000000000000000001 /* BitFun */ = {isa = PBXGroup; children = (D10000000000000000000002 /* App */, D10000000000000000000003 /* Features */, D10000000000000000000008 /* Infrastructure */, B10000000000000000000009 /* Resources.xcassets */, B10000000000000000000011 /* BitFunMobileCore.xcframework */, B10000000000000000000012 /* libsqlite3.tbd */); path = BitFun; sourceTree = ""; }; + D10000000000000000000001 /* BitFun */ = {isa = PBXGroup; children = (D10000000000000000000002 /* App */, D10000000000000000000003 /* Features */, D10000000000000000000008 /* Infrastructure */, D10000000000000000000011 /* Resources */, B10000000000000000000009 /* Resources.xcassets */, B10000000000000000000011 /* BitFunMobileCore.xcframework */, B10000000000000000000012 /* libsqlite3.tbd */); path = BitFun; sourceTree = ""; }; D10000000000000000000002 /* App */ = {isa = PBXGroup; children = (B10000000000000000000001 /* BitFunApp.swift */); path = App; sourceTree = ""; }; D10000000000000000000003 /* Features */ = {isa = PBXGroup; children = (D10000000000000000000004 /* Chat */, D10000000000000000000005 /* Shell */, D10000000000000000000010 /* DesignSystem */); path = Features; sourceTree = ""; }; D10000000000000000000004 /* Chat */ = {isa = PBXGroup; children = (B10000000000000000000005 /* ConversationHeader.swift */, B10000000000000000000006 /* ChatTimelineView.swift */, B10000000000000000000007 /* ComposerBar.swift */); path = Chat; sourceTree = ""; }; - D10000000000000000000005 /* Shell */ = {isa = PBXGroup; children = (B10000000000000000000003 /* BitFunTheme.swift */, B10000000000000000000004 /* SidebarView.swift */, B10000000000000000000008 /* MobileShellView.swift */); path = Shell; sourceTree = ""; }; - D10000000000000000000008 /* Infrastructure */ = {isa = PBXGroup; children = (B10000000000000000000002 /* MobileAppModel.swift */, B10000000000000000000010 /* MobileCoreAdapter.swift */); path = Infrastructure; sourceTree = ""; }; + D10000000000000000000005 /* Shell */ = {isa = PBXGroup; children = (B10000000000000000000003 /* BitFunTheme.swift */, B10000000000000000000004 /* SidebarView.swift */, B10000000000000000000008 /* MobileShellView.swift */, B10000000000000000000019 /* SessionActionComponents.swift */); path = Shell; sourceTree = ""; }; + D10000000000000000000008 /* Infrastructure */ = {isa = PBXGroup; children = (B10000000000000000000002 /* MobileAppModel.swift */, B10000000000000000000010 /* MobileCoreAdapter.swift */, B10000000000000000000017 /* MobileLocalization.swift */); path = Infrastructure; sourceTree = ""; }; + D10000000000000000000011 /* Resources */ = {isa = PBXGroup; children = (B10000000000000000000016 /* Localizable.xcstrings */); path = Resources; sourceTree = ""; }; D10000000000000000000009 /* Products */ = {isa = PBXGroup; children = (B10000000000000000000000 /* BitFun.app */); name = Products; sourceTree = ""; }; - D10000000000000000000010 /* DesignSystem */ = {isa = PBXGroup; children = (B10000000000000000000013 /* GeneratedMobileDesignTokens.swift */, B10000000000000000000014 /* GeneratedMobilePreviewScenarios.swift */, B10000000000000000000015 /* MobileDesignGallery.swift */); path = DesignSystem; sourceTree = ""; }; + D10000000000000000000010 /* DesignSystem */ = {isa = PBXGroup; children = (B10000000000000000000013 /* GeneratedMobileDesignTokens.swift */, B10000000000000000000014 /* GeneratedMobilePreviewScenarios.swift */, B10000000000000000000015 /* MobileDesignGallery.swift */, B10000000000000000000018 /* AdaptiveModalComponents.swift */); path = DesignSystem; sourceTree = ""; }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -64,20 +73,20 @@ /* End PBXNativeTarget section */ /* Begin PBXProject section */ - E10000000000000000000000 /* Project object */ = {isa = PBXProject; attributes = {LastUpgradeCheck = 1640; TargetAttributes = {E10000000000000000000001 = {CreatedOnToolsVersion = 16.4; }; }; }; buildConfigurationList = F10000000000000000000001 /* Build configuration list for PBXProject "BitFun" */; compatibilityVersion = "Xcode 14.0"; developmentRegion = en; hasScannedForEncodings = 0; knownRegions = (en, Base); mainGroup = D10000000000000000000000; productRefGroup = D10000000000000000000009 /* Products */; projectDirPath = ""; projectRoot = ""; targets = (E10000000000000000000001 /* BitFun */); }; + E10000000000000000000000 /* Project object */ = {isa = PBXProject; attributes = {LastUpgradeCheck = 1640; TargetAttributes = {E10000000000000000000001 = {CreatedOnToolsVersion = 16.4; }; }; }; buildConfigurationList = F10000000000000000000001 /* Build configuration list for PBXProject "BitFun" */; compatibilityVersion = "Xcode 14.0"; developmentRegion = "zh-Hans"; hasScannedForEncodings = 0; knownRegions = (en, "zh-Hans", Base); mainGroup = D10000000000000000000000; productRefGroup = D10000000000000000000009 /* Products */; projectDirPath = ""; projectRoot = ""; targets = (E10000000000000000000001 /* BitFun */); }; /* End PBXProject section */ /* Begin PBXSourcesBuildPhase section */ - C10000000000000000000002 /* Sources */ = {isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = (A10000000000000000000001, A10000000000000000000002, A10000000000000000000003, A10000000000000000000004, A10000000000000000000005, A10000000000000000000006, A10000000000000000000007, A10000000000000000000008, A10000000000000000000010, A10000000000000000000013, A10000000000000000000014, A10000000000000000000015); runOnlyForDeploymentPostprocessing = 0; }; + C10000000000000000000002 /* Sources */ = {isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = (A10000000000000000000001, A10000000000000000000002, A10000000000000000000003, A10000000000000000000004, A10000000000000000000005, A10000000000000000000006, A10000000000000000000007, A10000000000000000000008, A10000000000000000000010, A10000000000000000000013, A10000000000000000000014, A10000000000000000000015, A10000000000000000000017, A10000000000000000000018, A10000000000000000000019); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXSourcesBuildPhase section */ /* Begin PBXResourcesBuildPhase section */ - C10000000000000000000003 /* Resources */ = {isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = (A10000000000000000000009); runOnlyForDeploymentPostprocessing = 0; }; + C10000000000000000000003 /* Resources */ = {isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = (A10000000000000000000009, A10000000000000000000016); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXResourcesBuildPhase section */ /* Begin XCBuildConfiguration section */ - F10000000000000000000003 /* Debug */ = {isa = XCBuildConfiguration; buildSettings = {ALWAYS_SEARCH_USER_PATHS = NO; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGNING_ALLOWED = NO; CODE_SIGNING_REQUIRED = NO; DEVELOPMENT_TEAM = ""; FRAMEWORK_SEARCH_PATHS = ( "$(SRCROOT)/../shared/core-feature/build/XCFrameworks/debug" ); INFOPLIST_FILE = BitFun/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 16.0; PRODUCT_BUNDLE_IDENTIFIER = com.bitfun.mobile.ios; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; }; - F10000000000000000000004 /* Release */ = {isa = XCBuildConfiguration; buildSettings = {ALWAYS_SEARCH_USER_PATHS = NO; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGNING_ALLOWED = NO; CODE_SIGNING_REQUIRED = NO; DEVELOPMENT_TEAM = ""; FRAMEWORK_SEARCH_PATHS = ( "$(SRCROOT)/../shared/core-feature/build/XCFrameworks/debug" ); INFOPLIST_FILE = BitFun/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 16.0; PRODUCT_BUNDLE_IDENTIFIER = com.bitfun.mobile.ios; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Release; }; + F10000000000000000000003 /* Debug */ = {isa = XCBuildConfiguration; buildSettings = {ALWAYS_SEARCH_USER_PATHS = NO; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; DEVELOPMENT_TEAM = ""; FRAMEWORK_SEARCH_PATHS = ( "$(SRCROOT)/../shared/core-feature/build/XCFrameworks/debug" ); INFOPLIST_FILE = BitFun/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 16.0; PRODUCT_BUNDLE_IDENTIFIER = com.bitfun.mobile.ios; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; }; + F10000000000000000000004 /* Release */ = {isa = XCBuildConfiguration; buildSettings = {ALWAYS_SEARCH_USER_PATHS = NO; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; DEVELOPMENT_TEAM = ""; FRAMEWORK_SEARCH_PATHS = ( "$(SRCROOT)/../shared/core-feature/build/XCFrameworks/debug" ); INFOPLIST_FILE = BitFun/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 16.0; PRODUCT_BUNDLE_IDENTIFIER = com.bitfun.mobile.ios; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Release; }; F10000000000000000000005 /* Target Debug */ = {isa = XCBuildConfiguration; buildSettings = {SDKROOT = iphoneos; }; name = Debug; }; F10000000000000000000006 /* Target Release */ = {isa = XCBuildConfiguration; buildSettings = {SDKROOT = iphoneos; }; name = Release; }; /* End XCBuildConfiguration section */ diff --git a/src/apps/mobile/ios/BitFun/App/BitFunApp.swift b/src/apps/mobile/ios/BitFun/App/BitFunApp.swift index 3ae79ea193..8c75663120 100644 --- a/src/apps/mobile/ios/BitFun/App/BitFunApp.swift +++ b/src/apps/mobile/ios/BitFun/App/BitFunApp.swift @@ -3,6 +3,7 @@ import SwiftUI @main struct BitFunApp: App { @StateObject private var model = MobileAppModel.launchConfigured + @Environment(\.scenePhase) private var scenePhase private let designPreviewScenario = Self.resolveDesignPreviewScenario() var body: some Scene { @@ -12,6 +13,8 @@ struct BitFunApp: App { .preferredColorScheme(scenario.appearance == "dark" ? .dark : .light) } else { MobileShellView(model: model) + .onChange(of: scenePhase) { model.handleScenePhase($0) } + .environment(\.locale, Locale(identifier: model.appLanguage.rawValue)) } } } diff --git a/src/apps/mobile/ios/BitFun/Features/Chat/ChatTimelineView.swift b/src/apps/mobile/ios/BitFun/Features/Chat/ChatTimelineView.swift index 905ad57424..49b2fae340 100644 --- a/src/apps/mobile/ios/BitFun/Features/Chat/ChatTimelineView.swift +++ b/src/apps/mobile/ios/BitFun/Features/Chat/ChatTimelineView.swift @@ -1,34 +1,71 @@ +import BitFunMobileCore import SwiftUI +import UIKit struct ChatTimelineView: View { @ObservedObject var model: MobileAppModel + @State private var userScrolledUp = false var body: some View { ScrollViewReader { proxy in ScrollView(showsIndicators: false) { - LazyVStack(spacing: 0) { - ForEach(model.messages) { message in - ChatMessageBubble(message: message) - .id(message.id) - } - if model.isSending { - HStack(spacing: 5) { - Circle().fill(BitFunTheme.muted).frame(width: 5, height: 5) - Circle().fill(BitFunTheme.muted).frame(width: 5, height: 5) - Circle().fill(BitFunTheme.muted).frame(width: 5, height: 5) + LazyVStack(spacing: MobileDesignGeometry.messageSpacing) { + if model.surface == .remote && model.remoteHasMoreMessages { + Button { model.loadOlderRemoteMessages() } label: { + HStack(spacing: 7) { + if model.busy { ProgressView().controlSize(.small) } + Text(model.localized(model.busy ? "正在加载" : "加载更早消息")) + .font(MobileDesignTypography.labelSmall.font) + } + .foregroundStyle(BitFunTheme.muted) + .frame(maxWidth: .infinity, minHeight: 38) } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 20) - .padding(.vertical, 15) + .buttonStyle(.plain) + .disabled(model.busy) + } + ForEach(model.timelineRows) { row in + ConversationRowView(row: row, model: model).id(row.id) } + if model.timelineRows.isEmpty && model.isSending { + TypingIndicator().frame(maxWidth: .infinity, alignment: .leading) + } + Color.clear.frame(height: 1).id("timeline-bottom") } .padding(.horizontal, MobileDesignGeometry.contentGutter) .padding(.top, MobileDesignGeometry.timelineTopPadding) .padding(.bottom, 14) } - .onChange(of: model.messages.count) { _ in - if let id = model.messages.last?.id { - withAnimation(.easeOut(duration: 0.18)) { proxy.scrollTo(id, anchor: .bottom) } + .simultaneousGesture( + DragGesture(minimumDistance: 8).onChanged { value in + if value.translation.height < -8 { userScrolledUp = true } + } + ) + .onChange(of: model.timelineRows) { _ in + guard !userScrolledUp else { return } + withAnimation(.easeOut(duration: 0.18)) { + proxy.scrollTo("timeline-bottom", anchor: .bottom) + } + } + .overlay(alignment: .bottomTrailing) { + if userScrolledUp { + Button { + userScrolledUp = false + withAnimation(.easeOut(duration: 0.18)) { + proxy.scrollTo("timeline-bottom", anchor: .bottom) + } + } label: { + Image(systemName: "chevron.down") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(BitFunTheme.ink) + .frame(width: 36, height: 36) + .background(BitFunTheme.card) + .clipShape(Circle()) + .overlay(Circle().stroke(BitFunTheme.line, lineWidth: 1)) + .shadow(color: .black.opacity(0.09), radius: 8, y: 3) + } + .buttonStyle(.plain) + .padding(18) + .accessibilityLabel(Text(model.localized("滚动到底部"))) } } } @@ -36,29 +73,755 @@ struct ChatTimelineView: View { } } -private struct ChatMessageBubble: View { - let message: ChatMessage +private struct ConversationRowView: View { + let row: MobileConversationRow + @ObservedObject var model: MobileAppModel var body: some View { - VStack(alignment: message.role == .user ? .trailing : .leading, spacing: 0) { - Text(message.text) + switch row.kind { + case "EMPTY": EmptyConversationRow() + case "USER": userRow + default: assistantRow + } + } + + private var userRow: some View { + VStack(alignment: .trailing, spacing: 7) { + if !row.images.isEmpty { TimelineImageGrid(images: row.images) } + if !row.text.isEmpty { + Text(row.text) + .font(MobileDesignTypography.bodyMedium.font) + .foregroundStyle(BitFunTheme.ink) + .lineSpacing(MobileDesignTypography.bodyMedium.lineSpacing) + .textSelection(.enabled) + } + if row.pending { + Text(model.localized("正在发送")) + .font(MobileDesignTypography.labelSmall.font) + .foregroundStyle(BitFunTheme.muted) + } + if row.showRetry { + Button { model.retryMessage(row.text) } label: { + Label(model.localized("重新发送"), systemImage: "arrow.clockwise") + .font(MobileDesignTypography.labelSmall.font) + .foregroundStyle(BitFunTheme.red) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, MobileDesignGeometry.messageBubbleHorizontalPadding) + .padding(.vertical, MobileDesignGeometry.messageBubbleVerticalPadding) + .frame(maxWidth: MobileDesignGeometry.messageBubbleMaxWidth, alignment: .trailing) + .background(BitFunTheme.soft) + .clipShape(RoundedRectangle(cornerRadius: MobileDesignGeometry.messageBubbleRadius)) + .frame(maxWidth: .infinity, alignment: .trailing) + } + + private var assistantRow: some View { + VStack(alignment: .leading, spacing: 10) { + if row.typing { + TypingIndicator() + } else if !row.blocks.isEmpty { + MessageBlockList(blocks: row.blocks, model: model) + } else { + if let thinking = row.thinking, !thinking.isEmpty { + ThinkingBlock(text: thinking, streaming: row.streaming) + } + if !row.text.isEmpty { MarkdownMessageView(text: row.text, model: model) } + if !row.tools.isEmpty { ToolStatusList(tools: row.tools, model: model) } + } + if !row.images.isEmpty { TimelineImageGrid(images: row.images) } + if row.showRetry { + Button { model.retryMessage(row.text) } label: { + Label(model.localized("重试"), systemImage: "arrow.clockwise") + .font(MobileDesignTypography.labelSmall.font) + .foregroundStyle(BitFunTheme.red) + } + .buttonStyle(.plain) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +private struct EmptyConversationRow: View { + var body: some View { + VStack(spacing: 8) { + Image(systemName: "sparkles").font(.system(size: 23, weight: .medium)) + Text(MobileLocalization.text("从这里开始新的对话")) .font(MobileDesignTypography.bodyMedium.font) + } + .foregroundStyle(BitFunTheme.muted) + .frame(maxWidth: .infinity, minHeight: 180) + } +} + +private struct MessageBlockList: View { + let blocks: [MobileTimelineBlock] + @ObservedObject var model: MobileAppModel + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + ForEach(blocks) { block in + switch block { + case let .text(_, text, _): + if !text.isEmpty { MarkdownMessageView(text: text, model: model) } + case let .thinking(_, text, streaming): + ThinkingBlock(text: text, streaming: streaming) + case let .tools(_, tools): + ToolStatusList(tools: tools, model: model) + case let .subagent(_, title, running, text, children): + SubagentBlock(title: title, running: running, text: text, children: children, model: model) + } + } + } + } +} + +private struct SubagentBlock: View { + let title: String + let running: Bool + let text: String + let children: [MobileTimelineBlock] + @ObservedObject var model: MobileAppModel + @State private var expanded = true + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Button { withAnimation(.easeOut(duration: 0.18)) { expanded.toggle() } } label: { + HStack(spacing: 7) { + Image(systemName: "person.2.fill").font(.system(size: 12, weight: .medium)) + Text(title.isEmpty ? model.localized("子任务") : title) + .font(MobileDesignTypography.labelMedium.font).lineLimit(1) + Spacer() + if running { ProgressView().controlSize(.mini) } + Image(systemName: expanded ? "chevron.up" : "chevron.down") + .font(.system(size: 11, weight: .semibold)) + } + .foregroundStyle(BitFunTheme.muted) + .frame(minHeight: 32) + } + .buttonStyle(.plain) + if expanded { + if !text.isEmpty { MarkdownMessageView(text: text, model: model) } + if !children.isEmpty { MessageBlockList(blocks: children, model: model) } + } + } + .padding(.leading, 12) + .overlay(alignment: .leading) { Rectangle().fill(BitFunTheme.line).frame(width: 2) } + } +} + +private struct ThinkingBlock: View { + let text: String + let streaming: Bool + @State private var expanded: Bool + + init(text: String, streaming: Bool) { + self.text = text + self.streaming = streaming + _expanded = State(initialValue: streaming) + } + + var body: some View { + VStack(alignment: .leading, spacing: 5) { + Button { withAnimation(.easeOut(duration: 0.18)) { expanded.toggle() } } label: { + HStack(spacing: 7) { + if streaming { ProgressView().controlSize(.mini) } + Image(systemName: "sparkles").font(.system(size: 12, weight: .medium)) + Text(MobileLocalization.text(streaming ? "正在思考" : "思考过程")) + .font(MobileDesignTypography.labelMedium.font) + Spacer() + Image(systemName: expanded ? "chevron.up" : "chevron.down") + .font(.system(size: 11, weight: .semibold)) + } + .foregroundStyle(BitFunTheme.muted) + .frame(minHeight: 32) + } + .buttonStyle(.plain) + if expanded { + Text(text) + .font(MobileDesignTypography.bodyMedium.font) + .foregroundStyle(BitFunTheme.muted) + .lineSpacing(MobileDesignTypography.bodyMedium.lineSpacing) + .textSelection(.enabled) + } + } + } +} + +private struct TypingIndicator: View { + var body: some View { + TimelineView(.periodic(from: .now, by: 0.35)) { context in + let phase = Int(context.date.timeIntervalSinceReferenceDate / 0.35) % 3 + HStack(spacing: 5) { + ForEach(0..<3) { index in + Circle().fill(BitFunTheme.muted).frame(width: 5, height: 5) + .opacity(index == phase ? 1 : 0.32) + } + } + .frame(height: 32) + } + .accessibilityLabel(Text(MobileLocalization.text("正在回复"))) + } +} + +struct MarkdownMessageView: View { + let text: String + @ObservedObject var model: MobileAppModel + + private var blocks: [MarkdownBlock] { MarkdownParser.shared.parse(text: text) } + private var references: [MessageFileReference] { + MessageFileReferenceProjector.shared.project(source: text) + } + + var body: some View { + VStack(alignment: .leading, spacing: 9) { + ForEach(blocks, id: \.id) { MarkdownBlockView(block: $0) } + ForEach(references, id: \.id) { FileReferenceCard(reference: $0, model: model) } + } + .frame(maxWidth: .infinity, alignment: .leading) + .environment(\.openURL, OpenURLAction { url in + if url.scheme?.lowercased() == "computer" { + model.openRemoteFile(reference: url.absoluteString, label: url.lastPathComponent) + return .handled + } + return .systemAction + }) + } +} + +private struct MarkdownBlockView: View { + let block: MarkdownBlock + + var body: some View { + switch block.type { + case "heading": + Text(inlineString(block.inlines)) + .font(.system(size: headingSize, weight: .bold)) + .foregroundStyle(BitFunTheme.ink).textSelection(.enabled) + case "quote": + Text(inlineString(block.inlines)) + .font(MobileDesignTypography.bodyMedium.font).foregroundStyle(BitFunTheme.muted) + .lineSpacing(MobileDesignTypography.bodyMedium.lineSpacing).padding(.leading, 12) + .overlay(alignment: .leading) { Rectangle().fill(BitFunTheme.line).frame(width: 2) } + .textSelection(.enabled) + case "list": + VStack(alignment: .leading, spacing: 5) { + ForEach(block.items, id: \.id) { item in + HStack(alignment: .firstTextBaseline, spacing: 7) { + Text(item.marker).foregroundStyle(BitFunTheme.muted) + .frame(width: 20, alignment: .trailing) + Text(inlineString(item.inlines)).foregroundStyle(BitFunTheme.ink) + .lineSpacing(MobileDesignTypography.bodyMedium.lineSpacing) + .textSelection(.enabled) + } + .font(MobileDesignTypography.bodyMedium.font) + } + } + case "code": CodeBlock(language: block.language, code: block.text) + case "table": + ScrollView(.horizontal, showsIndicators: false) { + Text(block.text).font(.system(size: 12.5, design: .monospaced)) + .foregroundStyle(BitFunTheme.ink).padding(12).textSelection(.enabled) + } + .background(BitFunTheme.soft).clipShape(RoundedRectangle(cornerRadius: 12)) + case "divider": Rectangle().fill(BitFunTheme.line).frame(height: 1).padding(.vertical, 3) + default: + Text(inlineString(block.inlines)) + .font(MobileDesignTypography.bodyMedium.font).foregroundStyle(BitFunTheme.ink) + .lineSpacing(MobileDesignTypography.bodyMedium.lineSpacing).textSelection(.enabled) + } + } + + private var headingSize: CGFloat { + switch block.level { case 1: 18; case 2: 16; default: 15 } + } + + private func inlineString(_ inlines: [MarkdownInline]) -> AttributedString { + var result = AttributedString() + for inline in inlines { + var part = AttributedString(inline.text) + switch inline.type { + case "strong": part.font = .system(size: 14, weight: .semibold) + case "emphasis": part.font = .system(size: 14).italic() + case "code": + part.font = .system(size: 13, design: .monospaced) + part.backgroundColor = BitFunTheme.soft + case "link": + part.foregroundColor = MobileDesignColors.fileLink + part.underlineStyle = .single + part.link = URL(string: inline.url) + default: break + } + result.append(part) + } + return result.characters.isEmpty ? AttributedString(block.text) : result + } +} + +private struct CodeBlock: View { + let language: String + let code: String + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + HStack { + Text(language.isEmpty ? MobileLocalization.text("代码") : language) + .font(MobileDesignTypography.labelSmall.font).foregroundStyle(BitFunTheme.muted) + Spacer() + Button { UIPasteboard.general.string = code } label: { + Label(MobileLocalization.text("复制"), systemImage: "doc.on.doc") + .font(MobileDesignTypography.labelSmall.font).foregroundStyle(BitFunTheme.muted) + } + .buttonStyle(.plain) + } + .padding(.horizontal, 12).frame(height: 34) + Rectangle().fill(BitFunTheme.line).frame(height: 1) + ScrollView(.horizontal, showsIndicators: false) { + Text(code).font(.system(size: 12.5, design: .monospaced)) + .foregroundStyle(BitFunTheme.ink).padding(12).textSelection(.enabled) + } + } + .background(BitFunTheme.soft).clipShape(RoundedRectangle(cornerRadius: 12)) + .overlay(RoundedRectangle(cornerRadius: 12).stroke(BitFunTheme.line, lineWidth: 1)) + } +} + +private struct FileReferenceCard: View { + let reference: MessageFileReference + @ObservedObject var model: MobileAppModel + + var body: some View { + HStack(spacing: 10) { + Button { model.openRemoteFile(reference: reference.reference, label: reference.label) } label: { + HStack(spacing: 10) { + Image(systemName: "doc.text") + .font(.system(size: 16, weight: .medium)).foregroundStyle(MobileDesignColors.fileLink) + .frame(width: 34, height: 34).background(MobileDesignColors.fileLink.opacity(0.1)) + .clipShape(RoundedRectangle(cornerRadius: 9)) + VStack(alignment: .leading, spacing: 2) { + Text(reference.label).font(MobileDesignTypography.labelMedium.font) + .foregroundStyle(BitFunTheme.ink).lineLimit(1) + Text(reference.remotePath).font(MobileDesignTypography.labelSmall.font) + .foregroundStyle(BitFunTheme.muted).lineLimit(1) + if let status = model.downloadStatus(for: reference.remotePath) { + Text(status).font(MobileDesignTypography.labelSmall.font) + .foregroundStyle(model.downloadPhase == .failed ? BitFunTheme.red : BitFunTheme.muted) + .lineLimit(1) + } + } + Spacer(minLength: 0) + } + } + .buttonStyle(.plain) + Button { model.downloadRemoteFile(reference: reference.reference, label: reference.label) } label: { + Group { + if model.downloadStatus(for: reference.remotePath) != nil, + [.preparing, .downloading, .saving].contains(model.downloadPhase) { + ProgressView().controlSize(.small) + } else if model.downloadStatus(for: reference.remotePath) != nil, + model.downloadPhase == .saved { + Image(systemName: "checkmark.circle") + } else { + Image(systemName: "arrow.down.circle") + } + } + .font(.system(size: 18, weight: .medium)) + .foregroundStyle(BitFunTheme.muted).frame(width: 40, height: 40) + } + .buttonStyle(.plain) + .accessibilityLabel(model.localizedFormat("下载 %@", reference.label)) + } + .padding(.leading, 10).padding(.trailing, 4).padding(.vertical, 7) + .background(BitFunTheme.card).clipShape(RoundedRectangle(cornerRadius: 14)) + .overlay(RoundedRectangle(cornerRadius: 14).stroke(BitFunTheme.line, lineWidth: 1)) + } +} + +private struct TimelineImageGrid: View { + let images: [MobileTimelineImage] + @State private var selected: MobileTimelineImage? + + var body: some View { + LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 7) { + ForEach(images) { image in + Button { selected = image } label: { + if let uiImage = image.uiImage { + Image(uiImage: uiImage).resizable().scaledToFill() + .frame(height: images.count == 1 ? 180 : 112).frame(maxWidth: .infinity) + .clipped().clipShape(RoundedRectangle(cornerRadius: 14)) + } else { + Image(systemName: "photo").foregroundStyle(BitFunTheme.muted) + .frame(maxWidth: .infinity, minHeight: 112).background(BitFunTheme.soft) + .clipShape(RoundedRectangle(cornerRadius: 14)) + } + } + .buttonStyle(.plain) + } + } + .fullScreenCover(item: $selected) { FullScreenTimelineImage(image: $0) } + } +} + +private struct FullScreenTimelineImage: View { + let image: MobileTimelineImage + @Environment(\.dismiss) private var dismiss + + var body: some View { + ZStack(alignment: .topTrailing) { + Color.black.ignoresSafeArea() + if let uiImage = image.uiImage { Image(uiImage: uiImage).resizable().scaledToFit().ignoresSafeArea() } + Button { dismiss() } label: { + Image(systemName: "xmark").font(.system(size: 15, weight: .semibold)).foregroundStyle(.white) + .frame(width: 44, height: 44).background(Color.black.opacity(0.55)).clipShape(Circle()) + } + .buttonStyle(.plain).padding(20) + } + } +} + +private extension MobileTimelineImage { + var uiImage: UIImage? { + guard let marker = dataURL.range(of: "base64,") else { return nil } + return Data(base64Encoded: String(dataURL[marker.upperBound...])).flatMap(UIImage.init(data:)) + } +} + +private enum ToolDisplayRow: Identifiable { + case tool(MobileTimelineTool) + case collapsed(id: String, tools: [MobileTimelineTool]) + var id: String { + switch self { case let .tool(tool): tool.id; case let .collapsed(id, _): id } + } +} + +private struct ToolStatusList: View { + let tools: [MobileTimelineTool] + @ObservedObject var model: MobileAppModel + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + ForEach(displayRows) { row in + switch row { + case let .tool(tool): ToolStatusRow(tool: tool, model: model) + case let .collapsed(_, tools): CollapsedToolsRow(tools: tools, model: model) + } + } + } + } + + private var displayRows: [ToolDisplayRow] { + var result: [ToolDisplayRow] = [] + var pending: [MobileTimelineTool] = [] + func flush() { + if pending.count < 2 { result.append(contentsOf: pending.map(ToolDisplayRow.tool)) } + else if let first = pending.first { + result.append(.collapsed(id: "collapsed-\(first.id)-\(pending.count)", tools: pending)) + } + pending.removeAll() + } + for tool in tools { + let collapsible = tool.actions.isEmpty + && ["COMPLETED", "CANCELLED"].contains(tool.phase) + && ["DOCUMENT", "FOLDER", "SEARCH"].contains(tool.kind) + if collapsible { pending.append(tool) } else { flush(); result.append(.tool(tool)) } + } + flush() + return result + } +} + +private struct CollapsedToolsRow: View { + let tools: [MobileTimelineTool] + @ObservedObject var model: MobileAppModel + @State private var expanded = false + + var body: some View { + VStack(alignment: .leading, spacing: 3) { + Button { withAnimation(.easeOut(duration: 0.18)) { expanded.toggle() } } label: { + HStack(spacing: 8) { + Image(systemName: "doc.on.doc").font(.system(size: 12, weight: .medium)) + .frame(width: 20, height: 20).background(BitFunTheme.soft) + .clipShape(RoundedRectangle(cornerRadius: 6)) + Text(model.localizedFormat("已完成 %lld 项读取与搜索", Int64(tools.count))) + .font(MobileDesignTypography.bodySmall.font) + Spacer() + Image(systemName: expanded ? "chevron.up" : "chevron.down") + .font(.system(size: 10, weight: .semibold)) + } + .foregroundStyle(BitFunTheme.muted).frame(minHeight: 32) + } + .buttonStyle(.plain) + if expanded { ForEach(tools) { ToolStatusRow(tool: $0, model: model) } } + } + } +} + +private struct ToolStatusRow: View { + let tool: MobileTimelineTool + @ObservedObject var model: MobileAppModel + @State private var expanded = false + @State private var answer = "" + @State private var selectedOptions: [Int: Set] = [:] + @State private var otherAnswers: [Int: String] = [:] + + private var emphasized: Bool { !tool.actions.isEmpty || expanded || tool.phase == "FAILED" } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Button { + if !tool.input.isEmpty || !tool.output.isEmpty || !tool.filePath.isEmpty { + withAnimation(.easeOut(duration: 0.18)) { expanded.toggle() } + } + } label: { + HStack(spacing: 8) { + Image(systemName: toolSymbol).font(.system(size: 12, weight: .medium)) + .foregroundStyle(statusColor).frame(width: 20, height: 20) + .background(statusColor.opacity(0.09)).clipShape(RoundedRectangle(cornerRadius: 6)) + Text(statusLabel).font(MobileDesignTypography.bodySmall.font) + .foregroundStyle(BitFunTheme.ink).lineLimit(1) + Spacer(minLength: 4) + if tool.phase == "RUNNING" { ProgressView().controlSize(.mini) } + else { Text(statusMark).font(.system(size: 11, weight: .semibold)).foregroundStyle(statusColor) } + } + .frame(minHeight: 32) + } + .buttonStyle(.plain) + + if expanded { + if !tool.filePath.isEmpty { + Button { model.openRemoteFile(reference: tool.filePath, label: tool.fileLabel) } label: { + Label(tool.fileLabel.isEmpty ? tool.filePath : tool.fileLabel, systemImage: "doc.text") + .font(MobileDesignTypography.labelSmall.font).foregroundStyle(MobileDesignColors.fileLink) + } + .buttonStyle(.plain) + } + if !tool.input.isEmpty { detailText(model.localized("输入"), tool.input) } + if !tool.output.isEmpty { detailText(model.localized("输出"), tool.output) } + } + + if tool.actions.contains("ANSWER") { + if tool.questions.isEmpty { + legacyAnswerPanel + } else { + structuredAnswerPanel + } + } else if tool.actions.contains("APPROVE") || tool.actions.contains("REJECT") { + HStack(spacing: 8) { + if tool.actions.contains("REJECT") { toolAction(model.localized("拒绝"), primary: false) { model.rejectTool(tool.id) } } + if tool.actions.contains("APPROVE") { toolAction(model.localized("允许"), primary: true) { model.approveTool(tool.id) } } + } + } + if tool.actions.contains("CANCEL") { + Button { model.cancelTool(tool.id) } label: { + Text(model.localized("停止执行")) + .font(MobileDesignTypography.labelMedium.font).foregroundStyle(BitFunTheme.red) + .frame(maxWidth: .infinity, minHeight: 40).background(BitFunTheme.card).clipShape(Capsule()) + .overlay(Capsule().stroke(BitFunTheme.red.opacity(0.5), lineWidth: 1)) + } + .buttonStyle(.plain) + } + } + .padding(emphasized ? 10 : 0).background(emphasized ? BitFunTheme.soft : Color.clear) + .clipShape(RoundedRectangle(cornerRadius: 14)) + .overlay { if emphasized { RoundedRectangle(cornerRadius: 14).stroke(BitFunTheme.line, lineWidth: 1) } } + } + + private var legacyAnswerPanel: some View { + VStack(alignment: .leading, spacing: 8) { + Text(tool.question ?? model.localized("请输入回复")).font(MobileDesignTypography.bodySmall.font) .foregroundStyle(BitFunTheme.ink) - .lineSpacing(MobileDesignTypography.bodyMedium.lineSpacing) - .padding(.horizontal, MobileDesignGeometry.messageBubbleHorizontalPadding) - .padding(.vertical, MobileDesignGeometry.messageBubbleVerticalPadding) - .background(message.role == .user ? BitFunTheme.soft : BitFunTheme.card) - .clipShape(RoundedRectangle(cornerRadius: MobileDesignGeometry.messageBubbleRadius)) - .overlay( - RoundedRectangle(cornerRadius: MobileDesignGeometry.messageBubbleRadius) - .stroke(BitFunTheme.line, lineWidth: 1) - ) - .frame( - maxWidth: MobileDesignGeometry.messageBubbleMaxWidth, - alignment: message.role == .user ? .trailing : .leading - ) - } - .frame(maxWidth: .infinity, alignment: message.role == .user ? .trailing : .leading) - .padding(.bottom, MobileDesignGeometry.messageSpacing) + TextField(model.localized("回复"), text: $answer, axis: .vertical) + .font(MobileDesignTypography.bodyMedium.font).lineLimit(2...5).padding(10) + .background(BitFunTheme.card).clipShape(RoundedRectangle(cornerRadius: 11)) + .overlay(RoundedRectangle(cornerRadius: 11).stroke(BitFunTheme.line, lineWidth: 1)) + Button { model.answerTool(tool.id, answer: answer); answer = "" } label: { + Text(model.localized("发送回复")) + .font(MobileDesignTypography.labelMedium.font).foregroundStyle(.white) + .frame(maxWidth: .infinity, minHeight: 40) + .background(answer.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || model.busy ? BitFunTheme.muted : BitFunTheme.accent) + .clipShape(Capsule()) + } + .buttonStyle(.plain).disabled(answer.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || model.busy) + } + } + + private var structuredAnswerPanel: some View { + VStack(alignment: .leading, spacing: 13) { + ForEach(tool.questions) { question in + VStack(alignment: .leading, spacing: 7) { + if !question.header.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + Text(question.header).font(MobileDesignTypography.labelMedium.font).foregroundStyle(BitFunTheme.muted) + } + Text(question.question).font(MobileDesignTypography.bodySmall.font).foregroundStyle(BitFunTheme.ink) + ForEach(options(for: question)) { option in + let selected = selectedOptions[question.index, default: []].contains(option.label) + Button { toggle(option.label, for: question) } label: { + HStack(alignment: .top, spacing: 9) { + Image(systemName: selected ? (question.multiSelect ? "checkmark.square.fill" : "largecircle.fill.circle") : (question.multiSelect ? "square" : "circle")) + .foregroundStyle(selected ? BitFunTheme.accent : BitFunTheme.muted) + VStack(alignment: .leading, spacing: 2) { + Text(option.label).font(MobileDesignTypography.bodySmall.font).foregroundStyle(BitFunTheme.ink) + if let description = option.description, !description.isEmpty { + Text(description).font(MobileDesignTypography.labelSmall.font).foregroundStyle(BitFunTheme.muted) + } + } + Spacer(minLength: 0) + } + .padding(.vertical, 5) + } + .buttonStyle(.plain) + .disabled(model.busy) + if selected && isOther(option) { + TextField(model.localized("请输入回复"), text: Binding( + get: { otherAnswers[question.index, default: ""] }, + set: { otherAnswers[question.index] = $0 } + )) + .font(MobileDesignTypography.bodySmall.font).padding(9) + .disabled(model.busy) + .background(BitFunTheme.card).clipShape(RoundedRectangle(cornerRadius: 9)) + .overlay(RoundedRectangle(cornerRadius: 9).stroke(BitFunTheme.line, lineWidth: 1)) + } + } + } + } + Button { submitStructuredAnswers() } label: { + HStack { + if model.busy { ProgressView().controlSize(.small).tint(.white) } + Text(model.localized("发送回复")) + } + .font(MobileDesignTypography.labelMedium.font).foregroundStyle(.white) + .frame(maxWidth: .infinity, minHeight: 40) + .background(structuredAnswersValid && !model.busy ? BitFunTheme.accent : BitFunTheme.muted) + .clipShape(Capsule()) + } + .buttonStyle(.plain).disabled(!structuredAnswersValid || model.busy) + } + } + + private func options(for question: MobileTimelineQuestion) -> [MobileTimelineOption] { + question.options.contains(where: isOther) ? question.options : question.options + [MobileTimelineOption(label: model.localized("其他"), description: nil)] + } + + private func isOther(_ option: MobileTimelineOption) -> Bool { + let normalized = option.label.trimmingCharacters(in: .whitespacesAndNewlines) + let localizedOther = model.localized("其他").trimmingCharacters(in: .whitespacesAndNewlines) + return normalized.lowercased() == "other" || normalized == "其他" || normalized == localizedOther + } + + private func toggle(_ label: String, for question: MobileTimelineQuestion) { + guard !model.busy else { return } + if question.multiSelect { + if selectedOptions[question.index, default: []].contains(label) { + selectedOptions[question.index]?.remove(label) + } else { + selectedOptions[question.index, default: []].insert(label) + } + } else { + selectedOptions[question.index] = [label] + } + } + + private var structuredAnswersValid: Bool { + tool.questions.allSatisfy { question in + let selected = selectedOptions[question.index, default: []] + guard !selected.isEmpty else { return false } + return !selected.contains(where: { label in + isOther(MobileTimelineOption(label: label, description: nil)) && + otherAnswers[question.index, default: ""].trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + }) + } + } + + private func submitStructuredAnswers() { + let answers = tool.questions.map { question in + let selected = selectedOptions[question.index, default: []] + let values = selected.map { label in + isOther(MobileTimelineOption(label: label, description: nil)) + ? otherAnswers[question.index, default: ""].trimmingCharacters(in: .whitespacesAndNewlines) + : label + } + let value: QuestionAnswerValue = question.multiSelect + ? QuestionAnswerValueChoice(values: values) + : QuestionAnswerValueText(text: values[0]) + return QuestionAnswer(index: Int32(question.index), value: value) + } + model.answerTool(tool.id, answers: answers) + } + + @ViewBuilder + private func detailText(_ title: String, _ value: String) -> some View { + VStack(alignment: .leading, spacing: 3) { + Text(title).font(MobileDesignTypography.labelSmall.font).foregroundStyle(BitFunTheme.muted) + Text(value).font(.system(size: 12.5, design: .monospaced)).foregroundStyle(BitFunTheme.ink) + .lineLimit(5).textSelection(.enabled) + } + } + + private func toolAction(_ label: String, primary: Bool, action: @escaping () -> Void) -> some View { + Button(action: action) { + Text(label).font(MobileDesignTypography.labelMedium.font) + .foregroundStyle(primary ? Color.white : BitFunTheme.ink) + .frame(maxWidth: .infinity, minHeight: 40).background(primary ? BitFunTheme.accent : BitFunTheme.card) + .clipShape(Capsule()).overlay { if !primary { Capsule().stroke(BitFunTheme.line, lineWidth: 1) } } + } + .buttonStyle(.plain) + } + + private var operationLabel: String { + switch tool.operation { + case "UPDATE_TODOS": model.localized("更新待办") + case "START_TASK": model.localized("启动子任务") + case "READ_FILE": model.localized("读取文件") + case "WRITE_FILE": model.localized("写入文件") + case "DELETE_FILE": model.localized("删除文件") + case "VIEW_DIFF": model.localized("查看差异") + case "EDIT_FILE": model.localized("编辑文件") + case "RUN_COMMAND": model.localized("运行命令") + case "SEARCH_WEB": model.localized("搜索网页") + case "OPEN_WEB": model.localized("打开网页") + case "SEARCH_CODE": model.localized("搜索代码") + case "ASK_CONFIRMATION": model.localized("请求确认") + default: tool.name.isEmpty ? model.localized("工具") : tool.name + } + } + + private var statusLabel: String { + let target = tool.target.isEmpty ? "" : " · \(tool.target)" + return switch tool.phase { + case "RUNNING": model.localizedFormat("正在%@%@", operationLabel, target) + case "FAILED": model.localizedFormat("%@失败%@", operationLabel, target) + case "PENDING_CONFIRMATION": model.localizedFormat("等待确认 · %@", operationLabel) + case "WAITING": model.localizedFormat("等待执行 · %@", operationLabel) + default: "\(operationLabel)\(target)" + } + } + + private var toolSymbol: String { + switch tool.kind { + case "QUESTION": "questionmark.circle" + case "TODO": "checklist" + case "TASK": "person.2" + case "GIT": "arrow.triangle.branch" + case "DELETE": "trash" + case "DIFF": "doc.text.magnifyingglass" + case "PATCH", "COMMAND": "terminal" + case "CREATE": "doc.badge.plus" + case "MUTATE": "square.and.pencil" + case "FOLDER": "folder" + case "DOCUMENT": "doc.text" + case "SEARCH": "magnifyingglass" + case "WEB": "link" + default: "wrench.and.screwdriver" + } + } + + private var statusColor: Color { + switch tool.phase { case "FAILED": BitFunTheme.red; case "COMPLETED": BitFunTheme.green; default: BitFunTheme.muted } + } + + private var statusMark: String { + switch tool.phase { case "FAILED": "!"; case "PENDING_CONFIRMATION": "?"; case "CANCELLED": "×"; case "COMPLETED": "✓"; default: "•" } } } diff --git a/src/apps/mobile/ios/BitFun/Features/Chat/ComposerBar.swift b/src/apps/mobile/ios/BitFun/Features/Chat/ComposerBar.swift index 1796c4e665..c0a74f7c49 100644 --- a/src/apps/mobile/ios/BitFun/Features/Chat/ComposerBar.swift +++ b/src/apps/mobile/ios/BitFun/Features/Chat/ComposerBar.swift @@ -1,71 +1,557 @@ +import AVFoundation +import PhotosUI +import Speech import SwiftUI +import UniformTypeIdentifiers struct ComposerBar: View { @ObservedObject var model: MobileAppModel + @Environment(\.horizontalSizeClass) private var horizontalSizeClass @FocusState private var focused: Bool + @StateObject private var speech = SpeechInputController() + @State private var pickerItems: [PhotosPickerItem] = [] + @State private var modelSelectorOpen = ProcessInfo.processInfo.arguments.contains( + "--composer-model-picker" + ) || ProcessInfo.processInfo.environment["BITFUN_COMPOSER_MODEL_PICKER"] == "1" - var body: some View { - let placeholder = model.surface == .remote + private var placeholder: String { + model.localized(model.surface == .remote ? "向 BitFun 提问" - : (model.localSessionSelected ? "输入消息" : "问问 BitFun") + : (model.localSessionSelected ? "输入消息" : "问问 BitFun")) + } + + private var expanded: Bool { + focused || modelSelectorOpen || model.draft.contains("\n") + } + + private var hasContent: Bool { + !model.draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || + !model.composerImages.isEmpty + } + + private var canSend: Bool { + hasContent && !model.busy && !model.isSending && + (model.surface == .local || model.connectionPhase != .disconnected) + } + + var body: some View { + VStack(spacing: 2) { + if !model.composerImages.isEmpty { + attachmentStrip + } + + if expanded { + expandedInputRow + expandedActionRow + .transition(.move(edge: .bottom).combined(with: .opacity)) + } else { + collapsedRow + } + } + .padding(.horizontal, 8) + .padding(.top, expanded ? 4 : 0) + .padding(.bottom, expanded ? 2 : 0) + .frame(minHeight: expanded + ? MobileDesignGeometry.composerExpandedHeight + : MobileDesignGeometry.composerCollapsedHeight) + .background(BitFunTheme.card) + .clipShape( + RoundedRectangle( + cornerRadius: expanded || !model.composerImages.isEmpty + ? MobileDesignGeometry.composerExpandedRadius + : MobileDesignGeometry.composerCollapsedRadius + ) + ) + .shadow(color: .black.opacity(0.05), radius: 10, y: 2) + .padding(.horizontal, MobileDesignGeometry.contentGutter) + .padding(.top, 8) + .padding(.bottom, 14) + .background(BitFunTheme.page) + .animation(.easeOut(duration: 0.22), value: expanded) + .animation(.easeOut(duration: 0.18), value: model.composerImages.count) + .onAppear { + if model.composerModelPickerPreview { + modelSelectorOpen = true + } + } + .onChange(of: pickerItems) { items in + guard !items.isEmpty else { return } + Task { await importPickedImages(items) } + } + .overlayPreferenceValue(ComposerModelSelectorAnchorKey.self) { anchor in + GeometryReader { proxy in + if horizontalSizeClass == .regular, modelSelectorOpen, let anchor { + let frame = proxy[anchor] + modelSelector(asSheet: false) + .frame( + width: MobileDesignGeometry.composerModelSelectorWidth, + height: modelSelectorHeight(asSheet: false) + ) + .background(MobileDesignColors.floatingPanelBg) + .clipShape( + RoundedRectangle( + cornerRadius: MobileDesignGeometry.composerModelSelectorRadius + ) + ) + .overlay( + RoundedRectangle( + cornerRadius: MobileDesignGeometry.composerModelSelectorRadius + ) + .stroke(BitFunTheme.line, lineWidth: 1) + ) + .shadow(color: BitFunTheme.line, radius: 20, y: 8) + .position( + x: min( + max( + MobileDesignGeometry.composerModelSelectorWidth / 2 + 8, + frame.midX + ), + proxy.size.width - + MobileDesignGeometry.composerModelSelectorWidth / 2 - 8 + ), + y: frame.minY - modelSelectorHeight(asSheet: false) / 2 - 8 + ) + .transition(.opacity.combined(with: .scale(scale: 0.96, anchor: .bottom))) + .zIndex(20) + } + } + .allowsHitTesting(horizontalSizeClass == .regular && modelSelectorOpen) + } + .sheet( + isPresented: Binding( + get: { horizontalSizeClass != .regular && modelSelectorOpen }, + set: { if !$0 { modelSelectorOpen = false } } + ) + ) { + modelSelector(asSheet: true) + .presentationDetents([.height(modelSelectorHeight(asSheet: true))]) + .presentationDragIndicator(.visible) + } + } + + private var collapsedRow: some View { HStack(spacing: 5) { - Button { } label: { - ReferenceGlyph(assetName: "ComposerPlusGlyph", width: 18, height: 18) - .frame( - width: MobileDesignGeometry.composerActionSize, - height: MobileDesignGeometry.composerActionSize - ) + attachmentAction + inputField(maxLines: 1) + .frame(height: MobileDesignGeometry.composerInputHeight) + primaryAction + } + .frame(height: MobileDesignGeometry.composerCollapsedHeight) + } + + private var expandedInputRow: some View { + HStack(spacing: 0) { + inputField(maxLines: 4) + .frame(height: MobileDesignGeometry.composerExpandedInputHeight) + } + .frame(height: MobileDesignGeometry.composerExpandedInputRowHeight) + } + + private var expandedActionRow: some View { + HStack(spacing: 6) { + attachmentAction + if !model.modelOptions.isEmpty { + Button { modelSelectorOpen = true } label: { + HStack(spacing: 3) { + Text(selectedModel?.primaryLabel ?? model.localized("模型")) + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(BitFunTheme.ink) + .lineLimit(1) + Image(systemName: "chevron.down") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(BitFunTheme.muted) + } + .frame(height: 34) + .padding(.horizontal, 4) + } + .buttonStyle(.plain) + .accessibilityLabel(Text(model.localized("选择模型"))) + .anchorPreference(key: ComposerModelSelectorAnchorKey.self, value: .bounds) { $0 } + } + Spacer(minLength: 0) + primaryAction + } + .frame(height: MobileDesignGeometry.composerExpandedActionRowHeight) + .padding(.leading, 2) + } + + private func inputField(maxLines: Int) -> some View { + HStack(spacing: speech.isListening ? 8 : 0) { + if speech.isListening { + ListeningWave() } - .buttonStyle(.plain) TextField( "", text: $model.draft, - prompt: Text(placeholder).foregroundColor(BitFunTheme.muted), + prompt: Text(speech.isListening ? model.localized("正在聆听") : placeholder) + .foregroundColor(speech.isListening ? BitFunTheme.green : BitFunTheme.muted), axis: .vertical ) - .font(MobileDesignTypography.bodyLarge.font) - .foregroundStyle(BitFunTheme.ink) - .lineLimit(1...4) - .focused($focused) - .submitLabel(.send) - .onSubmit { model.send() } - .onChange(of: model.draft) { _ in model.syncDraftToCore() } - Button { model.send() } label: { + .font(MobileDesignTypography.bodyLarge.font) + .foregroundStyle(BitFunTheme.ink) + .lineLimit(1...maxLines) + .focused($focused) + .submitLabel(.send) + .onSubmit { + if canSend { model.send() } + } + .onChange(of: model.draft) { _ in model.syncDraftToCore() } + } + .padding(.leading, speech.isListening ? 12 : 4) + .padding(.trailing, 4) + .background(speech.isListening ? BitFunTheme.soft : Color.clear) + .overlay( + RoundedRectangle(cornerRadius: 20) + .stroke(speech.isListening ? BitFunTheme.green : Color.clear, lineWidth: 1) + ) + .clipShape(RoundedRectangle(cornerRadius: 20)) + } + + @ViewBuilder + private var attachmentAction: some View { + if model.composerImages.count < 4 { + PhotosPicker( + selection: $pickerItems, + maxSelectionCount: 4 - model.composerImages.count, + matching: .images + ) { + plusGlyph + } + .buttonStyle(.plain) + .accessibilityLabel(Text(model.localized("添加图片"))) + } else { + Button { model.showToast(model.localized("最多添加 4 张图片")) } label: { plusGlyph } + .buttonStyle(.plain) + .accessibilityLabel(Text(model.localized("已达到图片上限"))) + } + } + + private var plusGlyph: some View { + ReferenceGlyph(assetName: "ComposerPlusGlyph", width: 18, height: 18) + .frame( + width: MobileDesignGeometry.composerActionSize, + height: MobileDesignGeometry.composerActionSize + ) + } + + private var primaryAction: some View { + Button(action: performPrimaryAction) { + Group { if model.isSending { Image(systemName: "stop.fill") .font(.system(size: 16, weight: .bold)) .foregroundStyle(BitFunTheme.accent) - .frame( - width: MobileDesignGeometry.composerActionSize, - height: MobileDesignGeometry.composerActionSize - ) - } else if model.draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - ReferenceGlyph(assetName: "ComposerMicGlyph", width: 16, height: 19) - .foregroundStyle(BitFunTheme.muted) - .frame( - width: MobileDesignGeometry.composerActionSize, - height: MobileDesignGeometry.composerActionSize - ) - } else { + } else if hasContent { Image(systemName: "arrow.up") .font(.system(size: 16, weight: .bold)) - .foregroundStyle(BitFunTheme.accent) - .frame( - width: MobileDesignGeometry.composerActionSize, - height: MobileDesignGeometry.composerActionSize + .foregroundStyle(canSend ? BitFunTheme.accent : BitFunTheme.muted) + } else { + ReferenceGlyph(assetName: "ComposerMicGlyph", width: 16, height: 19) + .foregroundStyle(model.busy ? BitFunTheme.muted.opacity(0.45) : BitFunTheme.muted) + } + } + .frame( + width: MobileDesignGeometry.composerActionSize, + height: MobileDesignGeometry.composerActionSize + ) + } + .buttonStyle(.plain) + .disabled(!model.isSending && hasContent && !canSend) + .accessibilityLabel(primaryActionLabel) + } + + private var primaryActionLabel: String { + if model.isSending { return model.localized("停止") } + if hasContent { return model.localized("发送") } + return model.localized(speech.isListening ? "停止听写" : "语音输入") + } + + private var attachmentStrip: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(model.composerImages) { attachment in + ZStack(alignment: .topTrailing) { + Group { + if let image = UIImage(data: attachment.data) { + Image(uiImage: image) + .resizable() + .scaledToFill() + } else { + Image(systemName: "photo") + .foregroundStyle(BitFunTheme.muted) + } + } + .frame(width: 64, height: 64) + .background(BitFunTheme.soft) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(BitFunTheme.line, lineWidth: 1) ) + + Button { model.removeComposerImage(id: attachment.id) } label: { + Image(systemName: "xmark") + .font(.system(size: 9, weight: .bold)) + .foregroundStyle(Color.white) + .frame(width: 20, height: 20) + .background(Color.black.opacity(0.72)) + .clipShape(Circle()) + } + .buttonStyle(.plain) + .offset(x: 5, y: -5) + .accessibilityLabel(Text(model.localized("移除图片"))) + } + .padding(.top, 6) } } - .buttonStyle(.plain) + .padding(.horizontal, 2) } - .padding(.horizontal, 8) - .frame(minHeight: MobileDesignGeometry.composerCollapsedHeight) - .background(BitFunTheme.card) - .clipShape(RoundedRectangle(cornerRadius: MobileDesignGeometry.composerCollapsedRadius)) - .shadow(color: .black.opacity(0.05), radius: 10, y: 2) - .padding(.horizontal, MobileDesignGeometry.contentGutter) - .padding(.top, 8) - .padding(.bottom, 14) - .background(BitFunTheme.page) + .frame(height: 72) + } + + @ViewBuilder + private func modelSelector(asSheet: Bool) -> some View { + VStack(spacing: 10) { + if asSheet { + HStack(spacing: 0) { + Text(model.localized("选择模型")) + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(BitFunTheme.muted) + Spacer(minLength: 0) + Button { modelSelectorOpen = false } label: { + Image(systemName: "xmark") + .font(.system(size: 15, weight: .regular)) + .foregroundStyle(BitFunTheme.muted) + .frame( + width: MobileDesignGeometry.selectionCloseSize, + height: MobileDesignGeometry.selectionCloseSize + ) + } + .buttonStyle(.plain) + .accessibilityLabel(Text(model.localized("关闭"))) + } + .frame(height: MobileDesignGeometry.selectionCloseSize) + } + + ScrollView(showsIndicators: false) { + LazyVStack(spacing: MobileDesignGeometry.composerModelSelectorRowGap) { + ForEach(selectorModels) { option in + Button { + model.selectModel(option.id) + modelSelectorOpen = false + } label: { + HStack(spacing: 10) { + Image(systemName: option.selected ? "checkmark.circle" : "circle") + .font(.system(size: 16)) + .foregroundStyle(option.selected ? BitFunTheme.ink : Color.clear) + .frame(width: 20, height: 20) + VStack(alignment: .leading, spacing: 2) { + Text(option.primaryLabel) + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(BitFunTheme.ink) + .lineLimit(1) + Text(option.secondaryLabel) + .font(.system(size: 11)) + .foregroundStyle(BitFunTheme.muted) + .lineLimit(1) + } + Spacer(minLength: 0) + } + .padding(.horizontal, 10) + .frame(height: MobileDesignGeometry.composerModelSelectorRowHeight) + .background(option.selected ? BitFunTheme.soft : Color.clear) + .clipShape( + RoundedRectangle( + cornerRadius: MobileDesignGeometry.composerModelSelectorRowRadius + ) + ) + } + .buttonStyle(.plain) + } + } + } + } + .padding(10) + .background(asSheet ? BitFunTheme.card : MobileDesignColors.floatingPanelBg) + } + + private var selectedModel: ComposerModelOption? { + model.modelOptions.first(where: \.selected) ?? model.modelOptions.first + } + + private var selectorModels: [ComposerModelOption] { + model.modelOptions.filter(\.selected) + model.modelOptions.filter { !$0.selected } + } + + private func modelSelectorHeight(asSheet: Bool) -> CGFloat { + let visibleRows = min(model.modelOptions.count, 7) + let listHeight = CGFloat(visibleRows) * + MobileDesignGeometry.composerModelSelectorRowHeight + + CGFloat(max(0, visibleRows - 1)) * + MobileDesignGeometry.composerModelSelectorRowGap + return min(480, listHeight + (asSheet ? 86 : 20)) + } + + private func performPrimaryAction() { + if model.isSending { + model.stopSending() + return + } + if hasContent { + if canSend { model.send() } + return + } + if speech.isListening { + speech.stop() + return + } + guard !model.busy else { return } + let existing = model.draft.trimmingCharacters(in: .whitespacesAndNewlines) + speech.start( + localeIdentifier: model.appLanguage == .simplifiedChinese ? "zh-CN" : "en-US", + onPartial: { transcript in + model.draft = [existing, transcript] + .filter { !$0.isEmpty } + .joined(separator: existing.isEmpty ? "" : " ") + model.syncDraftToCore() + }, + onFailure: { message in model.showToast(model.localized(message)) } + ) + } + + private func importPickedImages(_ items: [PhotosPickerItem]) async { + for item in items { + guard let data = try? await item.loadTransferable(type: Data.self) else { + model.showToast(model.localized("无法读取所选图片")) + continue + } + let mimeType = item.supportedContentTypes + .compactMap(\.preferredMIMEType) + .first ?? "image/jpeg" + model.addComposerImage(data: data, mimeType: mimeType) + } + pickerItems = [] + } +} + +private struct ComposerModelSelectorAnchorKey: PreferenceKey { + static var defaultValue: Anchor? + + static func reduce(value: inout Anchor?, nextValue: () -> Anchor?) { + value = nextValue() ?? value + } +} + +private struct ListeningWave: View { + var body: some View { + HStack(spacing: 2) { + ForEach([8.0, 14.0, 10.0, 17.0], id: \.self) { height in + Capsule() + .fill(BitFunTheme.green) + .frame(width: 2, height: height) + } + } + .frame(width: 18, height: 22) + .accessibilityHidden(true) + } +} + +final class SpeechInputController: ObservableObject { + @Published private(set) var isListening = false + + private var recognizer: SFSpeechRecognizer? + private let audioEngine = AVAudioEngine() + private var request: SFSpeechAudioBufferRecognitionRequest? + private var recognitionTask: SFSpeechRecognitionTask? + private var tapInstalled = false + + func start( + localeIdentifier: String, + onPartial: @escaping (String) -> Void, + onFailure: @escaping (String) -> Void + ) { + recognizer = SFSpeechRecognizer(locale: Locale(identifier: localeIdentifier)) + SFSpeechRecognizer.requestAuthorization { [weak self] speechStatus in + guard speechStatus == .authorized else { + DispatchQueue.main.async { onFailure("请在系统设置中允许语音识别") } + return + } + AVAudioSession.sharedInstance().requestRecordPermission { granted in + guard granted else { + DispatchQueue.main.async { onFailure("请在系统设置中允许麦克风访问") } + return + } + DispatchQueue.main.async { + self?.beginRecognition(onPartial: onPartial, onFailure: onFailure) + } + } + } + } + + func stop() { + recognitionTask?.finish() + finishRecognition() + } + + private func beginRecognition( + onPartial: @escaping (String) -> Void, + onFailure: @escaping (String) -> Void + ) { + guard let recognizer, recognizer.isAvailable else { + onFailure("当前设备暂时无法使用语音识别") + return + } + + finishRecognition() + do { + let session = AVAudioSession.sharedInstance() + try session.setCategory(.record, mode: .measurement, options: .duckOthers) + try session.setActive(true, options: .notifyOthersOnDeactivation) + + let request = SFSpeechAudioBufferRecognitionRequest() + request.shouldReportPartialResults = true + self.request = request + + let input = audioEngine.inputNode + let format = input.outputFormat(forBus: 0) + input.installTap(onBus: 0, bufferSize: 1024, format: format) { buffer, _ in + request.append(buffer) + } + tapInstalled = true + audioEngine.prepare() + try audioEngine.start() + isListening = true + + recognitionTask = recognizer.recognitionTask(with: request) { [weak self] result, error in + DispatchQueue.main.async { + if let text = result?.bestTranscription.formattedString, !text.isEmpty { + onPartial(text) + } + if result?.isFinal == true || error != nil { + if error != nil && result == nil { onFailure("语音识别已中断,请重试") } + self?.finishRecognition() + } + } + } + } catch { + finishRecognition() + onFailure("无法启动语音输入,请检查麦克风") + } + } + + private func finishRecognition() { + if audioEngine.isRunning { + audioEngine.stop() + } + if tapInstalled { + audioEngine.inputNode.removeTap(onBus: 0) + tapInstalled = false + } + request?.endAudio() + request = nil + recognitionTask?.cancel() + recognitionTask = nil + isListening = false + try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) } } diff --git a/src/apps/mobile/ios/BitFun/Features/Chat/ConversationHeader.swift b/src/apps/mobile/ios/BitFun/Features/Chat/ConversationHeader.swift index 0f80c071ea..b8b4e138b1 100644 --- a/src/apps/mobile/ios/BitFun/Features/Chat/ConversationHeader.swift +++ b/src/apps/mobile/ios/BitFun/Features/Chat/ConversationHeader.swift @@ -2,71 +2,237 @@ import SwiftUI struct ConversationHeader: View { @ObservedObject var model: MobileAppModel + @Binding var actionsOpen: Bool var contextTitle: String? = nil - @State private var menuOpen = false + var sidebarAction: (() -> Void)? = nil + var sidebarActionLabel: String = "打开侧栏" + @State private var editing = false + @State private var renameDraft = "" private var resolvedSubtitle: String? { if let contextTitle, !contextTitle.isEmpty { return contextTitle } - if model.surface == .local && model.localSessionSelected { return "本地会话" } - if model.remoteConnected && model.remoteSessionSelected { return "DESKTOP-KM3L4UI" } + if model.surface == .local && model.localSessionSelected { return model.localized("本地会话") } + if model.remoteConnected && model.remoteSessionSelected { + return model.accountDeviceName ?? model.localized("已连接桌面端") + } return nil } var body: some View { - HStack(spacing: 8) { - Button { model.drawerOpen = true } label: { - ReferenceGlyph(assetName: "MenuGlyph", width: 23, height: 18) - .frame( - width: MobileDesignGeometry.controlTouchSize, - height: MobileDesignGeometry.controlTouchSize - ) - .background(BitFunTheme.card) - .overlay(Circle().stroke(BitFunTheme.line, lineWidth: 1)) - .clipShape(Circle()) - .shadow(color: .black.opacity(0.07), radius: 8, y: 3) - } - .buttonStyle(.plain) - VStack(spacing: 3) { - Text(model.selectedSession?.title ?? "BitFun") - .font( - (resolvedSubtitle == nil - ? MobileDesignTypography.titleMedium - : MobileDesignTypography.conversationHeaderTitle).font - ) - .foregroundStyle(BitFunTheme.ink) - .lineLimit(1) - if let resolvedSubtitle { - Text(resolvedSubtitle) - .font(MobileDesignTypography.labelMedium.font) - .foregroundStyle(BitFunTheme.muted) + VStack(spacing: 0) { + HStack(spacing: 8) { + if let sidebarAction { + Button(action: sidebarAction) { + ReferenceGlyph(assetName: "MenuGlyph", width: 23, height: 18) + .frame( + width: MobileDesignGeometry.controlTouchSize, + height: MobileDesignGeometry.controlTouchSize + ) + .background(BitFunTheme.card) + .overlay(Circle().stroke(BitFunTheme.line, lineWidth: 1)) + .clipShape(Circle()) + .shadow(color: .black.opacity(0.07), radius: 8, y: 3) + } + .buttonStyle(.plain) + .accessibilityLabel(MobileLocalization.text(sidebarActionLabel)) + } else { + Color.clear + .frame( + width: MobileDesignGeometry.controlTouchSize, + height: MobileDesignGeometry.controlTouchSize + ) + } + + VStack(spacing: 3) { + Text(model.selectedSession?.title ?? "BitFun") + .font( + (resolvedSubtitle == nil + ? MobileDesignTypography.titleMedium + : MobileDesignTypography.conversationHeaderTitle).font + ) + .foregroundStyle(BitFunTheme.ink) + .lineLimit(1) + if let resolvedSubtitle { + Text(resolvedSubtitle) + .font(MobileDesignTypography.labelMedium.font) + .foregroundStyle(BitFunTheme.muted) + .lineLimit(1) + } + } + .frame(maxWidth: .infinity) + .contentShape(Rectangle()) + .onTapGesture { + guard let session = model.selectedSession, !model.busy else { return } + renameDraft = session.title + editing = true + } + + if model.selectedSession != nil { + actionsMenu + } else { + Color.clear + .frame( + width: MobileDesignGeometry.controlTouchSize, + height: MobileDesignGeometry.controlTouchSize + ) } } - .frame(maxWidth: .infinity) - .contentShape(Rectangle()) - .onTapGesture { } + .frame( + height: resolvedSubtitle == nil + ? MobileDesignGeometry.conversationHeaderCompactHeight + : MobileDesignGeometry.conversationHeaderHeight + ) + .padding(.horizontal, MobileDesignGeometry.contentGutter) - Menu { - Button("置顶会话") { } - Button("导出会话") { } - Button("归档会话") { } - } label: { - ReferenceGlyph(assetName: "MoreGlyph", width: 23, height: 7) - .frame( - width: MobileDesignGeometry.controlTouchSize, - height: MobileDesignGeometry.controlTouchSize - ) - .background(BitFunTheme.card) - .overlay(Circle().stroke(BitFunTheme.line, lineWidth: 1)) - .clipShape(Circle()) - .shadow(color: .black.opacity(0.07), radius: 8, y: 3) + if editing { + renameEditor } } - .frame( - height: resolvedSubtitle == nil - ? MobileDesignGeometry.conversationHeaderCompactHeight - : MobileDesignGeometry.conversationHeaderHeight - ) - .padding(.horizontal, MobileDesignGeometry.contentGutter) .background(BitFunTheme.page) + .onChange(of: model.selectedSession?.title) { _ in + editing = false + } + .onAppear { + if ProcessInfo.processInfo.arguments.contains("--session-actions") { + actionsOpen = true + } + } + } + + private var actionsMenu: some View { + Button { actionsOpen.toggle() } label: { + ReferenceGlyph(assetName: "MoreGlyph", width: 23, height: 7) + .frame( + width: MobileDesignGeometry.controlTouchSize, + height: MobileDesignGeometry.controlTouchSize + ) + .background(BitFunTheme.card) + .overlay(Circle().stroke(BitFunTheme.line, lineWidth: 1)) + .clipShape(Circle()) + .shadow(color: .black.opacity(0.07), radius: 8, y: 3) + } + .buttonStyle(.plain) + .accessibilityLabel(model.localized("会话操作")) + .anchorPreference(key: SessionActionsAnchorKey.self, value: .bounds) { $0 } + } + + private var renameEditor: some View { + HStack(spacing: 8) { + TextField(model.localized("会话标题"), text: $renameDraft) + .font(.system(size: 14)) + .foregroundStyle(BitFunTheme.ink) + .padding(.horizontal, 12) + .frame(height: 42) + .background(BitFunTheme.card) + .overlay( + RoundedRectangle(cornerRadius: 14) + .stroke(BitFunTheme.line, lineWidth: 1) + ) + .clipShape(RoundedRectangle(cornerRadius: 14)) + + editorButton("保存", primary: true, enabled: !renameDraft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) { + model.renameSelectedSession(renameDraft) + editing = false + } + editorButton("取消", primary: false, enabled: true) { + editing = false + } + } + .padding(.leading, MobileDesignGeometry.contentGutter) + .padding(.trailing, MobileDesignGeometry.contentGutter) + .padding(.top, 10) + .padding(.bottom, 8) + } + + private func editorButton( + _ title: String, + primary: Bool, + enabled: Bool, + action: @escaping () -> Void + ) -> some View { + Button(action: action) { + Text(model.localized(title)) + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(primary && enabled ? Color.white : BitFunTheme.ink) + .frame(width: 52, height: 42) + .background(primary && enabled ? BitFunTheme.accent : BitFunTheme.soft) + .clipShape(RoundedRectangle(cornerRadius: 14)) + } + .buttonStyle(.plain) + .disabled(!enabled) + } +} + +struct SessionActionsAnchorKey: PreferenceKey { + static var defaultValue: Anchor? + + static func reduce(value: inout Anchor?, nextValue: () -> Anchor?) { + value = nextValue() ?? value + } +} + +/// The active-conversation popup is rendered by the shell so it can remain an +/// arrowless, anchored, auto-cancelling popup on compact iPhones as well as on +/// iPad. SwiftUI's native popover adapts to a centred page on compact width, +/// which is a different component from Harmony's `bindPopup(mask: false)`. +struct ConversationActionsPopover: View { + @ObservedObject var model: MobileAppModel + let onDismiss: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + Text(model.localized("会话")) + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(BitFunTheme.muted) + .frame(height: 28) + .padding(.leading, 8) + if model.surface == .local { + action( + model.selectedSession?.pinned == true ? "取消置顶" : "置顶会话", + icon: "checkmark.circle", + selected: model.selectedSession?.pinned == true, + perform: model.togglePinSelectedSession + ) + } + action("已上传文件", icon: "cloud", perform: model.showUploadedFiles) + if model.surface == .local { + Divider().overlay(BitFunTheme.line).padding(.vertical, 8) + action("归档会话", icon: "folder", perform: model.archiveSelectedSession) + action("删除", icon: "gearshape", perform: model.deleteSelectedSession) + } else if model.isSending { + Divider().overlay(BitFunTheme.line).padding(.vertical, 8) + action("停止", icon: "gearshape", perform: model.stopSending) + } + } + .bitFunPopoverSurface() + .accessibilityAction(.escape, onDismiss) + } + + private func action( + _ title: String, + icon: String, + selected: Bool = false, + perform: @escaping () -> Void + ) -> some View { + Button { + perform() + onDismiss() + } label: { + HStack(spacing: 10) { + Image(systemName: icon) + .font(.system(size: 20, weight: .regular)) + .foregroundStyle(BitFunTheme.muted) + .frame(width: 23, height: 23) + Text(model.localized(title)) + .font(.system(size: 15, weight: .regular)) + .foregroundStyle(BitFunTheme.ink) + Spacer(minLength: 0) + } + .padding(.horizontal, 8) + .frame(height: MobileDesignGeometry.popoverActionHeight) + .background(selected ? BitFunTheme.soft : Color.clear) + .clipShape(RoundedRectangle(cornerRadius: 10)) + } + .buttonStyle(.plain) } } diff --git a/src/apps/mobile/ios/BitFun/Features/DesignSystem/AdaptiveModalComponents.swift b/src/apps/mobile/ios/BitFun/Features/DesignSystem/AdaptiveModalComponents.swift new file mode 100644 index 0000000000..7412027386 --- /dev/null +++ b/src/apps/mobile/ios/BitFun/Features/DesignSystem/AdaptiveModalComponents.swift @@ -0,0 +1,276 @@ +import SwiftUI +import BitFunMobileCore + +/// Shared chrome for every BitFun modal page. The presentation primitive stays +/// native; this view owns the paper-and-ink header geometry inside it. +struct BitFunModalHeader: View { + let title: String + var subtitle: String? = nil + let onClose: () -> Void + + var body: some View { + HStack(spacing: 12) { + VStack(alignment: .leading, spacing: 2) { + Text(MobileLocalization.text(title)) + .font(MobileDesignTypography.headlineSmall.font) + .foregroundStyle(BitFunTheme.ink) + .lineLimit(1) + if let subtitle, !subtitle.isEmpty { + Text(MobileLocalization.text(subtitle)) + .font(MobileDesignTypography.labelSmall.font) + .foregroundStyle(BitFunTheme.muted) + .lineLimit(2) + } + } + Spacer(minLength: 8) + Button(action: onClose) { + Image(systemName: "xmark") + .font(.system(size: 15, weight: .medium)) + .foregroundStyle(BitFunTheme.ink) + .frame(width: 40, height: 40) + .background(BitFunTheme.soft) + .clipShape(Circle()) + } + .buttonStyle(.plain) + .accessibilityLabel(MobileLocalization.text("关闭")) + } + .frame(minHeight: MobileDesignGeometry.sheetHeaderHeight) + } +} + +/// Header used when a picker or provider page replaces the current modal +/// content. Harmony's selection panels use a quiet 32-point dismissal target +/// on a 56-point row rather than another filled circular control. +struct BitFunSelectionHeader: View { + let title: String + let onClose: () -> Void + + var body: some View { + HStack(spacing: 12) { + Text(MobileLocalization.text(title)) + .font(MobileDesignTypography.headlineSmall.font) + .foregroundStyle(BitFunTheme.ink) + .lineLimit(1) + Spacer(minLength: 8) + Button(action: onClose) { + Image(systemName: "xmark") + .font(.system(size: 18, weight: .regular)) + .foregroundStyle(BitFunTheme.muted) + .frame( + width: MobileDesignGeometry.selectionCloseSize, + height: MobileDesignGeometry.selectionCloseSize + ) + } + .buttonStyle(.plain) + .accessibilityLabel(MobileLocalization.text("关闭")) + } + .padding(.horizontal, 16) + .frame(height: MobileDesignGeometry.sheetHeaderHeight) + } +} + +struct BitFunModalCard: View { + var radius: CGFloat = MobileDesignGeometry.settingsCardRadius + var bordered: Bool = true + @ViewBuilder let content: () -> Content + + var body: some View { + VStack(spacing: 0, content: content) + .background(BitFunTheme.card) + .clipShape(RoundedRectangle(cornerRadius: radius)) + .overlay( + RoundedRectangle(cornerRadius: radius) + .stroke(bordered ? BitFunTheme.line : Color.clear, lineWidth: 1) + ) + } +} + +/// One signed-out connection decision, reused wherever the user can enter the +/// remote product. Keeping the order and treatment here prevents the sidebar +/// and pairing sheet from drifting into two different connection flows. +struct SignedOutConnectionActions: View { + let scanTitle: String + let accountTitle: String + let onScan: () -> Void + let onOpenAccount: () -> Void + var showScan = true + var enabled = true + var buttonHeight: CGFloat = 48 + var spacing: CGFloat = 10 + var fontSize: CGFloat = 16 + + var body: some View { + VStack(spacing: spacing) { + if showScan { + Button(action: onScan) { + Text(scanTitle) + .font(.system(size: fontSize, weight: .bold)) + .foregroundStyle(BitFunTheme.ink) + .frame(maxWidth: .infinity, minHeight: buttonHeight) + .background(BitFunTheme.card) + .overlay(Capsule().stroke(BitFunTheme.line, lineWidth: 1)) + .clipShape(Capsule()) + } + .buttonStyle(.plain) + .disabled(!enabled) + } + Button(action: onOpenAccount) { + Text(accountTitle) + .font(.system(size: fontSize, weight: .bold)) + .foregroundStyle(.white) + .frame(maxWidth: .infinity, minHeight: buttonHeight) + .background(BitFunTheme.accent) + .clipShape(Capsule()) + } + .buttonStyle(.plain) + .disabled(!enabled) + } + } +} + +struct BitFunPopoverSurfaceModifier: ViewModifier { + func body(content: Content) -> some View { + content + .padding(.horizontal, MobileDesignGeometry.popoverPadding) + .padding(.vertical, MobileDesignGeometry.popoverVerticalPadding) + .frame(width: MobileDesignGeometry.popoverWidth) + .background(MobileDesignColors.floatingPanelBg) + .clipShape(RoundedRectangle(cornerRadius: MobileDesignGeometry.popoverRadius)) + .overlay( + RoundedRectangle(cornerRadius: MobileDesignGeometry.popoverRadius) + .stroke(BitFunTheme.line, lineWidth: 1) + ) + .shadow( + color: BitFunTheme.line, + radius: MobileDesignGeometry.popoverShadowRadius, + y: 7 + ) + } +} + +struct BitFunCompactPopoverSurfaceModifier: ViewModifier { + func body(content: Content) -> some View { + content + .padding(.vertical, 8) + .frame(width: MobileDesignGeometry.compactPopoverWidth) + .background(MobileDesignColors.floatingPanelBg) + .clipShape(RoundedRectangle(cornerRadius: MobileDesignGeometry.compactPopoverRadius)) + .overlay( + RoundedRectangle(cornerRadius: MobileDesignGeometry.compactPopoverRadius) + .stroke(BitFunTheme.line, lineWidth: 1) + ) + .shadow(color: BitFunTheme.line, radius: MobileDesignGeometry.popoverShadowRadius, y: 7) + } +} + +extension View { + func bitFunPopoverSurface() -> some View { + modifier(BitFunPopoverSurfaceModifier()) + } + + func bitFunCompactPopoverSurface() -> some View { + modifier(BitFunCompactPopoverSurfaceModifier()) + } + + func bitFunAdaptiveModal( + isPresented: Binding, + placement: SettingsPlacement, + onDismiss: (() -> Void)? = nil, + @ViewBuilder content: @escaping () -> ModalContent + ) -> some View { + modifier( + BitFunAdaptiveModalModifier( + isPresented: isPresented, + placement: placement, + onDismiss: onDismiss, + modalContent: content + ) + ) + } +} + +/// Selects the native presentation lifecycle from the KMP placement decision. +/// Compact devices keep a system sheet; side placements use a native full-screen +/// cover containing a trailing paper surface so focus and VoiceOver are isolated +/// from the covered conversation while the dimensions remain Harmony-compatible. +private struct BitFunAdaptiveModalModifier: ViewModifier { + @Binding var isPresented: Bool + let placement: SettingsPlacement + let onDismiss: (() -> Void)? + @ViewBuilder let modalContent: () -> ModalContent + + private var isSide: Bool { placement.mode == .side } + + private var compactPresented: Binding { + Binding( + get: { isPresented && !isSide }, + set: { if !$0 { isPresented = false } } + ) + } + + private var sidePresented: Binding { + Binding( + get: { isPresented && isSide }, + set: { if !$0 { isPresented = false } } + ) + } + + func body(content base: Content) -> some View { + base + .sheet(isPresented: compactPresented, onDismiss: onDismiss) { + compactSheet + } + .fullScreenCover(isPresented: sidePresented, onDismiss: onDismiss) { + sideCover + } + } + + @ViewBuilder + private var compactSheet: some View { + let surface = modalContent() + .presentationDetents([.large]) + .presentationDragIndicator(.hidden) + if #available(iOS 16.4, *) { + surface.presentationCornerRadius(MobileDesignGeometry.sheetTopRadius) + } else { + surface + } + } + + @ViewBuilder + private var sideCover: some View { + let cover = ZStack(alignment: .trailing) { + MobileDesignColors.modalScrim + .ignoresSafeArea() + .contentShape(Rectangle()) + .onTapGesture { isPresented = false } + + modalContent() + .frame( + width: CGFloat(placement.width), + height: CGFloat(placement.height) + ) + .background(BitFunTheme.page) + .clipShape( + RoundedRectangle( + cornerRadius: MobileDesignGeometry.sheetSideRadius, + style: .continuous + ) + ) + .overlay( + RoundedRectangle( + cornerRadius: MobileDesignGeometry.sheetSideRadius, + style: .continuous + ) + .stroke(BitFunTheme.line, lineWidth: 1) + ) + .shadow(color: .black.opacity(0.14), radius: 18, x: -5, y: 8) + .accessibilityAddTraits(.isModal) + } + if #available(iOS 16.4, *) { + cover.presentationBackground(.clear) + } else { + cover + } + } +} diff --git a/src/apps/mobile/ios/BitFun/Features/DesignSystem/GeneratedMobileDesignTokens.swift b/src/apps/mobile/ios/BitFun/Features/DesignSystem/GeneratedMobileDesignTokens.swift index f9415dbbe9..54bf6b53fa 100644 --- a/src/apps/mobile/ios/BitFun/Features/DesignSystem/GeneratedMobileDesignTokens.swift +++ b/src/apps/mobile/ios/BitFun/Features/DesignSystem/GeneratedMobileDesignTokens.swift @@ -29,7 +29,7 @@ enum MobileDesignColors { static let connectHeroSecondary = dynamic(light: 0xFFC9C5FF, dark: 0xFF3C3B38) static let connectHeroSurface = dynamic(light: 0xFFF8FAFF, dark: 0xFF252522) static let connectScanAccent = dynamic(light: 0xFFFFD021, dark: 0xFFFFD021) - static let modalScrim = dynamic(light: 0x99000000, dark: 0x99000000) + static let modalScrim = dynamic(light: 0x44000000, dark: 0x44000000) static let soft = dynamic(light: 0xFFF4F3F0, dark: 0xFF2D2C28) static let floatingPanelBg = dynamic(light: 0xFFF7F7F5, dark: 0xFF1E1E1C) static let green = dynamic(light: 0xFF27C46A, dark: 0xFF3BD47B) @@ -101,6 +101,42 @@ enum MobileDesignGeometry { static let composerExpandedHeight: CGFloat = 126 static let composerCollapsedRadius: CGFloat = 26 static let composerExpandedRadius: CGFloat = 18 + static let composerModelSelectorWidth: CGFloat = 330 + static let composerModelSelectorRadius: CGFloat = 14 + static let composerModelSelectorRowHeight: CGFloat = 48 + static let composerModelSelectorRowRadius: CGFloat = 9 + static let composerModelSelectorRowGap: CGFloat = 6 + static let sheetTopRadius: CGFloat = 34 + static let sheetSideRadius: CGFloat = 34 + static let sheetHorizontalPadding: CGFloat = 20 + static let sheetHeaderHeight: CGFloat = 56 + static let sheetActionHeight: CGFloat = 46 + static let selectionTopRadius: CGFloat = 20 + static let selectionRowHeight: CGFloat = 64 + static let selectionCloseSize: CGFloat = 32 + static let popoverWidth: CGFloat = 292 + static let popoverRadius: CGFloat = 16 + static let popoverPadding: CGFloat = 12 + static let popoverVerticalPadding: CGFloat = 10 + static let popoverActionHeight: CGFloat = 48 + static let popoverShadowRadius: CGFloat = 18 + static let compactPopoverWidth: CGFloat = 150 + static let compactPopoverRadius: CGFloat = 14 + static let compactPopoverActionHeight: CGFloat = 42 + static let settingsCompactCardRadius: CGFloat = 8 + static let settingsCardRadius: CGFloat = 24 + static let settingsProminentCardRadius: CGFloat = 28 + static let modelCurrentRowHeight: CGFloat = 64 + static let modelSourceRowHeight: CGFloat = 62 + static let modelAccountRowHeight: CGFloat = 56 + static let modelAccountRowGap: CGFloat = 6 + static let modelSectionGap: CGFloat = 20 + static let modelOverviewTopPadding: CGFloat = 16 + static let modelOverviewBottomPadding: CGFloat = 24 + static let modelListTopPadding: CGFloat = 10 + static let modelListBottomPadding: CGFloat = 16 + static let modelEmptyAccountHeight: CGFloat = 80 + static let modelEditorHeight: CGFloat = 560 } enum MobileDesignBreakpoints { diff --git a/src/apps/mobile/ios/BitFun/Features/DesignSystem/MobileDesignGallery.swift b/src/apps/mobile/ios/BitFun/Features/DesignSystem/MobileDesignGallery.swift index 7e1609c89b..40b5544d2e 100644 --- a/src/apps/mobile/ios/BitFun/Features/DesignSystem/MobileDesignGallery.swift +++ b/src/apps/mobile/ios/BitFun/Features/DesignSystem/MobileDesignGallery.swift @@ -6,7 +6,7 @@ struct MobileDesignGallery: View { init(scenario: MobilePreviewScenario) { self.scenario = scenario - let session = ChatSession(id: UUID(), title: scenario.headerTitle, updatedLabel: "刚刚") + let session = ChatSession(id: UUID().uuidString, title: scenario.headerTitle, updatedLabel: "刚刚") let previewModel = MobileAppModel( sessions: [session], selectedSessionID: session.id, @@ -30,7 +30,11 @@ struct MobileDesignGallery: View { var body: some View { VStack(spacing: 0) { platformLabel - ConversationHeader(model: model, contextTitle: scenario.headerSubtitle) + ConversationHeader( + model: model, + actionsOpen: .constant(false), + contextTitle: scenario.headerSubtitle + ) ChatTimelineView(model: model) ComposerBar(model: model) } @@ -39,10 +43,10 @@ struct MobileDesignGallery: View { private var platformLabel: some View { HStack(spacing: 8) { - Text("iOS") + Text(verbatim: "iOS") .font(MobileDesignTypography.labelMedium.font) .fontWeight(.medium) - Text("NATIVE") + Text(verbatim: "NATIVE") .font(MobileDesignTypography.labelSmall.font) .foregroundStyle(BitFunTheme.muted) .padding(.horizontal, 8) @@ -50,7 +54,7 @@ struct MobileDesignGallery: View { .background(BitFunTheme.soft) .clipShape(Capsule()) Spacer() - Text("\(Int(scenario.viewportWidth)) × \(Int(scenario.viewportHeight))") + Text(verbatim: "\(Int(scenario.viewportWidth)) × \(Int(scenario.viewportHeight))") .font(MobileDesignTypography.labelSmall.font) .foregroundStyle(BitFunTheme.muted) } diff --git a/src/apps/mobile/ios/BitFun/Features/Shell/MobileShellView.swift b/src/apps/mobile/ios/BitFun/Features/Shell/MobileShellView.swift index 26eeebd203..15d80bc0d3 100644 --- a/src/apps/mobile/ios/BitFun/Features/Shell/MobileShellView.swift +++ b/src/apps/mobile/ios/BitFun/Features/Shell/MobileShellView.swift @@ -1,34 +1,219 @@ import AVFoundation +import BitFunMobileCore import SwiftUI +import UniformTypeIdentifiers struct MobileShellView: View { @ObservedObject var model: MobileAppModel + @State private var wideSidebarCollapsed = false + @State private var sessionActionsOpen = false + @State private var sidebarActionSession: ChatSession? var body: some View { + GeometryReader { proxy in + adaptiveSurface(viewportWidth: proxy.size.width, viewportHeight: proxy.size.height) + } + .overlayPreferenceValue(SessionActionsAnchorKey.self) { anchor in + GeometryReader { proxy in + if sessionActionsOpen, let anchor { + let frame = proxy[anchor] + ZStack(alignment: .topLeading) { + Color.clear + .contentShape(Rectangle()) + .onTapGesture { sessionActionsOpen = false } + ConversationActionsPopover( + model: model, + onDismiss: { sessionActionsOpen = false } + ) + .offset( + x: min( + max(8, frame.maxX - MobileDesignGeometry.popoverWidth), + proxy.size.width - MobileDesignGeometry.popoverWidth - 8 + ), + y: frame.maxY + 8 + ) + .transition( + .offset(x: 8, y: -8).combined(with: .opacity) + ) + } + } + } + } + .overlayPreferenceValue(SidebarSessionActionsAnchorKey.self) { anchors in + GeometryReader { proxy in + if let session = sidebarActionSession, + let anchor = anchors[session.id] { + let frame = proxy[anchor] + let remote = model.surface == .remote + ZStack(alignment: .topLeading) { + Color.clear + .contentShape(Rectangle()) + .onTapGesture { sidebarActionSession = nil } + SessionActionSurface( + model: model, + session: session, + presentation: .popover, + canViewDetails: true, + canArchive: !remote, + canExport: !remote, + canDelete: true, + onViewDetails: { + sidebarActionSession = nil + DispatchQueue.main.asyncAfter(deadline: .now() + 0.18) { + model.showSessionDetails(session) + } + }, + onArchive: { if !remote { model.archiveLocalSession(session) } }, + onExport: { if !remote { model.exportLocalSession(session) } }, + onDelete: { + if remote { model.deleteRemoteSession(session) } + else { model.deleteLocalSession(session) } + }, + onClose: { sidebarActionSession = nil } + ) + .position( + x: frame.maxX + 6 + 150, + y: min(max(frame.midY, 170), proxy.size.height - 170) + ) + } + } + } + } + .animation(.easeOut(duration: 0.24), value: model.drawerOpen) + .animation(.easeInOut(duration: 0.22), value: wideSidebarCollapsed) + .overlay(alignment: .bottom) { + if let message = model.toastMessage { + Text(message) + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(Color.white) + .padding(.horizontal, 16) + .frame(minHeight: 38) + .background(Color.black.opacity(0.82)) + .clipShape(Capsule()) + .padding(.bottom, 86) + .transition(.move(edge: .bottom).combined(with: .opacity)) + } + } + .animation(.easeOut(duration: 0.18), value: model.toastMessage) + .fileExporter( + isPresented: $model.downloadExporterOpen, + document: MobileDownloadDocument(data: model.pendingDownload?.data ?? Data()), + contentType: model.pendingDownload.flatMap { UTType(mimeType: $0.mimeType) } ?? .data, + defaultFilename: model.pendingDownload?.name ?? "download" + ) { result in + switch result { + case .success: model.finishDownloadExport(success: true) + case .failure: model.finishDownloadExport(success: false) + } + } + .fileExporter( + isPresented: $model.generalExportOpen, + document: MobileDownloadDocument(data: model.generalExportData), + contentType: UTType(filenameExtension: "md") ?? .plainText, + defaultFilename: model.generalExportName + ) { _ in + model.finishGeneralExport() + } + } + + @ViewBuilder + private func adaptiveSurface(viewportWidth: CGFloat, viewportHeight: CGFloat) -> some View { + let width = Int32(max(0, viewportWidth.rounded(.down))) + let height = Int32(max(0, viewportHeight.rounded(.down))) + let layoutPolicy = ConversationLayoutPolicy.shared + let wide = layoutPolicy.useMasterDetail( + viewportWidth: width, + wideViewportMatched: width >= layoutPolicy.MD_MIN_WIDTH, + isFolded: false, + creases: [], + isExpandedFoldable: false, + isHover: false + ) + let geometry = layoutPolicy.resolveWideGeometry(viewportWidth: width, creases: []) + let adaptiveInput = AdaptiveLayoutInput( + viewportWidth: width, + viewportHeight: height, + isFolded: false, + isExpandedFoldable: false, + isHoverOperate: false, + wideLayoutMatched: width >= layoutPolicy.MD_MIN_WIDTH, + verticalCreases: [], + horizontalCreases: [], + isRtl: false + ) + let settingsPlacement = SettingsPlacementPolicy.shared.resolve( + input: adaptiveInput, + kind: .settings + ) + let connectPlacement = SettingsPlacementPolicy.shared.resolve( + input: adaptiveInput, + kind: .connect + ) + let sessionDetailsPlacement = SettingsPlacementPolicy.shared.resolve( + input: adaptiveInput, + kind: .sessionDetails + ) + let remoteViewSettingsPlacement = SettingsPlacementPolicy.shared.resolve( + input: adaptiveInput, + kind: .remoteViewSettings + ) + let previewLayout = FilePreviewPlacementPolicy.shared.resolveLayout( + previewVisible: model.filePreview != nil, + largeScreenLayout: wide, + viewportWidth: width, + creases: [], + preferredMasterWidth: geometry.masterPaneWidth + ) + let previewInPane = model.filePreview != nil && + previewLayout.placement != FilePreviewPlacement.compactFullPage + let previewForSheet = Binding( + get: { previewInPane ? nil : model.filePreview }, + set: { value in + if value == nil { model.dismissFilePreview() } + } + ) + let focusSplit = previewLayout.placement == FilePreviewPlacement.wideFocusSplit + let triplePane = previewLayout.placement == FilePreviewPlacement.wideTriplePane + let sidebarVisible = wide && !wideSidebarCollapsed && !focusSplit + let sidebarWidth = triplePane + ? CGFloat(previewLayout.masterPaneWidth) + : CGFloat(geometry.masterPaneWidth) + ZStack(alignment: .leading) { - VStack(spacing: 0) { - ConversationHeader(model: model) - if model.connectionPhase != .connected { - ConnectionStatusBar(phase: model.connectionPhase, detail: model.coreErrorMessage) - } - if model.surface == .remote && !model.remoteConnected { - RemoteHomeView(model: model) - ComposerBar(model: model) - } else if model.surface == .remote && !model.remoteSessionSelected { - RemoteConnectedHomeView() - ComposerBar(model: model) - } else if model.surface == .local && !model.localSessionSelected { - LocalHomeView(model: model) - ComposerBar(model: model) - } else { - ChatTimelineView(model: model) - ComposerBar(model: model) + HStack(spacing: 0) { + if sidebarVisible { + SidebarView( + model: model, + permanent: true, + onCollapse: { wideSidebarCollapsed = true }, + onPermanentActions: { sidebarActionSession = $0 } + ) + .frame(width: sidebarWidth) + paneSeparator(width: triplePane ? CGFloat(previewLayout.masterConversationGap) : 0) + } + + conversationSurface( + sidebarAction: sidebarVisible ? nil : { + if wide { + if focusSplit { model.dismissFilePreview() } + wideSidebarCollapsed = false + } else { + model.drawerOpen = true + } + }, + sidebarActionLabel: wide ? "展开侧栏" : "打开侧栏" + ) + .frame(width: previewInPane ? CGFloat(previewLayout.conversationPaneWidth) : nil) + + if previewInPane, let preview = model.filePreview { + paneSeparator(width: CGFloat(previewLayout.conversationPreviewGap)) + RemoteFilePreviewSheet(model: model, preview: preview, embedded: true) + .frame(width: CGFloat(previewLayout.previewPaneWidth)) } } - .background(BitFunTheme.page) - .ignoresSafeArea(.keyboard, edges: .bottom) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) - if model.drawerOpen { + if !sidebarVisible && model.drawerOpen { Color.black.opacity(0.24) .ignoresSafeArea() .onTapGesture { model.drawerOpen = false } @@ -37,134 +222,897 @@ struct MobileShellView: View { .shadow(color: .black.opacity(0.18), radius: 26, x: 10, y: 0) } } - .animation(.easeOut(duration: 0.24), value: model.drawerOpen) - .sheet(isPresented: $model.settingsOpen) { SettingsView(model: model) } - .sheet(isPresented: $model.pairingSheetOpen) { PairingSheet(model: model) } + .sheet(item: previewForSheet, onDismiss: model.dismissFilePreview) { preview in + RemoteFilePreviewSheet(model: model, preview: preview) + } + .bitFunAdaptiveModal( + isPresented: $model.settingsOpen, + placement: settingsPlacement + ) { + SettingsView(model: model) + } + .bitFunAdaptiveModal( + isPresented: $model.remoteControlSettingsOpen, + placement: settingsPlacement + ) { + RemoteControlSettingsView(model: model) + } + .bitFunAdaptiveModal( + isPresented: $model.remoteViewSettingsOpen, + placement: remoteViewSettingsPlacement + ) { + RemoteViewSettingsView(model: model) + } + .bitFunAdaptiveModal( + isPresented: $model.pairingSheetOpen, + placement: connectPlacement, + onDismiss: model.dismissPairing + ) { + PairingSheet(model: model) + } + .bitFunAdaptiveModal( + isPresented: $model.accountSheetOpen, + placement: settingsPlacement + ) { + AccountSettingsView(model: model) + } + .bitFunAdaptiveModal( + isPresented: Binding( + get: { model.sessionDetails != nil }, + set: { if !$0 { model.dismissSessionDetails() } } + ), + placement: sessionDetailsPlacement + ) { + if let session = model.sessionDetails { + SessionDetailsView( + model: model, + session: session, + onClose: model.dismissSessionDetails + ) + } + } + .onChange(of: wide) { isWide in + if !isWide { wideSidebarCollapsed = false } + } + } + + @ViewBuilder + private func conversationSurface( + sidebarAction: (() -> Void)?, + sidebarActionLabel: String + ) -> some View { + Group { + if model.remoteCreateOpen { + RemoteCreateSessionView( + model: model, + onBack: { model.remoteCreateOpen = false } + ) + } else { + conversationContent( + sidebarAction: sidebarAction, + sidebarActionLabel: sidebarActionLabel + ) + } + } + .background(BitFunTheme.page) + .ignoresSafeArea(.keyboard, edges: .bottom) + } + + private func conversationContent( + sidebarAction: (() -> Void)?, + sidebarActionLabel: String + ) -> some View { + VStack(spacing: 0) { + ConversationHeader( + model: model, + actionsOpen: $sessionActionsOpen, + sidebarAction: sidebarAction, + sidebarActionLabel: sidebarActionLabel + ) + if model.connectionPhase != .connected { + ConnectionStatusBar( + phase: model.connectionPhase, + detail: model.coreErrorMessage, + onRetry: model.verifyRemoteConnection + ) + } + if model.surface == .remote && !model.remoteConnected { + RemoteHomeView(model: model) + ComposerBar(model: model) + } else if model.surface == .remote && !model.remoteSessionSelected { + RemoteConnectedHomeView(model: model) + ComposerBar(model: model) + } else if model.surface == .local && !model.localSessionSelected { + LocalHomeView(model: model) + ComposerBar(model: model) + } else { + ChatTimelineView(model: model) + ComposerBar(model: model) + } + } + } + + @ViewBuilder + private func paneSeparator(width: CGFloat) -> some View { + if width > 0 { + Rectangle().fill(BitFunTheme.line).frame(width: width) + } } } -private struct PairingSheet: View { +private struct RemoteCreateSessionView: View { @ObservedObject var model: MobileAppModel - @Environment(\.dismiss) private var dismiss - @State private var pairingURL = "" - @State private var scannerOpen = false - @FocusState private var focused: Bool + let onBack: () -> Void + @Environment(\.horizontalSizeClass) private var horizontalSizeClass + @StateObject private var speech = SpeechInputController() + @State private var instruction = "" + @State private var selectedWorkspacePath = "" + @State private var selectedModelID: String? + @State private var pickerKind: RemoteCreateSelectionKind? = ProcessInfo.processInfo.arguments.contains( + "--remote-create-workspace-picker" + ) ? .workspace : nil var body: some View { - VStack(alignment: .leading, spacing: 0) { + VStack(spacing: 0) { HStack { - Text("连接桌面端") - .font(.system(size: 24, weight: .bold)) - .foregroundStyle(BitFunTheme.ink) - Spacer() - Button { dismiss() } label: { - Image(systemName: "xmark") - .font(.system(size: 15, weight: .medium)) + Button(action: onBack) { + Image(systemName: "chevron.left") + .font(.system(size: 19, weight: .medium)) .foregroundStyle(BitFunTheme.ink) - .frame(width: 36, height: 36) - .background(BitFunTheme.soft) + .frame(width: 44, height: 44) + .background(BitFunTheme.card) .clipShape(Circle()) } .buttonStyle(.plain) + .accessibilityLabel(model.localized("返回")) + Spacer() } - .padding(.bottom, 24) + .frame(height: 78, alignment: .top) + .padding(.leading, 18) + .padding(.top, 14) - Text("扫描桌面端显示的二维码,或粘贴连接链接。") - .font(.system(size: 15)) - .foregroundStyle(BitFunTheme.muted) - .lineSpacing(4) - .padding(.bottom, 18) + Spacer(minLength: 12) - Button { - scannerOpen = true - } label: { - Label("扫描二维码", systemImage: "qrcode.viewfinder") - .font(.system(size: 15, weight: .medium)) - .foregroundStyle(BitFunTheme.accent) - .frame(maxWidth: .infinity, minHeight: 44) - .background(BitFunTheme.soft) - .clipShape(Capsule()) + if horizontalSizeClass == .regular, !model.accountDevices.isEmpty { + contextButton( + kind: .device, + icon: "desktopcomputer", + label: model.accountDeviceName ?? model.localized("选择桌面设备") + ) } - .buttonStyle(.plain) - .padding(.bottom, 12) + contextButton( + kind: .workspace, + icon: selectedWorkspacePath.isEmpty ? "message" : "folder", + label: selectedWorkspaceName + ) + createComposer + } + .background(BitFunTheme.page) + .overlayPreferenceValue(RemoteCreateSelectionAnchorKey.self) { anchors in + GeometryReader { proxy in + if horizontalSizeClass == .regular, + let kind = pickerKind, + let anchor = anchors[kind] { + let frame = proxy[anchor] + ZStack(alignment: .topLeading) { + Color.clear + .contentShape(Rectangle()) + .onTapGesture { pickerKind = nil } + selectionContent(kind: kind, includeHeader: false) + .bitFunPopoverSurface() + .fixedSize(horizontal: false, vertical: true) + .position( + x: min( + max(MobileDesignGeometry.popoverWidth / 2 + 8, frame.midX), + proxy.size.width - MobileDesignGeometry.popoverWidth / 2 - 8 + ), + y: max(120, frame.minY - selectionHeight(kind) / 2 - 8) + ) + } + } + } + } + .sheet(item: compactPicker) { kind in + selectionContent(kind: kind, includeHeader: true) + .presentationDetents([.height(selectionHeight(kind))]) + .presentationDragIndicator(.visible) + } + .onAppear { + if let selected = model.remoteWorkspaces.first(where: \.selected) { + selectedWorkspacePath = selected.path + } + selectedModelID = model.modelOptions.first(where: \.selected)?.id ?? model.modelOptions.first?.id + } + } + + private var compactPicker: Binding { + Binding( + get: { horizontalSizeClass == .regular ? nil : pickerKind }, + set: { pickerKind = $0 } + ) + } + + private var selectedWorkspaceName: String { + guard !selectedWorkspacePath.isEmpty else { return model.localized("对话") } + return model.remoteWorkspaces.first(where: { $0.path == selectedWorkspacePath })?.name + ?? selectedWorkspacePath + } + + private var selectedModel: ComposerModelOption? { + model.modelOptions.first(where: { $0.id == selectedModelID }) ?? model.modelOptions.first + } + + private func contextButton(kind: RemoteCreateSelectionKind, icon: String, label: String) -> some View { + Button { pickerKind = kind } label: { + HStack(spacing: 13) { + Image(systemName: icon) + .font(.system(size: 20, weight: .medium)) + .foregroundStyle(BitFunTheme.muted) + .frame(width: 26, height: 26) + Text(label) + .font(.system(size: 16, weight: .medium)) + .foregroundStyle(BitFunTheme.ink) + .lineLimit(1) + Image(systemName: pickerKind == kind ? "chevron.up" : "chevron.down") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(BitFunTheme.muted) + Spacer(minLength: 0) + } + .frame(height: 48) + .padding(.horizontal, 28) + } + .buttonStyle(.plain) + .disabled(model.busy) + .anchorPreference(key: RemoteCreateSelectionAnchorKey.self, value: .bounds) { + [kind: $0] + } + } + + private var createComposer: some View { + VStack(spacing: 2) { + TextField( + "", + text: $instruction, + prompt: Text(model.localized(speech.isListening ? "正在聆听" : "告诉 BitFun 要做什么")) + .foregroundColor(speech.isListening ? BitFunTheme.green : BitFunTheme.muted), + axis: .vertical + ) + .font(MobileDesignTypography.bodyLarge.font) + .lineLimit(1...4) + .padding(.horizontal, 6) + .frame(minHeight: MobileDesignGeometry.composerExpandedInputRowHeight) HStack(spacing: 8) { - TextField("粘贴桌面端连接链接", text: $pairingURL, axis: .vertical) - .font(.system(size: 14)) + if let selectedModel { + Button { pickerKind = .model } label: { + HStack(spacing: 4) { + Text(selectedModel.primaryLabel) + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(BitFunTheme.ink) + .lineLimit(1) + Image(systemName: pickerKind == .model ? "chevron.up" : "chevron.down") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(BitFunTheme.muted) + } + .frame(height: 34) + } + .buttonStyle(.plain) + .anchorPreference(key: RemoteCreateSelectionAnchorKey.self, value: .bounds) { + [.model: $0] + } + } + Spacer(minLength: 0) + Button(action: primaryAction) { + Image(systemName: instruction.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + ? (speech.isListening ? "stop.fill" : "mic.fill") + : "arrow.up") + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(canSubmit ? Color.white : BitFunTheme.ink) + .frame( + width: MobileDesignGeometry.composerActionSize, + height: MobileDesignGeometry.composerActionSize + ) + .background(canSubmit ? BitFunTheme.accent : BitFunTheme.soft) + .clipShape(Circle()) + } + .buttonStyle(.plain) + .disabled(model.busy || !model.remoteConnected) + } + .frame(height: MobileDesignGeometry.composerExpandedActionRowHeight) + } + .padding(.horizontal, 8) + .padding(.top, 4) + .padding(.bottom, 2) + .frame(minHeight: MobileDesignGeometry.composerExpandedHeight) + .background(BitFunTheme.card) + .clipShape(RoundedRectangle(cornerRadius: MobileDesignGeometry.composerExpandedRadius)) + .shadow(color: .black.opacity(0.05), radius: 10, y: 2) + .padding(.horizontal, MobileDesignGeometry.contentGutter) + .padding(.top, 8) + .padding(.bottom, 14) + } + + private var canSubmit: Bool { + !instruction.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && + model.remoteConnected && !model.busy + } + + private func primaryAction() { + let value = instruction.trimmingCharacters(in: .whitespacesAndNewlines) + if !value.isEmpty { + guard canSubmit else { return } + model.createRemoteSession( + agentType: selectedWorkspacePath.isEmpty ? "Claw" : "code", + title: "", + instruction: value, + modelID: selectedModelID + ) + instruction = "" + return + } + if speech.isListening { + speech.stop() + return + } + speech.start( + localeIdentifier: model.appLanguage == .simplifiedChinese ? "zh-CN" : "en-US", + onPartial: { instruction = $0 }, + onFailure: { model.showToast(model.localized($0)) } + ) + } + + @ViewBuilder + private func selectionContent(kind: RemoteCreateSelectionKind, includeHeader: Bool) -> some View { + VStack(spacing: 0) { + if includeHeader { + BitFunSelectionHeader(title: kind.title, onClose: { pickerKind = nil }) + } + ScrollView(showsIndicators: false) { + VStack(spacing: 0) { + switch kind { + case .device: + ForEach(model.accountDevices) { device in + selectionRow( + icon: "desktopcomputer", + title: device.name.isEmpty ? device.id : device.name, + subtitle: model.localized(device.online ? "在线" : "离线"), + selected: device.selected, + enabled: device.online || device.selected + ) { + pickerKind = nil + model.selectRemoteDevice(device) + } + } + case .workspace: + selectionRow( + icon: "message", + title: model.localized("对话"), + subtitle: "", + selected: selectedWorkspacePath.isEmpty, + enabled: true + ) { + selectedWorkspacePath = "" + pickerKind = nil + if let assistant = model.remoteAssistants.first { + model.selectRemoteAssistant(assistant) + } + } + ForEach(model.remoteWorkspaces) { workspace in + selectionRow( + icon: "folder", + title: workspace.name, + subtitle: workspace.path, + selected: workspace.path == selectedWorkspacePath, + enabled: true + ) { + selectedWorkspacePath = workspace.path + pickerKind = nil + model.selectRemoteWorkspace(workspace) + } + } + case .model: + if model.modelOptions.isEmpty { + Text(model.localized("暂无可用模型")) + .font(.system(size: 13)) + .foregroundStyle(BitFunTheme.muted) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(18) + } else { + ForEach(model.modelOptions) { option in + selectionRow( + icon: option.source == "LOCAL" ? "gearshape" : "cloud", + title: option.primaryLabel, + subtitle: option.secondaryLabel, + selected: option.id == selectedModelID, + enabled: true + ) { + selectedModelID = option.id + pickerKind = nil + } + } + } + } + } + } + } + .background(BitFunTheme.card) + } + + private func selectionRow( + icon: String, + title: String, + subtitle: String, + selected: Bool, + enabled: Bool, + action: @escaping () -> Void + ) -> some View { + Button(action: action) { + HStack(spacing: 12) { + Image(systemName: selected ? "checkmark.circle" : "circle") + .font(.system(size: 19)) + .foregroundStyle(selected ? BitFunTheme.ink : Color.clear) + .frame(width: 20) + Image(systemName: icon) + .font(.system(size: 19, weight: .medium)) + .foregroundStyle(BitFunTheme.muted) + .frame(width: 24) + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(.system(size: 15, weight: .medium)) + .foregroundStyle(BitFunTheme.ink) + .lineLimit(1) + if !subtitle.isEmpty { + Text(subtitle) + .font(.system(size: 11)) + .foregroundStyle(BitFunTheme.muted) + .lineLimit(1) + } + } + Spacer(minLength: 0) + } + .frame(minHeight: 58) + .padding(.horizontal, 12) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(!enabled) + .opacity(enabled ? 1 : 0.55) + } + + private func selectionHeight(_ kind: RemoteCreateSelectionKind) -> CGFloat { + let count: Int + switch kind { + case .device: count = max(1, model.accountDevices.count) + case .workspace: count = max(1, model.remoteWorkspaces.count + 1) + case .model: count = max(1, model.modelOptions.count) + } + let header: CGFloat = horizontalSizeClass == .regular ? 16 : MobileDesignGeometry.sheetHeaderHeight + return min(440, header + CGFloat(count * 64) + 24) + } +} + +private enum RemoteCreateSelectionKind: String, Identifiable, Hashable { + case device + case workspace + case model + + var id: String { rawValue } + var title: String { + switch self { + case .device: return "桌面设备" + case .workspace: return "工作区" + case .model: return "选择模型" + } + } +} + +private struct RemoteCreateSelectionAnchorKey: PreferenceKey { + static var defaultValue: [RemoteCreateSelectionKind: Anchor] = [:] + + static func reduce( + value: inout [RemoteCreateSelectionKind: Anchor], + nextValue: () -> [RemoteCreateSelectionKind: Anchor] + ) { + value.merge(nextValue(), uniquingKeysWith: { _, next in next }) + } +} + +private struct MobileDownloadDocument: FileDocument { + static var readableContentTypes: [UTType] { [.data] } + let data: Data + + init(data: Data) { + self.data = data + } + + init(configuration: ReadConfiguration) throws { + data = configuration.file.regularFileContents ?? Data() + } + + func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { + FileWrapper(regularFileWithContents: data) + } +} + +private struct RemoteFilePreviewSheet: View { + @ObservedObject var model: MobileAppModel + let preview: MobileFilePreview + var embedded = false + @Environment(\.dismiss) private var dismiss + + var body: some View { + VStack(spacing: 0) { + HStack(spacing: 12) { + Image(systemName: preview.imageData == nil ? "doc.text" : "photo") + .font(.system(size: 16, weight: .medium)) + .foregroundStyle(MobileDesignColors.fileLink) + .frame(width: 34, height: 34) + .background(MobileDesignColors.fileLink.opacity(0.1)) + .clipShape(RoundedRectangle(cornerRadius: 9)) + Text(preview.name) + .font(MobileDesignTypography.titleSmall.font) .foregroundStyle(BitFunTheme.ink) - .lineLimit(2...4) - .focused($focused) + .lineLimit(1) + Spacer() Button { - pairingURL = UIPasteboard.general.string ?? "" - focused = false + model.downloadRemoteFile( + reference: "computer://\(preview.id)", + label: preview.name + ) } label: { - Image(systemName: "doc.on.clipboard") - .font(.system(size: 17, weight: .medium)) - .foregroundStyle(BitFunTheme.accent) - .frame(width: 40, height: 40) + Image(systemName: "arrow.down.circle") + .font(.system(size: 18, weight: .medium)) + .foregroundStyle(BitFunTheme.ink) + .frame(width: 36, height: 36) + } + .buttonStyle(.plain) + .accessibilityLabel(Text(model.localizedFormat("下载 %@", preview.name))) + Button { + model.dismissFilePreview() + if !embedded { dismiss() } + } label: { + Image(systemName: "xmark") + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(BitFunTheme.ink) + .frame(width: 36, height: 36) + .background(BitFunTheme.soft) + .clipShape(Circle()) } .buttonStyle(.plain) - .accessibilityLabel("从剪贴板粘贴") + .accessibilityLabel(Text(model.localized("关闭文件预览"))) } - .padding(.horizontal, 14) - .padding(.vertical, 10) - .background(BitFunTheme.card) - .overlay(RoundedRectangle(cornerRadius: 12).stroke(BitFunTheme.line, lineWidth: 1)) - .clipShape(RoundedRectangle(cornerRadius: 12)) + .padding(.horizontal, 18) + .padding(.vertical, 12) - if let error = model.pairingError { - Text(error) - .font(.system(size: 13)) - .foregroundStyle(BitFunTheme.red) - .lineSpacing(3) - .padding(.top, 12) - } + Rectangle().fill(BitFunTheme.line).frame(height: 1) - Button { - model.submitPairing(url: pairingURL) - focused = false - } label: { - HStack(spacing: 8) { - if model.pairingBusy { ProgressView().tint(.white) } - Text(model.pairingBusy ? "正在连接" : "连接") - .font(.system(size: 16, weight: .semibold)) + Group { + if model.filePreviewLoading { + VStack(spacing: 12) { + ProgressView() + Text(model.localized("正在加载文件")) + .font(MobileDesignTypography.bodySmall.font) + .foregroundStyle(BitFunTheme.muted) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let failure = preview.failure { + VStack(spacing: 10) { + Image(systemName: "exclamationmark.triangle") + .font(.system(size: 28, weight: .medium)) + Text(model.localized("无法预览")) + .font(MobileDesignTypography.titleSmall.font) + Text(failure) + .font(MobileDesignTypography.bodySmall.font) + .multilineTextAlignment(.center) + } + .foregroundStyle(BitFunTheme.muted) + .padding(24) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let data = preview.imageData, let image = UIImage(data: data) { + ScrollView([.horizontal, .vertical], showsIndicators: false) { + Image(uiImage: image) + .resizable() + .scaledToFit() + .padding(18) + } + } else { + ScrollView(showsIndicators: false) { + if preview.mimeType.contains("markdown") || preview.name.lowercased().hasSuffix(".md") { + MarkdownMessageView(text: preview.content, model: model) + .padding(18) + } else { + Text(preview.content) + .font(.system(size: 13, design: .monospaced)) + .foregroundStyle(BitFunTheme.ink) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(18) + .textSelection(.enabled) + } + } } - .foregroundStyle(.white) - .frame(maxWidth: .infinity, minHeight: 48) - .background(pairingURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? BitFunTheme.muted : BitFunTheme.accent) - .clipShape(Capsule()) } - .buttonStyle(.plain) - .disabled(model.pairingBusy || pairingURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) - .padding(.top, 22) + .frame(maxWidth: .infinity, maxHeight: .infinity) - Spacer() + if preview.truncated { + Text(model.localized("文件较大,当前仅显示部分内容")) + .font(MobileDesignTypography.labelSmall.font) + .foregroundStyle(BitFunTheme.muted) + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + .background(BitFunTheme.soft) + } } - .padding(.horizontal, 20) - .padding(.top, 22) .background(BitFunTheme.page) - .presentationDetents([.medium]) + .presentationDetents([.large]) .presentationDragIndicator(.visible) + } +} + +private struct PairingSheet: View { + private enum Step { case intro, scan } + + @ObservedObject var model: MobileAppModel + @Environment(\.dismiss) private var dismiss + @State private var step: Step = .intro + @State private var pairingURL = ProcessInfo.processInfo.arguments.contains("--pairing-account") + ? "https://relay.example.com/#/pair?room=preview-room&pk=preview-key&auth=account&user=preview" + : "" + @State private var pairingUserID = "" + // Intentionally transient: pairing passwords must never enter saved scene state. + @State private var pairingPassword = "" + @State private var scannerOpen = false + @State private var manualOpen = false + @FocusState private var focused: Bool + + var body: some View { + return ZStack { + if step == .intro { introPage } else { scanPage } + if manualOpen { manualPairingOverlay } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(BitFunTheme.card) + .onAppear { + if model.pairingScanRequested { + step = .scan + scannerOpen = true + model.consumePairingScanRequest() + } else if ProcessInfo.processInfo.arguments.contains("--pairing-manual") || + ProcessInfo.processInfo.arguments.contains("--pairing-account") { + step = .scan + manualOpen = true + focused = !ProcessInfo.processInfo.arguments.contains("--pairing-account") + } + } .fullScreenCover(isPresented: $scannerOpen) { QRCodeScannerView { code in pairingURL = code scannerOpen = false + if PairingLinkHintsKt.inspectPairingLink(url: code).requiresAccount { + manualOpen = true + focused = true + } else { + model.submitPairing(url: code) + } } .ignoresSafeArea() } } -} -private struct QRCodeScannerView: UIViewControllerRepresentable { - let onCode: (String) -> Void + private var introPage: some View { + VStack(spacing: 0) { + hero(height: 250) + VStack(spacing: 15) { + Image(systemName: "desktopcomputer") + .font(.system(size: 54, weight: .medium)) + .foregroundStyle(BitFunTheme.ink) + .frame(width: 88, height: 88) + .background(BitFunTheme.card) + .clipShape(RoundedRectangle(cornerRadius: 28)) + .shadow(color: BitFunTheme.line, radius: 18, y: 7) + Text(model.localized("选择连接方式")) + .font(.system(size: 24, weight: .bold)) + .foregroundStyle(BitFunTheme.ink) + } + .padding(.horizontal, 28) + .offset(y: -10) + Spacer(minLength: 12) + SignedOutConnectionActions( + scanTitle: model.localized("扫码连接"), + accountTitle: model.localized("登录 BitFun 账号"), + onScan: { + step = .scan + scannerOpen = true + }, + onOpenAccount: model.openAccountFromPairing, + enabled: !model.pairingBusy, + buttonHeight: 58, + spacing: 12, + fontSize: 20 + ) + .padding(.horizontal, 44) + .padding(.bottom, 34) + } + } - func makeUIViewController(context: Context) -> QRScannerController { - let controller = QRScannerController() - controller.onCode = onCode - return controller + private var scanPage: some View { + VStack(spacing: 0) { + hero(height: 252) + VStack(spacing: 22) { + Button { scannerOpen = true } label: { + Image(systemName: "qrcode.viewfinder") + .font(.system(size: 72, weight: .regular)) + .foregroundStyle(BitFunTheme.ink) + .frame(width: 176, height: 176) + .background(MobileDesignColors.connectHeroSurface) + .overlay(RoundedRectangle(cornerRadius: 34).stroke(BitFunTheme.line, lineWidth: 1.5)) + .clipShape(RoundedRectangle(cornerRadius: 34)) + } + .buttonStyle(.plain) + Text(model.localized("扫描二维码")) + .font(.system(size: 24, weight: .bold)).foregroundStyle(BitFunTheme.ink) + if let error = model.pairingError { + Text(error).font(.system(size: 13)).foregroundStyle(BitFunTheme.red) + .multilineTextAlignment(.center) + } + } + .offset(y: -50) + Spacer(minLength: 12) + Button { manualOpen = true; focused = true } label: { + Text(model.localized("手动输入配对码")) + .font(.system(size: 20, weight: .bold)) + .foregroundStyle(BitFunTheme.ink) + .frame(maxWidth: .infinity, minHeight: 58) + .background(BitFunTheme.card) + .overlay(Capsule().stroke(BitFunTheme.line, lineWidth: 1.5)) + .clipShape(Capsule()) + } + .buttonStyle(.plain) + .padding(.horizontal, 44) + .padding(.bottom, 34) + } } - func updateUIViewController(_ uiViewController: QRScannerController, context: Context) {} + private func hero(height: CGFloat) -> some View { + ZStack(alignment: .topLeading) { + LinearGradient( + colors: [MobileDesignColors.connectHeroBg, MobileDesignColors.connectHeroSurface], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + Button { + if step == .scan { step = .intro } else { dismiss() } + } label: { + Image(systemName: "chevron.left") + .font(.system(size: 20, weight: .medium)) + .foregroundStyle(BitFunTheme.ink) + .frame(width: 44, height: 44) + .background(BitFunTheme.card) + .clipShape(Circle()) + } + .buttonStyle(.plain) + .padding(.top, 18).padding(.leading, 18) + } + .frame(height: height) + } + + private var manualPairingOverlay: some View { + let hints = PairingLinkHintsKt.inspectPairingLink(url: pairingURL) + let effectiveUserID = pairingUserID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + ? hints.suggestedUserId + : pairingUserID.trimmingCharacters(in: .whitespacesAndNewlines) + let canSubmit = !model.pairingBusy && + !pairingURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && + (!hints.requiresAccount || (!effectiveUserID.isEmpty && !pairingPassword.isEmpty)) + + return ZStack { + MobileDesignColors.modalScrim + .ignoresSafeArea() + .onTapGesture { + if !model.pairingBusy { + pairingPassword = "" + manualOpen = false + } + } + VStack(alignment: .leading, spacing: 20) { + Text(model.localized(hints.requiresAccount ? "账号认证配对" : "手动输入配对码")) + .font(.system(size: 24, weight: .bold)).foregroundStyle(BitFunTheme.ink) + Text(model.localized( + hints.requiresAccount + ? "此桌面要求使用 BitFun 账号验证身份。" + : "输入桌面端显示的配对链接或代码。" + )) + .font(.system(size: 17)).foregroundStyle(BitFunTheme.muted).lineSpacing(5) + TextField(model.localized("配对码或连接链接"), text: $pairingURL) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .keyboardType(.URL) + .lineLimit(1) + .font(.system(size: 20)).foregroundStyle(BitFunTheme.ink) + .padding(.horizontal, 20).frame(minHeight: 62) + .background(BitFunTheme.soft).clipShape(Capsule()) + .focused($focused) + if hints.requiresAccount { + TextField( + hints.suggestedUserId.isEmpty + ? model.localized("BitFun 用户名") + : hints.suggestedUserId, + text: $pairingUserID + ) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .textContentType(.username) + .font(.system(size: 18)).foregroundStyle(BitFunTheme.ink) + .padding(.horizontal, 20).frame(minHeight: 56) + .background(BitFunTheme.soft).clipShape(Capsule()) + + SecureField(model.localized("BitFun 密码"), text: $pairingPassword) + .textContentType(.password) + .font(.system(size: 18)).foregroundStyle(BitFunTheme.ink) + .padding(.horizontal, 20).frame(minHeight: 56) + .background(BitFunTheme.soft).clipShape(Capsule()) + + Text(model.localized("账号凭据只用于本次加密配对,不会保存。")) + .font(.system(size: 13)) + .foregroundStyle(BitFunTheme.muted) + .lineSpacing(3) + } + if let error = model.pairingError { + Text(error).font(.system(size: 13)).foregroundStyle(BitFunTheme.red) + } + HStack(spacing: 12) { + pairingButton("取消", primary: false) { + pairingPassword = "" + manualOpen = false + focused = false + } + pairingButton(model.pairingBusy ? "正在连接" : "配对", primary: true) { + if hints.requiresAccount { + model.submitPairing( + url: pairingURL, + userID: effectiveUserID, + password: pairingPassword + ) + pairingPassword = "" + } else { + model.submitPairing(url: pairingURL) + } + focused = false + } + .disabled(!canSubmit) + } + } + .padding(.horizontal, 28).padding(.top, 30).padding(.bottom, 28) + .frame(maxWidth: 520) + .background(BitFunTheme.card) + .clipShape(RoundedRectangle(cornerRadius: 34)) + .overlay(RoundedRectangle(cornerRadius: 34).stroke(BitFunTheme.line, lineWidth: 1)) + .padding(.horizontal, 34) + } + } + + private func pairingButton(_ title: String, primary: Bool, action: @escaping () -> Void) -> some View { + Button(action: action) { + Text(model.localized(title)) + .font(.system(size: 19, weight: .bold)) + .foregroundStyle(primary ? Color.white : BitFunTheme.ink) + .frame(maxWidth: .infinity, minHeight: 58) + .background(primary ? BitFunTheme.accent : BitFunTheme.soft) + .clipShape(Capsule()) + } + .buttonStyle(.plain) + } +} + +private struct QRCodeScannerView: UIViewControllerRepresentable { + let onCode: (String) -> Void + + func makeUIViewController(context: Context) -> QRScannerController { + let controller = QRScannerController() + controller.onCode = onCode + return controller + } + + func updateUIViewController(_ uiViewController: QRScannerController, context: Context) {} } private final class QRScannerController: UIViewController, AVCaptureMetadataOutputObjectsDelegate { @@ -197,207 +1145,1473 @@ private final class QRScannerController: UIViewController, AVCaptureMetadataOutp } } - override func viewDidLayoutSubviews() { - super.viewDidLayoutSubviews() - previewLayer?.frame = view.bounds + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + previewLayer?.frame = view.bounds + } + + private func configureCapture() { + guard let device = AVCaptureDevice.default(for: .video), + let input = try? AVCaptureDeviceInput(device: device), + session.canAddInput(input) else { return } + let output = AVCaptureMetadataOutput() + guard session.canAddOutput(output) else { return } + session.addInput(input) + session.addOutput(output) + output.setMetadataObjectsDelegate(self, queue: .main) + output.metadataObjectTypes = [.qr] + let layer = AVCaptureVideoPreviewLayer(session: session) + layer.videoGravity = .resizeAspectFill + view.layer.insertSublayer(layer, at: 0) + previewLayer = layer + session.startRunning() + } + + func metadataOutput( + _ output: AVCaptureMetadataOutput, + didOutput metadataObjects: [AVMetadataObject], + from connection: AVCaptureConnection, + ) { + guard let value = (metadataObjects.first as? AVMetadataMachineReadableCodeObject)?.stringValue, + !value.isEmpty else { return } + session.stopRunning() + onCode?(value) + dismiss(animated: true) + } +} + +private struct LocalHomeView: View { + @ObservedObject var model: MobileAppModel + + private let prompts: [(String, String)] = [ + ("Aa", "帮我写点内容"), + ("≡", "梳理一个问题"), + ("✓", "制定行动计划") + ] + + var body: some View { + VStack(spacing: 0) { + Spacer(minLength: 0) + VStack(spacing: 12) { + ForEach(prompts, id: \.1) { icon, title in + let promptText = model.localized(title) + Button { + model.draft = promptText + model.send() + } label: { + HStack(spacing: 20) { + Text(icon) + .font(.system(size: 29, weight: .regular)) + .foregroundStyle(BitFunTheme.muted) + .frame(width: 32) + .fixedSize() + Text(promptText) + .font(.system(size: 20, weight: .medium)) + .foregroundStyle(BitFunTheme.muted) + Spacer(minLength: 0) + } + .frame(height: 48) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 20) + .padding(.bottom, 12) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(BitFunTheme.page) + } +} + +private struct RemoteHomeView: View { + @ObservedObject var model: MobileAppModel + + var body: some View { + ZStack(alignment: .topTrailing) { + VStack(spacing: 12) { + Spacer() + ZStack { + Image(systemName: "desktopcomputer") + .font(.system(size: 42, weight: .medium)) + .foregroundStyle(BitFunTheme.ink) + } + .frame(width: 74, height: 74) + .background(BitFunTheme.card) + .overlay(RoundedRectangle(cornerRadius: 24).stroke(BitFunTheme.line, lineWidth: 1)) + .clipShape(RoundedRectangle(cornerRadius: 24)) + Text(model.localized("连接桌面端")) + .font(.system(size: 18, weight: .bold)) + .foregroundStyle(BitFunTheme.ink) + Text(model.localized("扫描桌面端显示的二维码,开始远程处理任务。")) + .font(.system(size: 13)) + .foregroundStyle(BitFunTheme.muted) + .multilineTextAlignment(.center) + .lineSpacing(7) + .padding(.horizontal, 20) + Button(model.localized("连接")) { model.connectRemote() } + .font(.system(size: 15, weight: .medium)) + .foregroundStyle(.white) + .frame(width: 136, height: 44) + .background(BitFunTheme.accent) + .clipShape(Capsule()) + Spacer() + } + remoteSettingsButton + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(.horizontal, 20) + .padding(.bottom, 48) + .background(BitFunTheme.page) + } + + private var remoteSettingsButton: some View { + Button { model.remoteControlSettingsOpen = true } label: { + Image(systemName: "gearshape") + .font(.system(size: 18, weight: .medium)) + .foregroundStyle(BitFunTheme.ink) + .frame(width: 44, height: 44) + .background(BitFunTheme.card) + .overlay(Circle().stroke(BitFunTheme.line, lineWidth: 1)) + .clipShape(Circle()) + } + .buttonStyle(.plain) + .accessibilityLabel(model.localized("远程控制设置")) + .padding(.top, 16).padding(.trailing, 16) + } +} + +private struct RemoteConnectedHomeView: View { + @ObservedObject var model: MobileAppModel + + var body: some View { + ZStack(alignment: .topTrailing) { + VStack(spacing: 14) { + Spacer() + Image(systemName: "desktopcomputer.and.macbook") + .font(.system(size: 34, weight: .medium)).foregroundStyle(BitFunTheme.muted) + Text(model.localized("桌面端已连接")) + .font(MobileDesignTypography.titleMedium.font).foregroundStyle(BitFunTheme.ink) + Text(model.localized("选择已有会话,或在当前工作区创建一个新会话。")) + .font(MobileDesignTypography.bodySmall.font).foregroundStyle(BitFunTheme.muted) + .multilineTextAlignment(.center) + Button { model.remoteCreateOpen = true } label: { + Label(model.localized("新建远程会话"), systemImage: "plus") + .font(MobileDesignTypography.labelMedium.font).foregroundStyle(.white) + .frame(minWidth: 176, minHeight: 44).background(BitFunTheme.accent).clipShape(Capsule()) + } + .buttonStyle(.plain) + Spacer() + } + Button { model.remoteControlSettingsOpen = true } label: { + Image(systemName: "gearshape") + .font(.system(size: 18, weight: .medium)) + .foregroundStyle(BitFunTheme.ink) + .frame(width: 44, height: 44) + .background(BitFunTheme.card) + .overlay(Circle().stroke(BitFunTheme.line, lineWidth: 1)) + .clipShape(Circle()) + } + .buttonStyle(.plain) + .accessibilityLabel(model.localized("远程控制设置")) + .padding(.top, 16).padding(.trailing, 16) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(BitFunTheme.page) + } +} + +private struct ConnectionStatusBar: View { + let phase: ConnectionPhase + var detail: String? + let onRetry: () -> Void + var body: some View { + HStack(spacing: 8) { + Circle().fill(phase == .reconnecting ? BitFunTheme.muted : BitFunTheme.red).frame(width: 8, height: 8) + Text(MobileLocalization.text(phase == .reconnecting ? "正在恢复连接" : "连接不可用")) + .font(.system(size: 13, weight: .medium)) + Text( + detail ?? MobileLocalization.text( + phase == .reconnecting ? "正在重新连接桌面端" : "请重新连接" + ) + ) + .font(.system(size: 12)) + .foregroundStyle(BitFunTheme.muted) + Spacer() + if phase == .disconnected { + Button(MobileLocalization.text("重试"), action: onRetry) + .font(.system(size: 13, weight: .semibold)) + .buttonStyle(.plain) + .foregroundStyle(BitFunTheme.accent) + } + } + .foregroundStyle(BitFunTheme.ink) + .padding(.horizontal, 18) + .frame(height: 48) + .background(BitFunTheme.soft) + } +} + +private struct SettingsView: View { + @ObservedObject var model: MobileAppModel + @Environment(\.dismiss) private var dismiss + @State private var accountOpen = false + + private var appVersion: String { + Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "1.0.0" + } + + private var selectedModelName: String { + model.modelOptions.first(where: \.selected)?.primaryLabel + ?? model.modelOptions.first?.primaryLabel + ?? model.localized("未配置") + } + + var body: some View { + ZStack(alignment: .topTrailing) { + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 0) { + Text(model.localized("设置")) + .font(.system(size: 28, weight: .bold)) + .foregroundStyle(BitFunTheme.ink) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.bottom, 30) + + Button { accountOpen = true } label: { + SettingsCard { + SettingsProfileRow( + subtitle: model.accountUser ?? model.localized("未登录") + ) + } + } + .buttonStyle(.plain) + .padding(.bottom, 24) + + SettingsGroup(title: "通用") { + VStack(spacing: 0) { + Button { model.languagePickerOpen = true } label: { + SettingsValueRow( + icon: "textformat", + title: "语言", + value: model.appLanguage.nativeName, + showsChevron: true + ) + } + .buttonStyle(.plain) + Divider().overlay(BitFunTheme.line).padding(.horizontal, 26) + Button { model.generalConfigOpen = true } label: { + SettingsValueRow( + icon: "square.grid.2x2", + title: "模型", + value: selectedModelName, + showsChevron: true + ) + } + .buttonStyle(.plain) + } + } + SettingsGroup(title: "关于") { + VStack(spacing: 0) { + SettingsValueRow( + icon: nil, + title: "产品", + value: "BitFun iOS版" + ) + Divider().overlay(BitFunTheme.line).padding(.horizontal, 26) + SettingsValueRow(icon: nil, title: "版本", value: appVersion) + } + } + } + .padding(.horizontal, 16) + .padding(.top, 64) + .padding(.bottom, 34) + } + + Button { dismiss() } label: { + Image(systemName: "xmark") + .font(.system(size: 18, weight: .regular)) + .foregroundStyle(BitFunTheme.ink) + .frame(width: 40, height: 40) + .background(BitFunTheme.card) + .clipShape(Circle()) + } + .buttonStyle(.plain) + .accessibilityLabel(model.localized("关闭")) + .padding(.top, 22) + .padding(.trailing, 18) + + if model.languagePickerOpen { + LanguagePickerSheet(model: model) + .transition(.move(edge: .trailing).combined(with: .opacity)) + } else if model.generalConfigOpen { + GeneralChatConfigSheet(model: model) + .transition(.move(edge: .trailing).combined(with: .opacity)) + } else if accountOpen { + AccountSettingsView(model: model, onClose: { accountOpen = false }) + .transition(.move(edge: .trailing).combined(with: .opacity)) + } + } + .background(BitFunTheme.page) + .animation(.easeInOut(duration: 0.2), value: model.languagePickerOpen) + .animation(.easeInOut(duration: 0.2), value: model.generalConfigOpen) + .animation(.easeInOut(duration: 0.2), value: accountOpen) + } +} + +private struct LanguagePickerSheet: View { + @ObservedObject var model: MobileAppModel + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + BitFunSelectionHeader(title: "选择语言", onClose: { model.languagePickerOpen = false }) + Divider().overlay(BitFunTheme.line) + + VStack(spacing: 0) { + ForEach(MobileLanguage.allCases) { language in + Button { + model.setLanguage(language) + model.languagePickerOpen = false + } label: { + HStack { + Text(language.nativeName) + .font(.system(size: 16, weight: .medium)) + .foregroundStyle(BitFunTheme.ink) + Spacer() + if model.appLanguage == language { + Image(systemName: "checkmark") + .font(.system(size: 18, weight: .medium)) + .foregroundStyle(BitFunTheme.ink) + } + } + .padding(.horizontal, 16) + .frame(height: MobileDesignGeometry.selectionRowHeight) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + } + .padding(.top, 8) + .padding(.bottom, 28) + + Spacer(minLength: 0) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .background(BitFunTheme.card) + .clipShape(RoundedRectangle(cornerRadius: MobileDesignGeometry.selectionTopRadius)) + } +} + +private struct RemoteViewSettingsView: View { + @ObservedObject var model: MobileAppModel + + private var statuses: [String] { + model.sessionListStatusOptions + } + + private var workspaces: [MobileSessionWorkspaceOption] { + model.sessionListWorkspaceOptions + } + + private var agentGroups: [String] { + model.sessionListAgentGroups + } + + var body: some View { + VStack(spacing: 0) { + BitFunModalHeader( + title: "视图设置", + subtitle: "调整会话列表的分组和信息密度", + onClose: { model.remoteViewSettingsOpen = false } + ) + .padding(.horizontal, 20) + Divider().overlay(BitFunTheme.line) + + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 8) { + sectionTitle("分组方式") + SettingsCard { + choiceRow("按项目", value: "PROJECT", selected: model.remoteGroupMode) + settingsDivider + choiceRow("按时间倒序排列", value: "TIME", selected: model.remoteGroupMode) + settingsDivider + choiceRow("聊天优先", value: "CHAT", selected: model.remoteGroupMode) + } + + sectionTitle("筛选") + filterLabel("工作区") + SettingsCard { + filterRow( + "所有工作区", + selected: model.remoteWorkspaceFilter.isEmpty, + action: { model.remoteWorkspaceFilter = "" } + ) + ForEach(workspaces) { workspace in + settingsDivider + filterRow( + workspace.name, + selected: normalizedPath(model.remoteWorkspaceFilter) == normalizedPath(workspace.path), + action: { model.remoteWorkspaceFilter = workspace.path } + ) + } + } + + filterLabel("Agent 类型") + SettingsCard { + filterRow( + "所有 Agent 类型", + selected: model.remoteViewAgentFilter.isEmpty, + action: { model.remoteViewAgentFilter = "" } + ) + ForEach(agentGroups, id: \.self) { group in + settingsDivider + filterRow( + agentLabel(group), + selected: model.remoteViewAgentFilter == group, + action: { model.remoteViewAgentFilter = group } + ) + } + } + + filterLabel("状态") + SettingsCard { + filterRow( + "所有状态", + selected: model.remoteStatusFilter.isEmpty, + action: { model.remoteStatusFilter = "" } + ) + ForEach(statuses, id: \.self) { status in + settingsDivider + filterRow( + statusLabel(status), + selected: model.remoteStatusFilter == status, + action: { model.remoteStatusFilter = status } + ) + } + } + + sectionTitle("显示信息") + SettingsCard { + metadataToggle("工作区", isOn: $model.remoteShowWorkspaceMetadata) + settingsDivider + metadataToggle("更新时间", isOn: $model.remoteShowUpdatedMetadata) + settingsDivider + metadataToggle("状态", isOn: $model.remoteShowStatusMetadata) + } + } + .padding(.horizontal, 20) + .padding(.top, 8) + .padding(.bottom, 34) + } + } + .background(BitFunTheme.page) + } + + private func sectionTitle(_ title: String) -> some View { + Text(model.localized(title)) + .font(MobileDesignTypography.labelLarge.font) + .foregroundStyle(BitFunTheme.muted) + .padding(.top, 8) + .padding(.leading, 4) + } + + private func filterLabel(_ title: String) -> some View { + Text(model.localized(title)) + .font(MobileDesignTypography.labelSmall.font) + .foregroundStyle(BitFunTheme.muted) + .padding(.top, 2) + .padding(.leading, 10) + } + + private var settingsDivider: some View { + Divider().overlay(BitFunTheme.line).padding(.horizontal, 20) + } + + private func choiceRow(_ title: String, value: String, selected: String) -> some View { + filterRow(title, selected: value == selected) { model.remoteGroupMode = value } + } + + private func filterRow(_ title: String, selected: Bool, action: @escaping () -> Void) -> some View { + Button(action: action) { + HStack(spacing: 12) { + Text(model.localized(title)) + .font(.system(size: 16, weight: .medium)) + .foregroundStyle(BitFunTheme.ink) + .lineLimit(1) + Spacer(minLength: 0) + if selected { + Image(systemName: "checkmark") + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(BitFunTheme.accent) + } + } + .padding(.horizontal, 20) + .frame(minHeight: 52) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + + private func metadataToggle(_ title: String, isOn: Binding) -> some View { + Toggle(isOn: isOn) { + Text(model.localized(title)) + .font(.system(size: 16, weight: .medium)) + .foregroundStyle(BitFunTheme.ink) + } + .tint(BitFunTheme.accent) + .padding(.horizontal, 20) + .frame(minHeight: 56) + } + + private func agentLabel(_ group: String) -> String { + switch group { + case "CHAT": return "聊天" + case "COWORK": return "Cowork" + default: return "Code" + } + } + + private func statusLabel(_ status: String) -> String { + switch status { + case "active", "running": return "运行中" + case "ready", "idle": return "就绪" + case "archived": return "已归档" + default: return status + } + } + + private func normalizedPath(_ path: String) -> String { + var result = path.trimmingCharacters(in: .whitespacesAndNewlines) + while result.count > 1 && (result.hasSuffix("/") || result.hasSuffix("\\")) { + result.removeLast() + } + return result + } +} + +/// The desktop-wide control page mirrors HarmonyOS' RemoteControlSettingsSheet. +/// Account navigation and full-access confirmation stay inside this adaptive +/// modal so a settings action never creates a second sheet or scrim. +private struct RemoteControlSettingsView: View { + private enum Page { case control, account } + + @ObservedObject var model: MobileAppModel + @State private var page: Page = .control + @State private var confirmingFullAccess = false + + var body: some View { + Group { + if page == .account { + AccountSettingsView(model: model, onClose: { page = .control }) + } else { + controlPage + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(BitFunTheme.page) + .animation(.easeInOut(duration: 0.2), value: page) + .onAppear { + if model.remoteConnected { model.refreshRemotePermissionMode() } + } + } + + private var controlPage: some View { + ZStack(alignment: .topTrailing) { + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 0) { + Text(model.localized("远程控制")) + .font(.system(size: 20, weight: .bold)) + .foregroundStyle(BitFunTheme.ink) + .frame(maxWidth: .infinity, minHeight: 56, alignment: .center) + .padding(.bottom, 30) + + Button { page = .account } label: { + SettingsCard { + HStack(spacing: 12) { + Image(systemName: "person.crop.circle") + .font(.system(size: 28, weight: .regular)) + .foregroundStyle(BitFunTheme.muted) + .frame(width: 34, height: 34) + Text(model.localized(model.accountUser == nil ? "登录 BitFun 账号" : "个人资料")) + .font(.system(size: 18, weight: .medium)) + .foregroundStyle(BitFunTheme.ink) + Spacer() + Image(systemName: "chevron.right") + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(BitFunTheme.muted.opacity(0.72)) + } + .padding(.horizontal, 18) + .frame(height: 64) + } + } + .buttonStyle(.plain) + .padding(.bottom, 28) + + remoteSectionTitle("当前远程控制") + currentControlCard + + remoteSectionTitle("其他连接方式") + .padding(.top, 16) + Button { + model.remoteControlSettingsOpen = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.22) { + model.connectRemote() + } + } label: { + SettingsCard { + HStack(spacing: 12) { + Image(systemName: "link") + .font(.system(size: 20, weight: .regular)) + .foregroundStyle(BitFunTheme.muted) + .frame(width: 24, height: 24) + VStack(alignment: .leading, spacing: 2) { + Text(model.localized("扫描二维码连接")) + .font(.system(size: 16, weight: .medium)) + .foregroundStyle(BitFunTheme.ink) + Text(model.localized("适用于临时配对或未登录账号的桌面端。")) + .font(.system(size: 13)) + .foregroundStyle(BitFunTheme.muted) + .lineLimit(2) + } + Spacer(minLength: 8) + Image(systemName: "chevron.right") + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(BitFunTheme.muted.opacity(0.72)) + } + .padding(.horizontal, 18) + .frame(minHeight: 78) + } + } + .buttonStyle(.plain) + + permissionSection + .padding(.top, 20) + } + .padding(.horizontal, 18) + .padding(.top, 20) + .padding(.bottom, 42) + } + + Button { model.remoteControlSettingsOpen = false } label: { + Image(systemName: "xmark") + .font(.system(size: 17, weight: .regular)) + .foregroundStyle(BitFunTheme.ink) + .frame(width: 40, height: 40) + .background(BitFunTheme.card) + .clipShape(Circle()) + } + .buttonStyle(.plain) + .accessibilityLabel(model.localized("关闭")) + .padding(.top, 16).padding(.trailing, 16) + } + } + + private var currentControlCard: some View { + SettingsCard { + HStack(spacing: 14) { + Image(systemName: "desktopcomputer") + .font(.system(size: 23, weight: .regular)) + .foregroundStyle(BitFunTheme.muted) + .frame(width: 40, height: 40) + VStack(alignment: .leading, spacing: 2) { + Text(model.localized("BitFun 桌面版")) + .font(.system(size: 14)).foregroundStyle(BitFunTheme.muted) + Text(model.accountDeviceName ?? model.localized("尚未连接桌面端")) + .font(.system(size: 18, weight: .medium)).foregroundStyle(BitFunTheme.ink) + .lineLimit(1) + Text(connectionStatus) + .font(.system(size: 14)).foregroundStyle(BitFunTheme.muted) + } + Spacer(minLength: 6) + if model.remoteConnected { + remoteChip("断开", action: model.disconnectRemote) + } else if model.connectionPhase == .disconnected { + remoteChip("重新连接", action: model.verifyRemoteConnection) + } + } + .padding(.horizontal, 18) + .frame(minHeight: 92) + + Divider().overlay(BitFunTheme.line).padding(.horizontal, 18) + + HStack(spacing: 10) { + Image(systemName: "link") + .font(.system(size: 18)).foregroundStyle(BitFunTheme.muted) + .frame(width: 20, height: 20) + Text(model.localized("连接来源")) + .font(.system(size: 14)).foregroundStyle(BitFunTheme.muted) + Spacer() + Text(connectionSource) + .font(.system(size: 13)).foregroundStyle(BitFunTheme.ink) + .padding(.horizontal, 10).padding(.vertical, 5) + .background(BitFunTheme.soft).clipShape(Capsule()) + } + .padding(.horizontal, 18) + .frame(height: 52) + } + } + + private var permissionSection: some View { + VStack(alignment: .leading, spacing: 10) { + HStack { + remoteSectionTitle("远程权限") + Spacer() + if model.remoteConnected { + Button(model.localized("刷新")) { model.refreshRemotePermissionMode() } + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(BitFunTheme.ink) + .buttonStyle(.plain) + .disabled(model.busy) + } + } + SettingsCard { + Text(model.localized("控制桌面端执行工具时采用的确认方式。")) + .font(.system(size: 13)).foregroundStyle(BitFunTheme.muted) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 18).padding(.top, 16).padding(.bottom, 4) + permissionRow("ASK", title: "每次询问", detail: "执行需要授权的操作前先询问。") + Divider().overlay(BitFunTheme.line).padding(.horizontal, 18) + permissionRow("AUTO", title: "自动允许", detail: "自动允许常规操作,高风险操作仍会询问。") + Divider().overlay(BitFunTheme.line).padding(.horizontal, 18) + permissionRow("FULL_ACCESS", title: "完全访问", detail: "不再询问,允许桌面端执行所有操作。") + + if let failure = model.remotePermissionFailure, !failure.isEmpty { + Text(failure) + .font(.system(size: 12)).foregroundStyle(BitFunTheme.red) + .padding(.horizontal, 18).padding(.bottom, 10) + } + + if confirmingFullAccess { + fullAccessConfirmation + } + } + } + } + + private var fullAccessConfirmation: some View { + VStack(alignment: .leading, spacing: 10) { + Text(model.localized("确认完全访问")) + .font(.system(size: 15, weight: .bold)).foregroundStyle(BitFunTheme.red) + Text(model.localized("完全访问会取消所有操作确认。仅在你信任当前桌面端时启用。")) + .font(.system(size: 13)).foregroundStyle(BitFunTheme.ink).lineSpacing(4) + HStack(spacing: 10) { + confirmationButton("取消", destructive: false) { confirmingFullAccess = false } + confirmationButton("启用完全访问", destructive: true) { + model.setRemotePermissionMode("FULL_ACCESS") + confirmingFullAccess = false + } + } + } + .padding(16) + .overlay(RoundedRectangle(cornerRadius: 18).stroke(BitFunTheme.red, lineWidth: 1)) + .padding(.horizontal, 12).padding(.bottom, 14) + } + + private func permissionRow(_ mode: String, title: String, detail: String) -> some View { + Button { + if mode == "FULL_ACCESS" { confirmingFullAccess = true } + else { + confirmingFullAccess = false + model.setRemotePermissionMode(mode) + } + } label: { + HStack(spacing: 12) { + ZStack { + if model.remotePermissionMode == mode { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 20)).foregroundStyle(BitFunTheme.ink) + } + } + .frame(width: 22, height: 24) + VStack(alignment: .leading, spacing: 3) { + Text(model.localized(title)) + .font(.system(size: 16, weight: .medium)).foregroundStyle(BitFunTheme.ink) + Text(model.localized(detail)) + .font(.system(size: 12)).foregroundStyle(BitFunTheme.muted) + .lineLimit(2) + } + Spacer(minLength: 0) + } + .padding(.horizontal, 18) + .frame(minHeight: 72) + .contentShape(Rectangle()) + .opacity(model.remoteConnected && !model.busy ? 1 : 0.54) + } + .buttonStyle(.plain) + .disabled(!model.remoteConnected || model.busy) + } + + private func remoteSectionTitle(_ title: String) -> some View { + Text(model.localized(title)) + .font(.system(size: 18, weight: .bold)) + .foregroundStyle(BitFunTheme.muted) + .frame(maxWidth: .infinity, minHeight: 42, alignment: .leading) + .padding(.horizontal, 18) + } + + private func remoteChip(_ title: String, action: @escaping () -> Void) -> some View { + Button(action: action) { + Text(model.localized(title)) + .font(.system(size: 14)).foregroundStyle(BitFunTheme.ink) + .padding(.horizontal, 10).padding(.vertical, 7) + .background(BitFunTheme.soft).clipShape(Capsule()) + } + .buttonStyle(.plain) + } + + private func confirmationButton( + _ title: String, + destructive: Bool, + action: @escaping () -> Void + ) -> some View { + Button(action: action) { + Text(model.localized(title)) + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(destructive ? Color.white : BitFunTheme.ink) + .frame(maxWidth: .infinity, minHeight: 42) + .background(destructive ? BitFunTheme.red : BitFunTheme.soft) + .clipShape(Capsule()) + } + .buttonStyle(.plain) + } + + private var connectionStatus: String { + switch model.connectionPhase { + case .connected: model.localized(model.remoteConnected ? "已连接" : "未连接") + case .reconnecting: model.localized("正在重新连接") + case .disconnected: model.localized("连接已断开") + } + } + + private var connectionSource: String { + if model.accountSelectedDeviceID != nil { return model.localized("账号设备") } + if model.remoteConnected { return model.localized("扫码配对") } + return model.localized("未连接") + } +} + +private struct AccountSettingsView: View { + @ObservedObject var model: MobileAppModel + var onClose: (() -> Void)? = nil + @State private var relayURL = AccountDefaults.shared.CLOUD_RELAY_URL + @State private var username = "" + @State private var password = "" + + var body: some View { + Group { + if model.accountUser == nil { + loginPage + } else { + profilePage + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(BitFunTheme.page) + } + + private var loginPage: some View { + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 0) { + Button { close() } label: { + Image(systemName: "chevron.left") + .font(.system(size: 19, weight: .medium)) + .foregroundStyle(BitFunTheme.ink) + .frame(width: 44, height: 44) + } + .buttonStyle(.plain) + .accessibilityLabel(model.localized("返回")) + + Text(model.localized("登录 BitFun 账号")) + .font(.system(size: 32, weight: .bold)) + .foregroundStyle(BitFunTheme.ink) + Text(model.localized("登录后可查看并连接账号下的桌面设备。")) + .font(.system(size: 15)) + .foregroundStyle(BitFunTheme.muted) + .lineSpacing(4) + .padding(.top, 12) + .padding(.bottom, 42) + + accountField(model.localized("用户名"), text: $username, secure: false, height: 58) + accountField(model.localized("密码"), text: $password, secure: true, height: 58) + .padding(.top, 14) + + Text(model.localized("登录服务器")) + .font(.system(size: 13)) + .foregroundStyle(BitFunTheme.muted) + .padding(.leading, 4) + .padding(.top, 26) + .padding(.bottom, 8) + accountField(model.localized("Relay 地址"), text: $relayURL, secure: false, height: 52) + + if let error = model.coreErrorMessage, !error.isEmpty { + Text(error) + .font(.system(size: 13)) + .foregroundStyle(BitFunTheme.red) + .padding(.top, 12) + } + + Button { + model.loginAccount(relayURL: relayURL, username: username, password: password) + password = "" + } label: { + HStack(spacing: 8) { + if model.accountBusy { ProgressView().tint(.white) } + Text(model.localized(model.accountBusy ? "正在登录" : "登录")) + } + .font(.system(size: 17, weight: .bold)) + .foregroundStyle(.white) + .frame(maxWidth: .infinity, minHeight: 56) + .background(canLogin ? BitFunTheme.accent : BitFunTheme.muted.opacity(0.35)) + .clipShape(RoundedRectangle(cornerRadius: 18)) + } + .buttonStyle(.plain) + .disabled(!canLogin) + .padding(.top, model.coreErrorMessage == nil ? 30 : 22) + } + .padding(.horizontal, 28) + .padding(.top, 22) + .padding(.bottom, 44) + } + } + + private var profilePage: some View { + VStack(alignment: .leading, spacing: 0) { + BitFunModalHeader(title: "个人资料", onClose: close) + .padding(.horizontal, MobileDesignGeometry.sheetHorizontalPadding) + .padding(.top, 8) + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 0) { + VStack(spacing: 10) { + ZStack { + Circle().fill(BitFunTheme.soft) + Image(systemName: "person.fill") + .font(.system(size: 34, weight: .medium)) + .foregroundStyle(BitFunTheme.ink) + } + .frame(width: 70, height: 70) + Text(model.accountUser ?? "") + .font(.system(size: 22, weight: .bold)) + .foregroundStyle(BitFunTheme.ink) + .lineLimit(1) + Text(profileIdentifier) + .font(.system(size: 14)) + .foregroundStyle(BitFunTheme.muted) + .lineLimit(1) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 24) + .background(BitFunTheme.card) + .clipShape(RoundedRectangle(cornerRadius: 28)) + .padding(.bottom, 24) + + VStack(alignment: .leading, spacing: 10) { + HStack { + Text(model.localized("BitFun 账号")) + .font(.system(size: 17, weight: .bold)) + .foregroundStyle(BitFunTheme.ink) + Spacer() + Text(model.localized("已登录")) + .font(.system(size: 14)) + .foregroundStyle(BitFunTheme.green) + } + Text(model.localizedFormat("当前以 %@ 登录。", model.accountUser ?? "")) + .font(.system(size: 14)) + .foregroundStyle(BitFunTheme.muted) + .lineSpacing(3) + } + .padding(.horizontal, 18) + .padding(.vertical, 16) + .background(BitFunTheme.card) + .clipShape(RoundedRectangle(cornerRadius: 24)) + .padding(.bottom, 24) + + VStack(alignment: .leading, spacing: 8) { + HStack { + Text(model.localized("设备管理")) + .font(.system(size: 17, weight: .bold)) + .foregroundStyle(BitFunTheme.ink) + Spacer() + Button { model.refreshRemoteDevices() } label: { + Text(model.localized(model.accountRefreshing ? "正在刷新" : "刷新")) + .font(.system(size: 13)) + .foregroundStyle(model.accountRefreshing ? BitFunTheme.muted : BitFunTheme.ink) + } + .buttonStyle(.plain) + .disabled(model.accountRefreshing) + } + VStack(spacing: 0) { + ForEach(Array(model.accountDevices.enumerated()), id: \.offset) { index, device in + Button { model.selectRemoteDevice(device) } label: { + SettingsDeviceRow(device: device) + } + .buttonStyle(.plain) + .disabled(!device.online && !device.selected) + if index < model.accountDevices.count - 1 { + Divider().overlay(BitFunTheme.line).padding(.horizontal, 20) + } + } + if model.accountDevices.isEmpty { + Text(model.localized("暂无可连接的桌面设备")) + .font(.system(size: 13)) + .foregroundStyle(BitFunTheme.muted) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 12) + } + } + } + .padding(.horizontal, 18) + .padding(.vertical, 16) + .background(BitFunTheme.card) + .clipShape(RoundedRectangle(cornerRadius: 24)) + .padding(.bottom, 24) + + Text(model.localized("个人资料详情")) + .font(.system(size: 18, weight: .bold)) + .foregroundStyle(BitFunTheme.muted) + .padding(.leading, 18) + .padding(.bottom, 8) + + VStack(spacing: 0) { + profileDetailRow(label: model.localized("用户 ID"), value: profileIdentifier) + Divider().overlay(BitFunTheme.line).padding(.horizontal, 18) + profileDetailRow( + label: model.localized("设备 ID"), + value: model.localDeviceID.isEmpty ? "-" : model.localDeviceID + ) + } + .background(BitFunTheme.card) + .clipShape(RoundedRectangle(cornerRadius: 28)) + + Button(role: .destructive) { + model.logoutAccount() + } label: { + Text(model.localized("退出账号")) + .font(.system(size: 16, weight: .medium)) + .foregroundStyle(BitFunTheme.red) + .frame(maxWidth: .infinity, minHeight: 54) + .background(BitFunTheme.card) + .clipShape(RoundedRectangle(cornerRadius: 16)) + } + .buttonStyle(.plain) + .padding(.top, 18) + } + .padding(.horizontal, MobileDesignGeometry.sheetHorizontalPadding) + .padding(.top, 20) + .padding(.bottom, 34) + } + } + } + + private var profileIdentifier: String { + model.accountUserID?.isEmpty == false ? model.accountUserID! : (model.accountUser ?? "-") + } + + private func profileDetailRow(label: String, value: String) -> some View { + HStack(spacing: 12) { + Text(label) + .font(.system(size: 16)) + .foregroundStyle(BitFunTheme.ink) + Spacer(minLength: 8) + Text(value) + .font(.system(size: 16)) + .foregroundStyle(BitFunTheme.muted) + .lineLimit(1) + .truncationMode(.middle) + .multilineTextAlignment(.trailing) + } + .frame(minHeight: 56) + .padding(.horizontal, 18) + } + + private var canLogin: Bool { + !model.accountBusy && + !relayURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && + !username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && + !password.isEmpty } - private func configureCapture() { - guard let device = AVCaptureDevice.default(for: .video), - let input = try? AVCaptureDeviceInput(device: device), - session.canAddInput(input) else { return } - let output = AVCaptureMetadataOutput() - guard session.canAddOutput(output) else { return } - session.addInput(input) - session.addOutput(output) - output.setMetadataObjectsDelegate(self, queue: .main) - output.metadataObjectTypes = [.qr] - let layer = AVCaptureVideoPreviewLayer(session: session) - layer.videoGravity = .resizeAspectFill - view.layer.insertSublayer(layer, at: 0) - previewLayer = layer - session.startRunning() + private func close() { + if let onClose { onClose() } else { model.accountSheetOpen = false } } - func metadataOutput( - _ output: AVCaptureMetadataOutput, - didOutput metadataObjects: [AVMetadataObject], - from connection: AVCaptureConnection, - ) { - guard let value = (metadataObjects.first as? AVMetadataMachineReadableCodeObject)?.stringValue, - !value.isEmpty else { return } - session.stopRunning() - onCode?(value) - dismiss(animated: true) + @ViewBuilder + private func accountField( + _ placeholder: String, + text: Binding, + secure: Bool, + height: CGFloat + ) -> some View { + Group { + if secure { SecureField(placeholder, text: text) } + else { TextField(placeholder, text: text) } + } + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .font(.system(size: height == 58 ? 17 : 14)) + .foregroundStyle(BitFunTheme.ink) + .padding(.horizontal, 20) + .frame(height: height) + .background(BitFunTheme.card) + .clipShape(RoundedRectangle(cornerRadius: height == 58 ? 18 : 16)) } } -private struct LocalHomeView: View { +private struct GeneralChatConfigSheet: View { + private enum Page { case overview, account, local } + @ObservedObject var model: MobileAppModel + @State private var page: Page = .overview + @State private var baseURL = "" + @State private var modelName = "" + @State private var apiKey = "" + @State private var clearAPIKey = false - private let prompts: [(String, String)] = [ - ("Aa", "帮我写点内容"), - ("≡", "梳理一个问题"), - ("✓", "制定行动计划") - ] + private var selectedModel: ComposerModelOption? { + model.modelOptions.first(where: \.selected) + } + + private var accountModels: [ComposerModelOption] { + model.modelOptions.filter { $0.source == "ACCOUNT" } + } + + private var localModel: ComposerModelOption? { + model.modelOptions.first { $0.source == "LOCAL" } + } + + private var localComplete: Bool { + !model.generalConfigBaseURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && + !model.generalConfigModel.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && + model.generalConfigHasAPIKey + } var body: some View { - VStack(spacing: 0) { - Spacer(minLength: 0) - VStack(spacing: 12) { - ForEach(prompts, id: \.1) { icon, title in + VStack(alignment: .leading, spacing: 0) { + modelHeader + Divider().overlay(BitFunTheme.line) + switch page { + case .overview: overview + case .account: accountSelection + case .local: localEditor + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .background(BitFunTheme.card) + .onAppear { + baseURL = model.generalConfigBaseURL + modelName = model.generalConfigModel + } + } + + private var modelHeader: some View { + HStack(spacing: 8) { + if page != .overview { + Button { page = .overview } label: { + Image(systemName: "chevron.left") + .font(.system(size: 18, weight: .medium)) + .frame(width: 42, height: 42) + } + .buttonStyle(.plain) + .foregroundStyle(BitFunTheme.ink) + .accessibilityLabel(model.localized("返回")) + } + Text(model.localized(headerTitle)) + .font(MobileDesignTypography.headlineSmall.font) + .foregroundStyle(BitFunTheme.ink) + .lineLimit(1) + Spacer(minLength: 8) + Button { model.generalConfigOpen = false } label: { + Image(systemName: "xmark") + .font(.system(size: 18, weight: .regular)) + .foregroundStyle(BitFunTheme.muted) + .frame( + width: MobileDesignGeometry.selectionCloseSize, + height: MobileDesignGeometry.selectionCloseSize + ) + } + .buttonStyle(.plain) + .accessibilityLabel(model.localized("关闭")) + } + .padding(.horizontal, 16) + .frame(height: MobileDesignGeometry.sheetHeaderHeight) + } + + private var headerTitle: String { + switch page { + case .overview: "普通对话模型" + case .account: "选择账号模型" + case .local: "本机自定义模型" + } + } + + private var overview: some View { + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: MobileDesignGeometry.modelSectionGap) { + VStack(alignment: .leading, spacing: 8) { + sectionTitle("当前使用") + modelOverviewRow( + icon: "checkmark.circle.fill", + title: selectedModel?.primaryLabel ?? model.localized("未配置"), + subtitle: selectedModel.map { sourceLabel($0.source) } ?? "", + height: MobileDesignGeometry.modelCurrentRowHeight + ) + } + VStack(alignment: .leading, spacing: 8) { + sectionTitle("模型来源") + VStack(spacing: 0) { + Button { page = .account } label: { + sourceRow( + icon: "cloud", + title: "云端账号模型", + subtitle: accountModels.isEmpty + ? model.localized("暂无可用的账号模型") + : model.localizedFormat("已同步 %d 个", accountModels.count), + chevronAction: nil + ) + } + .buttonStyle(.plain) + Divider().overlay(BitFunTheme.line).padding(.leading, 56) + HStack(spacing: 0) { + Button { + if localComplete, let localModel { model.selectModel(localModel.id) } + else { page = .local } + } label: { + sourceRow( + icon: "wrench.and.screwdriver", + title: localComplete ? model.generalConfigModel : model.localized("未配置"), + subtitle: localComplete ? model.localized("本机") : "", + chevronAction: nil + ) + } + .buttonStyle(.plain) + Button { page = .local } label: { + Image(systemName: "chevron.right") + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(BitFunTheme.muted) + .frame(width: 44, height: MobileDesignGeometry.modelSourceRowHeight) + } + .buttonStyle(.plain) + } + } + .background(BitFunTheme.soft) + .clipShape(RoundedRectangle(cornerRadius: MobileDesignGeometry.settingsCompactCardRadius)) + } + } + .padding(.horizontal, 16) + .padding(.top, MobileDesignGeometry.modelOverviewTopPadding) + .padding(.bottom, MobileDesignGeometry.modelOverviewBottomPadding) + } + } + + private var accountSelection: some View { + Group { + if accountModels.isEmpty { + Text(model.localized("暂无可用的账号模型")) + .font(MobileDesignTypography.bodyMedium.font) + .foregroundStyle(BitFunTheme.muted) + .frame(maxWidth: .infinity, minHeight: MobileDesignGeometry.modelEmptyAccountHeight, alignment: .leading) + .padding(.horizontal, 16) + } else { + ScrollView(showsIndicators: true) { + LazyVStack(spacing: MobileDesignGeometry.modelAccountRowGap) { + ForEach(accountModels) { option in + Button { + model.selectModel(option.id) + page = .overview + } label: { + HStack(spacing: 10) { + Image(systemName: option.selected ? "checkmark.circle" : "circle") + .foregroundStyle(option.selected ? BitFunTheme.ink : Color.clear) + .frame(width: 20, height: 20) + VStack(alignment: .leading, spacing: 2) { + Text(option.primaryLabel) + .font(MobileDesignTypography.titleSmall.font) + .foregroundStyle(BitFunTheme.ink) + .lineLimit(1) + Text(model.localized("云端账号")) + .font(MobileDesignTypography.labelSmall.font) + .foregroundStyle(BitFunTheme.muted) + } + Spacer() + } + .padding(.horizontal, 10) + .frame(height: MobileDesignGeometry.modelAccountRowHeight) + .background(option.selected ? BitFunTheme.soft : Color.clear) + .clipShape(RoundedRectangle(cornerRadius: 9)) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 10) + .padding(.top, MobileDesignGeometry.modelListTopPadding) + .padding(.bottom, MobileDesignGeometry.modelListBottomPadding) + } + } + } + } + + private var localEditor: some View { + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 20) { + labeledField("API URL", placeholder: "https://api.example.com", text: $baseURL, secure: false) + labeledField( + "API Key", + placeholder: model.generalConfigHasAPIKey ? "API Key(留空则保留)" : "请输入 API Key", + text: $apiKey, + secure: true + ) + if model.generalConfigHasAPIKey { Button { - model.draft = title - model.send() + clearAPIKey.toggle() + apiKey = "" } label: { - HStack(spacing: 20) { - Text(icon) - .font(.system(size: 29, weight: .regular)) - .foregroundStyle(BitFunTheme.muted) - .frame(width: 32) - .fixedSize() - Text(title) - .font(.system(size: 20, weight: .medium)) - .foregroundStyle(BitFunTheme.muted) - Spacer(minLength: 0) - } - .frame(height: 48) - .contentShape(Rectangle()) + Text(model.localized(clearAPIKey ? "保留已保存的 Key" : "清除已保存的 API Key")) + .font(MobileDesignTypography.bodySmall.font) + .foregroundStyle(clearAPIKey ? BitFunTheme.ink : BitFunTheme.red) } .buttonStyle(.plain) } + labeledField("模型名称", placeholder: "例如 chat-model", text: $modelName, secure: false) + HStack(spacing: 12) { + editorAction(title: model.generalConnectionTestRunning ? "测试中…" : "测试连接", primary: false) { + model.testGeneralConnection( + baseURL: baseURL, model: modelName, apiKey: apiKey, clearAPIKey: clearAPIKey + ) + } + .disabled(model.generalConnectionTestRunning || (apiKey.isEmpty && (!model.generalConfigHasAPIKey || clearAPIKey))) + editorAction(title: "保存", primary: true) { + model.saveGeneralConfig( + baseURL: baseURL, model: modelName, apiKey: apiKey, clearAPIKey: clearAPIKey + ) + } + } + if apiKey.isEmpty && (!model.generalConfigHasAPIKey || clearAPIKey) { + Text(model.localized("保留或输入 API Key 后可测试连接。")) + .font(MobileDesignTypography.labelSmall.font) + .foregroundStyle(MobileDesignColors.subtle) + } + if let failure = model.generalConfigFailure { + Text(configFailureText(failure)) + .font(MobileDesignTypography.bodySmall.font).foregroundStyle(BitFunTheme.red) + } + if let message = model.generalConnectionTestMessage { + Text(message).font(MobileDesignTypography.bodySmall.font) + .foregroundStyle(message == model.localized("连接成功") ? BitFunTheme.green : BitFunTheme.red) + } } - .padding(.horizontal, 20) - .padding(.bottom, 12) + .padding(.horizontal, 16) + .padding(.top, 18) + .padding(.bottom, 30) } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(BitFunTheme.page) } -} -private struct RemoteHomeView: View { - @ObservedObject var model: MobileAppModel + private func sectionTitle(_ title: String) -> some View { + Text(model.localized(title)) + .font(MobileDesignTypography.labelMedium.font) + .foregroundStyle(BitFunTheme.muted) + } - var body: some View { - VStack(spacing: 12) { - Spacer() - ZStack { - Image(systemName: "desktopcomputer") - .font(.system(size: 42, weight: .medium)) - .foregroundStyle(BitFunTheme.ink) + private func modelOverviewRow(icon: String, title: String, subtitle: String, height: CGFloat) -> some View { + HStack(spacing: 12) { + Image(systemName: icon).font(.system(size: 23)).frame(width: 28, height: 28) + VStack(alignment: .leading, spacing: 3) { + Text(title).font(MobileDesignTypography.bodyLarge.font.weight(.medium)).lineLimit(1) + if !subtitle.isEmpty { + Text(subtitle).font(MobileDesignTypography.labelSmall.font).foregroundStyle(BitFunTheme.muted) + } } - .frame(width: 74, height: 74) - .background(BitFunTheme.card) - .overlay(RoundedRectangle(cornerRadius: 24).stroke(BitFunTheme.line, lineWidth: 1)) - .clipShape(RoundedRectangle(cornerRadius: 24)) - Text("连接桌面端") - .font(.system(size: 18, weight: .bold)) - .foregroundStyle(BitFunTheme.ink) - Text("扫描桌面端显示的二维码,开始远程处理任务。") - .font(.system(size: 13)) - .foregroundStyle(BitFunTheme.muted) - .multilineTextAlignment(.center) - .lineSpacing(7) - .padding(.horizontal, 20) - Button("连接") { model.connectRemote() } - .font(.system(size: 15, weight: .medium)) - .foregroundStyle(.white) - .frame(width: 136, height: 44) - .background(BitFunTheme.accent) - .clipShape(Capsule()) Spacer() } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .padding(.horizontal, 20) - .padding(.bottom, 48) - .background(BitFunTheme.page) + .foregroundStyle(BitFunTheme.ink) + .padding(.horizontal, 16) + .frame(maxWidth: .infinity, minHeight: height) + .background(BitFunTheme.soft) + .clipShape(RoundedRectangle(cornerRadius: MobileDesignGeometry.settingsCompactCardRadius)) } -} -private struct RemoteConnectedHomeView: View { - var body: some View { - VStack { - Spacer() + private func sourceRow(icon: String, title: String, subtitle: String, chevronAction: (() -> Void)?) -> some View { + HStack(spacing: 12) { + Image(systemName: icon).font(.system(size: 21)).foregroundStyle(BitFunTheme.muted).frame(width: 28, height: 28) + VStack(alignment: .leading, spacing: 3) { + Text(model.localized(title)).font(MobileDesignTypography.titleSmall.font).foregroundStyle(BitFunTheme.ink).lineLimit(1) + if !subtitle.isEmpty { + Text(subtitle).font(MobileDesignTypography.labelSmall.font).foregroundStyle(BitFunTheme.muted).lineLimit(1) + } + } Spacer() + if chevronAction != nil { + Image(systemName: "chevron.right").font(.system(size: 14, weight: .medium)).foregroundStyle(BitFunTheme.muted) + } } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(BitFunTheme.page) + .padding(.horizontal, 16) + .frame(maxWidth: .infinity, minHeight: MobileDesignGeometry.modelSourceRowHeight) } -} -private struct ConnectionStatusBar: View { - let phase: ConnectionPhase - var detail: String? - var body: some View { - HStack(spacing: 8) { - Circle().fill(phase == .reconnecting ? BitFunTheme.muted : BitFunTheme.red).frame(width: 8, height: 8) - Text(phase == .reconnecting ? "正在恢复连接" : "连接不可用") - .font(.system(size: 13, weight: .medium)) - Text(detail ?? (phase == .reconnecting ? "正在重新连接桌面端" : "请重新连接")) - .font(.system(size: 12)) - .foregroundStyle(BitFunTheme.muted) - Spacer() + private func sourceLabel(_ source: String) -> String { + model.localized(source == "LOCAL" ? "本机" : "云端账号") + } + + @ViewBuilder + private func labeledField(_ label: String, placeholder: String, text: Binding, secure: Bool) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text(model.localized(label)) + .font(MobileDesignTypography.labelMedium.font) + .foregroundStyle(BitFunTheme.ink) + Group { + if secure { SecureField(model.localized(placeholder), text: text) } + else { TextField(model.localized(placeholder), text: text) } + } + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .font(MobileDesignTypography.bodyMedium.font) + .padding(.horizontal, 14) + .frame(height: 52) + .background(BitFunTheme.soft) + .clipShape(RoundedRectangle(cornerRadius: MobileDesignGeometry.settingsCompactCardRadius)) + } + } + + private func editorAction(title: String, primary: Bool, action: @escaping () -> Void) -> some View { + Button(action: action) { + Text(model.localized(title)) + .font(MobileDesignTypography.bodyLarge.font.weight(.medium)) + .foregroundStyle(primary ? Color.white : BitFunTheme.ink) + .frame(maxWidth: .infinity, minHeight: 50) + .background(primary ? BitFunTheme.accent : BitFunTheme.soft) + .clipShape(Capsule()) + } + .buttonStyle(.plain) + } + + private func configFailureText(_ failure: String) -> String { + switch failure { + case "INVALID_URL": model.localized("请输入有效的服务地址") + case "MODEL_REQUIRED": model.localized("请输入模型名称") + case "API_KEY_REQUIRED": model.localized("请输入 API Key") + default: model.localized("配置无法保存,请稍后重试") } - .foregroundStyle(BitFunTheme.ink) - .padding(.horizontal, 18) - .frame(height: 48) - .background(BitFunTheme.soft) } } -private struct SettingsView: View { +private struct PermissionModeRow: View { @ObservedObject var model: MobileAppModel - @Environment(\.dismiss) private var dismiss + let mode: String + let title: String + let detail: String var body: some View { - VStack(alignment: .leading, spacing: 0) { - HStack { - Spacer() - Button { dismiss() } label: { - Image(systemName: "xmark") - .font(.system(size: 17, weight: .regular)) - .foregroundStyle(BitFunTheme.ink) - .frame(width: 48, height: 48) - .background(BitFunTheme.card) - .clipShape(Circle()) - .shadow(color: .black.opacity(0.07), radius: 10, y: 4) + Button { model.setRemotePermissionMode(mode) } label: { + HStack(spacing: 12) { + VStack(alignment: .leading, spacing: 3) { + Text(model.localized(title)).font(MobileDesignTypography.bodyMedium.font).foregroundStyle(BitFunTheme.ink) + Text(model.localized(detail)).font(MobileDesignTypography.labelSmall.font).foregroundStyle(BitFunTheme.muted) } - .buttonStyle(.plain) - } - .padding(.top, 8) - - ScrollView(showsIndicators: false) { - VStack(alignment: .leading, spacing: 0) { - Text("设置") - .font(.system(size: 32, weight: .bold)) - .foregroundStyle(BitFunTheme.ink) - .padding(.top, 72) - .padding(.bottom, 34) - - SettingsCard { - SettingsValueRow(icon: "person", title: "个人资料", value: "6a7c7282-b185-4ae7-…") - } - SettingsGroup(title: "语言") { - SettingsValueRow(icon: "textformat", title: "语言", value: "简体中文") - } - SettingsGroup(title: "普通对话") { - SettingsValueRow(icon: "square.grid.2x2", title: "模型", value: "deepseek-v4-pro") - } - SettingsGroup(title: "关于") { - VStack(spacing: 0) { - SettingsValueRow(icon: nil, title: "产品", value: "BitFun HarmonyOS") - Divider().overlay(BitFunTheme.line).padding(.horizontal, 20) - SettingsValueRow(icon: nil, title: "版本", value: "1.0.0") - } - } + Spacer() + if model.remotePermissionMode == mode { + Image(systemName: "checkmark.circle.fill").foregroundStyle(BitFunTheme.green) } - .padding(.horizontal, 20) - .padding(.bottom, 28) } + .padding(.horizontal, 20).frame(minHeight: 62) } - .background(BitFunTheme.page) - .presentationDetents([.medium, .large]) - .presentationDragIndicator(.hidden) + .buttonStyle(.plain).disabled(model.busy) } } @@ -406,14 +2620,14 @@ private struct SettingsGroup: View { @ViewBuilder let content: () -> Content var body: some View { - VStack(alignment: .leading, spacing: 10) { - Text(title) - .font(.system(size: 20, weight: .bold)) + VStack(alignment: .leading, spacing: 8) { + Text(MobileLocalization.text(title)) + .font(.system(size: 18, weight: .bold)) .foregroundStyle(BitFunTheme.muted) - .padding(.leading, 18) - .padding(.top, 34) + .padding(.leading, 12) SettingsCard(content: content) } + .padding(.bottom, 24) } } @@ -421,9 +2635,39 @@ private struct SettingsCard: View { @ViewBuilder let content: () -> Content var body: some View { - VStack(spacing: 0, content: content) - .background(BitFunTheme.card) - .clipShape(RoundedRectangle(cornerRadius: 14)) + BitFunModalCard( + radius: MobileDesignGeometry.settingsCompactCardRadius, + bordered: false, + content: content + ) + } +} + +private struct SettingsProfileRow: View { + let subtitle: String + + var body: some View { + HStack(spacing: 12) { + Image(systemName: "person.crop.circle") + .font(.system(size: 24, weight: .regular)) + .foregroundStyle(BitFunTheme.muted) + .frame(width: 34, height: 34) + VStack(alignment: .leading, spacing: 2) { + Text(MobileLocalization.text("个人资料")) + .font(.system(size: 16, weight: .medium)) + .foregroundStyle(BitFunTheme.ink) + Text(MobileLocalization.text(subtitle)) + .font(.system(size: 13)) + .foregroundStyle(BitFunTheme.muted) + .lineLimit(1) + } + Spacer(minLength: 8) + Image(systemName: "chevron.right") + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(BitFunTheme.muted.opacity(0.72)) + } + .padding(.horizontal, 18) + .frame(height: 64) } } @@ -431,26 +2675,63 @@ private struct SettingsValueRow: View { let icon: String? let title: String let value: String + var showsChevron: Bool = false var body: some View { HStack(spacing: 14) { if let icon { Image(systemName: icon) - .font(.system(size: 23, weight: .regular)) + .font(.system(size: 20, weight: .regular)) .foregroundStyle(BitFunTheme.muted) - .frame(width: 42, height: 42) + .frame(width: 23, height: 23) } - Text(title) - .font(.system(size: 17, weight: .medium)) + Text(MobileLocalization.text(title)) + .font(.system(size: 16, weight: .medium)) .foregroundStyle(BitFunTheme.ink) Spacer(minLength: 12) - Text(value) - .font(.system(size: 16)) + Text(MobileLocalization.text(value)) + .font(.system(size: 15)) .foregroundStyle(BitFunTheme.muted) .lineLimit(1) - Image(systemName: "chevron.right") - .font(.system(size: 14, weight: .medium)) + if showsChevron { + Image(systemName: "chevron.right") + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(BitFunTheme.muted.opacity(0.72)) + } + } + .padding(.horizontal, 18) + .frame(height: 52) + } +} + +private struct SettingsDeviceRow: View { + let device: MobileAccountDevice + + var body: some View { + HStack(spacing: 14) { + Image(systemName: "desktopcomputer") + .font(.system(size: 21, weight: .regular)) .foregroundStyle(BitFunTheme.muted) + .frame(width: 42, height: 42) + VStack(alignment: .leading, spacing: 3) { + Text(device.name) + .font(.system(size: 16, weight: .medium)) + .foregroundStyle(BitFunTheme.ink) + .lineLimit(1) + Text(MobileLocalization.text(device.online ? "在线" : "离线")) + .font(.system(size: 12)) + .foregroundStyle(device.online ? BitFunTheme.green : BitFunTheme.muted) + } + Spacer(minLength: 12) + if device.selected { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 18)) + .foregroundStyle(BitFunTheme.green) + } else { + Image(systemName: "chevron.right") + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(BitFunTheme.muted) + } } .padding(.horizontal, 20) .frame(minHeight: 76) diff --git a/src/apps/mobile/ios/BitFun/Features/Shell/SessionActionComponents.swift b/src/apps/mobile/ios/BitFun/Features/Shell/SessionActionComponents.swift new file mode 100644 index 0000000000..a65122e5e1 --- /dev/null +++ b/src/apps/mobile/ios/BitFun/Features/Shell/SessionActionComponents.swift @@ -0,0 +1,297 @@ +import SwiftUI + +enum SessionActionPresentation { + case bottomSheet + case popover +} + +/// One action contract for sidebar and workspace session rows. Compact lists +/// present it as a sheet; permanent lists present the same rows as a popover. +/// High-impact deletion replaces those rows in place instead of stacking an +/// alert and a second scrim over the action surface. +struct SessionActionSurface: View { + @ObservedObject var model: MobileAppModel + let session: ChatSession + let presentation: SessionActionPresentation + var canViewDetails = true + var canArchive = false + var canExport = false + var canDelete = true + let onViewDetails: () -> Void + let onArchive: () -> Void + let onExport: () -> Void + let onDelete: () -> Void + let onClose: () -> Void + @State private var confirmingDelete = false + + var body: some View { + VStack(spacing: 0) { + if presentation == .bottomSheet { + Capsule() + .fill(BitFunTheme.line) + .frame(width: 36, height: 4) + .padding(.bottom, 10) + } + + HStack(spacing: 12) { + VStack(alignment: .leading, spacing: 3) { + Text(model.localized("会话操作")) + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(BitFunTheme.muted) + Text(session.title.isEmpty ? model.localized("未命名会话") : session.title) + .font(.system(size: 15, weight: .medium)) + .foregroundStyle(BitFunTheme.ink) + .lineLimit(1) + } + Spacer(minLength: 8) + Button(action: onClose) { + Image(systemName: "xmark") + .font(.system(size: 16, weight: .regular)) + .foregroundStyle(BitFunTheme.muted) + .frame(width: 40, height: 40) + } + .buttonStyle(.plain) + .accessibilityLabel(model.localized("关闭")) + } + .frame(height: 52) + + Divider().overlay(BitFunTheme.line).padding(.top, 6).padding(.bottom, 8) + + if confirmingDelete { + deleteConfirmation + } else { + actionRows + } + } + .padding(.horizontal, 16) + .padding(.top, 10) + .padding(.bottom, 18) + .frame(width: presentation == .popover ? 300 : nil) + .frame(maxWidth: presentation == .bottomSheet ? .infinity : nil) + .background(BitFunTheme.card) + .clipShape(RoundedRectangle(cornerRadius: MobileDesignGeometry.popoverRadius)) + .overlay( + RoundedRectangle(cornerRadius: MobileDesignGeometry.popoverRadius) + .stroke(BitFunTheme.line, lineWidth: 1) + ) + .shadow( + color: presentation == .popover ? BitFunTheme.line : .clear, + radius: presentation == .popover ? 20 : 0, + y: presentation == .popover ? 8 : 0 + ) + .accessibilityAction(.escape, onClose) + } + + @ViewBuilder + private var actionRows: some View { + if canViewDetails { + actionRow("查看详情", icon: "info.circle") { + onViewDetails() + onClose() + } + } + if canArchive { + actionRow( + session.status.lowercased() == "archived" ? "取消归档" : "归档会话", + icon: "archivebox" + ) { + onArchive() + onClose() + } + } + if canExport { + actionRow("导出会话", icon: "cloud") { + onExport() + onClose() + } + } + if canDelete { + if canArchive || canExport { + Divider().overlay(BitFunTheme.line).padding(.vertical, 6) + } + actionRow("删除", icon: "trash", destructive: true) { + confirmingDelete = true + } + } + } + + private var deleteConfirmation: some View { + VStack(spacing: 12) { + Text(model.localized("删除后无法恢复此会话,是否继续?")) + .font(.system(size: 13)) + .foregroundStyle(BitFunTheme.muted) + .frame(maxWidth: .infinity, alignment: .leading) + HStack(spacing: 10) { + confirmationButton( + "取消", + fill: BitFunTheme.soft, + foreground: BitFunTheme.ink, + emphasized: false + ) { + confirmingDelete = false + } + confirmationButton( + "删除", + fill: BitFunTheme.red, + foreground: .white, + emphasized: true + ) { + onDelete() + onClose() + } + } + } + } + + private func actionRow( + _ title: String, + icon: String, + destructive: Bool = false, + action: @escaping () -> Void + ) -> some View { + Button(action: action) { + HStack(spacing: 12) { + Image(systemName: icon) + .font(.system(size: 19, weight: .regular)) + .foregroundStyle(destructive ? BitFunTheme.red : BitFunTheme.muted) + .frame(width: 23, height: 23) + Text(model.localized(title)) + .font(.system(size: 15)) + .foregroundStyle(destructive ? BitFunTheme.red : BitFunTheme.ink) + Spacer(minLength: 0) + } + .padding(.horizontal, 10) + .frame(height: MobileDesignGeometry.sheetActionHeight) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + + private func confirmationButton( + _ title: String, + fill: Color, + foreground: Color, + emphasized: Bool, + action: @escaping () -> Void + ) -> some View { + Button(action: action) { + Text(model.localized(title)) + .font(.system(size: 14, weight: emphasized ? .medium : .regular)) + .foregroundStyle(foreground) + .frame(maxWidth: .infinity, minHeight: 44) + .background(fill) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + .buttonStyle(.plain) + } +} + +/// Read-only details use the same field order and paper geometry as Harmony's +/// `SessionDetailsView`; paths stay selectable on the controller device. +struct SessionDetailsView: View { + @ObservedObject var model: MobileAppModel + let session: ChatSession + let onClose: () -> Void + + var body: some View { + VStack(spacing: 0) { + HStack(spacing: 12) { + VStack(alignment: .leading, spacing: 3) { + Text(model.localized("会话详情")) + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(BitFunTheme.muted) + Text(session.title.isEmpty ? model.localized("未命名会话") : session.title) + .font(.system(size: 18, weight: .medium)) + .foregroundStyle(BitFunTheme.ink) + .lineLimit(2) + } + Spacer(minLength: 8) + Button(action: onClose) { + Image(systemName: "xmark") + .font(.system(size: 17)) + .foregroundStyle(BitFunTheme.muted) + .frame(width: 44, height: 44) + .background(BitFunTheme.soft) + .clipShape(Circle()) + } + .buttonStyle(.plain) + .accessibilityLabel(model.localized("关闭")) + } + .padding(.leading, 20) + .padding(.trailing, 16) + .padding(.top, 18) + .padding(.bottom, 16) + + Divider().overlay(BitFunTheme.line) + + ScrollView(showsIndicators: false) { + VStack(spacing: 0) { + detailRow("Agent 类型", value: session.agentType.isEmpty ? model.localized("未知") : session.agentType) + if let workspaceName = session.workspaceName, !workspaceName.isEmpty { + detailRow("工作区", value: workspaceName) + } + if let path = session.workspacePath, !path.isEmpty { + pathRow(path) + } + if !session.createdAt.isEmpty { detailRow("创建时间", value: session.createdAt) } + if !session.updatedLabel.isEmpty { detailRow("更新时间", value: session.updatedLabel) } + detailRow("消息数量", value: String(max(0, session.messageCount))) + if !session.status.isEmpty { + detailRow("状态", value: statusLabel) + } + } + .padding(.horizontal, 20) + .padding(.top, 8) + .padding(.bottom, 24) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(BitFunTheme.page) + } + + private var statusLabel: String { + switch session.status.lowercased() { + case "archived": return model.localized("已归档") + case "active": return model.localized("执行中") + default: return session.status + } + } + + private func detailRow(_ label: String, value: String) -> some View { + HStack(spacing: 16) { + Text(model.localized(label)) + .font(.system(size: 13)) + .foregroundStyle(BitFunTheme.muted) + .frame(width: 104, alignment: .leading) + Text(value) + .font(.system(size: 15)) + .foregroundStyle(BitFunTheme.ink) + .multilineTextAlignment(.trailing) + .frame(maxWidth: .infinity, alignment: .trailing) + .lineLimit(2) + } + .padding(.vertical, 8) + .frame(minHeight: 52) + .overlay(alignment: .bottom) { Divider().overlay(BitFunTheme.line) } + } + + private func pathRow(_ path: String) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text(model.localized("工作区路径")) + .font(.system(size: 13)) + .foregroundStyle(BitFunTheme.muted) + Text(path) + .font(.system(size: 12, design: .monospaced)) + .foregroundStyle(BitFunTheme.ink) + .padding(.horizontal, 10) + .padding(.vertical, 8) + .frame(maxWidth: .infinity, alignment: .leading) + .background(BitFunTheme.card) + .overlay(RoundedRectangle(cornerRadius: 6).stroke(BitFunTheme.line, lineWidth: 1)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + .textSelection(.enabled) + } + .padding(.vertical, 12) + .overlay(alignment: .bottom) { Divider().overlay(BitFunTheme.line) } + } +} diff --git a/src/apps/mobile/ios/BitFun/Features/Shell/SidebarView.swift b/src/apps/mobile/ios/BitFun/Features/Shell/SidebarView.swift index 7d2a0bd9aa..22dbe25a64 100644 --- a/src/apps/mobile/ios/BitFun/Features/Shell/SidebarView.swift +++ b/src/apps/mobile/ios/BitFun/Features/Shell/SidebarView.swift @@ -1,24 +1,39 @@ import SwiftUI +struct SidebarSessionActionsAnchorKey: PreferenceKey { + static var defaultValue: [String: Anchor] = [:] + + static func reduce( + value: inout [String: Anchor], + nextValue: () -> [String: Anchor] + ) { + value.merge(nextValue(), uniquingKeysWith: { _, next in next }) + } +} + +struct SidebarWorkspaceCreateAnchorKey: PreferenceKey { + static var defaultValue: [String: Anchor] = [:] + + static func reduce( + value: inout [String: Anchor], + nextValue: () -> [String: Anchor] + ) { + value.merge(nextValue(), uniquingKeysWith: { _, next in next }) + } +} + struct SidebarView: View { @ObservedObject var model: MobileAppModel + var permanent = false + var onCollapse: (() -> Void)? = nil + var onPermanentActions: ((ChatSession) -> Void)? = nil @State private var search = "" @State private var searchVisible = false - - private var devices: [SidebarDevice] { - if let accountDeviceName = model.accountDeviceName, !accountDeviceName.isEmpty { - return [SidebarDevice(name: accountDeviceName, online: true)] - } - return [ - SidebarDevice(name: "Mac-userdeMacBook-Pro.local", online: true), - SidebarDevice(name: "DESKTOP-KM3L4UI", online: true), - ] - } - - private let workspaces = [ - SidebarWorkspace(name: "arkanaly...", sessions: ["本项目是啥", "Remote Code ..."]), - SidebarWorkspace(name: "BitFun", sessions: ["你在哪个分支", "你去拉一下Deep...", "你去看看issue里..."]) - ] + @State private var visibleRecentCount = 6 + @State private var expandedWorkspacePaths: Set = [] + @State private var compactActionSession: ChatSession? + @State private var workspaceCreatePath: String? + @State private var remoteChatsCollapsed = false private var recentSessions: [ChatSession] { let source = model.sessions @@ -26,10 +41,48 @@ struct SidebarView: View { return source.filter { $0.title.localizedCaseInsensitiveContains(search) } } + private var shownRecentSessions: [ChatSession] { + Array(recentSessions.prefix(search.isEmpty ? visibleRecentCount : recentSessions.count)) + } + + private var hasActiveRemoteViewFilter: Bool { + !model.remoteWorkspaceFilter.isEmpty || + !model.remoteViewAgentFilter.isEmpty || + !model.remoteStatusFilter.isEmpty + } + + private var sidebarDevices: [MobileAccountDevice] { + var devices = model.accountDevices.map { device in + MobileAccountDevice( + id: device.id, + name: device.name, + online: device.online, + selected: model.usesDirectPairing + ? device.name == model.directPairingDeviceName + : device.selected + ) + } + if model.usesDirectPairing, + let name = model.directPairingDeviceName, + !name.isEmpty, + !devices.contains(where: { $0.name == name }) { + devices.insert( + MobileAccountDevice( + id: model.directPairingSidebarDeviceID, + name: name, + online: true, + selected: true + ), + at: 0 + ) + } + return devices + } + var body: some View { GeometryReader { proxy in VStack(alignment: .leading, spacing: 0) { - authenticatedHeader + if model.accountUser == nil { signedOutHeader } else { authenticatedHeader } if searchVisible { searchField } @@ -38,24 +91,117 @@ struct SidebarView: View { recentSection workspaceSection } - .padding(.bottom, 84) + .padding(.bottom, model.accountUser == nil && !model.remoteConnected ? 142 : 84) } footer } .padding(.horizontal, 20) .padding(.top, 4) .padding(.bottom, 16) - .frame(width: min(320, proxy.size.width * 0.68), height: proxy.size.height, alignment: .topLeading) + .frame( + width: permanent ? proxy.size.width : min(320, proxy.size.width * 0.68), + height: proxy.size.height, + alignment: .topLeading + ) .background(BitFunTheme.page) } + .sheet(item: $compactActionSession) { session in + let surface = SessionActionSurface( + model: model, + session: session, + presentation: .bottomSheet, + canViewDetails: true, + canArchive: model.surface == .local, + canExport: model.surface == .local, + canDelete: true, + onViewDetails: { openDetails(afterClosing: session) }, + onArchive: { if model.surface == .local { model.archiveLocalSession(session) } }, + onExport: { if model.surface == .local { model.exportLocalSession(session) } }, + onDelete: { + if model.surface == .remote { model.deleteRemoteSession(session) } + else { model.deleteLocalSession(session) } + }, + onClose: { compactActionSession = nil } + ) + .presentationDetents([.height(380)]) + .presentationDragIndicator(.hidden) + if #available(iOS 16.4, *) { + surface.presentationCornerRadius(MobileDesignGeometry.popoverRadius) + } else { + surface + } + } + .overlayPreferenceValue(SidebarWorkspaceCreateAnchorKey.self) { anchors in + GeometryReader { proxy in + if let path = workspaceCreatePath, + let workspace = model.remoteWorkspaces.first(where: { $0.path == path }), + let anchor = anchors[path] { + let frame = proxy[anchor] + let menuHeight = MobileDesignGeometry.compactPopoverActionHeight * 2 + 16 + ZStack(alignment: .topLeading) { + Color.clear + .contentShape(Rectangle()) + .onTapGesture { workspaceCreatePath = nil } + workspaceCreateMenu(workspace) + .position( + x: min( + max(MobileDesignGeometry.compactPopoverWidth / 2 + 8, frame.midX), + proxy.size.width - MobileDesignGeometry.compactPopoverWidth / 2 - 8 + ), + y: max(menuHeight / 2 + 8, frame.minY - menuHeight / 2 - 6) + ) + } + } + } + } + .task { + if ProcessInfo.processInfo.arguments.contains("--project-create-menu"), + workspaceCreatePath == nil, + let workspace = model.remoteWorkspaces.first { + try? await Task.sleep(nanoseconds: 450_000_000) + workspaceCreatePath = workspace.path + } else if ProcessInfo.processInfo.arguments.contains("--sidebar-actions"), + compactActionSession == nil, + let session = shownRecentSessions.first { + try? await Task.sleep(nanoseconds: 450_000_000) + if permanent { onPermanentActions?(session) } + else { compactActionSession = session } + } + } } private var authenticatedHeader: some View { HStack(spacing: 6) { - Text("BitFun") + Text(verbatim: "BitFun") .font(.system(size: 20, weight: .bold)) .foregroundStyle(BitFunTheme.ink) Spacer(minLength: 0) + if let onCollapse { + Button(action: onCollapse) { + Image(systemName: "sidebar.left") + .font(.system(size: 17, weight: .medium)) + .foregroundStyle(BitFunTheme.muted) + .frame(width: 38, height: 38) + .background(BitFunTheme.card) + .overlay(Circle().stroke(BitFunTheme.line, lineWidth: 1)) + .clipShape(Circle()) + } + .buttonStyle(.plain) + .accessibilityLabel(Text(model.localized("收起侧栏"))) + } + if model.remoteConnected { + Button { model.remoteViewSettingsOpen = true } label: { + Image(systemName: "ellipsis") + .font(.system(size: 17, weight: .medium)) + .foregroundStyle(BitFunTheme.muted) + .frame(width: 38, height: 38) + .background(BitFunTheme.card) + .overlay(Circle().stroke(BitFunTheme.line, lineWidth: 1)) + .clipShape(Circle()) + } + .buttonStyle(.plain) + .accessibilityLabel(Text(model.localized("视图设置"))) + } Button { withAnimation(.easeOut(duration: 0.18)) { searchVisible.toggle() } if !searchVisible { search = "" } @@ -68,13 +214,44 @@ struct SidebarView: View { .shadow(color: .black.opacity(0.08), radius: 10, y: 4) } .buttonStyle(.plain) - .accessibilityLabel("搜索") + .accessibilityLabel(Text(model.localized("搜索"))) + } + .frame(height: 50) + } + + private var signedOutHeader: some View { + HStack(spacing: 8) { + Button { model.newLocalChat() } label: { + HStack(spacing: 8) { + Image(systemName: "square.and.pencil") + .font(.system(size: 17, weight: .medium)) + Text(model.localized("聊天")) + .font(.system(size: 15, weight: .medium)) + } + .foregroundStyle(BitFunTheme.ink) + .frame(height: 42) + } + .buttonStyle(.plain) + Spacer(minLength: 0) + if let onCollapse { + Button(action: onCollapse) { + Image(systemName: "sidebar.left") + .font(.system(size: 17, weight: .medium)) + .foregroundStyle(BitFunTheme.muted) + .frame(width: 38, height: 38) + .background(BitFunTheme.card) + .overlay(Circle().stroke(BitFunTheme.line, lineWidth: 1)) + .clipShape(Circle()) + } + .buttonStyle(.plain) + .accessibilityLabel(Text(model.localized("收起侧栏"))) + } } .frame(height: 50) } private var searchField: some View { - TextField("搜索对话", text: $search) + TextField(model.localized("搜索对话"), text: $search) .font(.system(size: 14)) .foregroundStyle(BitFunTheme.ink) .padding(.horizontal, 14) @@ -82,26 +259,57 @@ struct SidebarView: View { .background(BitFunTheme.soft) .clipShape(RoundedRectangle(cornerRadius: 8)) .padding(.top, 12) + .onChange(of: search) { value in + if model.surface == .remote { model.searchRemoteSessions(value) } + } } private var recentSection: some View { VStack(alignment: .leading, spacing: 0) { - Text("最近对话") + Text(model.localized("最近对话")) .font(.system(size: 14, weight: .medium)) .foregroundStyle(BitFunTheme.muted) .padding(.top, 16) .padding(.bottom, 6) - ForEach(recentSessions) { session in - SidebarRecentRow(session: session, selected: session.id == model.selectedSessionID) { - model.surface = .local - model.select(session) - } + if shownRecentSessions.isEmpty { + Text(model.localized(search.isEmpty ? "暂无最近会话" : "没有匹配的会话")) + .font(.system(size: 13)) + .foregroundStyle(BitFunTheme.muted) + .padding(.horizontal, 12) + .frame(height: 44, alignment: .leading) } - if recentSessions.count > 6 { - HStack(spacing: 8) { - Text("···") - Text("还有 \(recentSessions.count - 6) 个会话") + ForEach(shownRecentSessions) { session in + SidebarRecentRow( + model: model, + session: session, + selected: model.surface == .local && session.id == model.selectedSessionID, + onOpen: { + model.surface = .local + model.select(session) + }, + onActions: { + model.surface = .local + if permanent { onPermanentActions?(session) } + else { compactActionSession = session } + } + ) + } + if visibleRecentCount < recentSessions.count && search.isEmpty { + Button { + visibleRecentCount = min(visibleRecentCount + 6, recentSessions.count) + } label: { + HStack(spacing: 8) { + Text(verbatim: "···") + Text( + model.localizedFormat( + "还有 %lld 个会话", + Int64(recentSessions.count - visibleRecentCount) + ) + ) + } + .frame(maxWidth: .infinity, alignment: .leading) } + .buttonStyle(.plain) .font(.system(size: 13)) .foregroundStyle(BitFunTheme.muted) .frame(height: 40, alignment: .leading) @@ -110,25 +318,59 @@ struct SidebarView: View { } } + private func openDetails(afterClosing session: ChatSession) { + compactActionSession = nil + DispatchQueue.main.asyncAfter(deadline: .now() + 0.24) { + model.showSessionDetails(session) + } + } + private var workspaceSection: some View { VStack(alignment: .leading, spacing: 0) { HStack { - Text("设备") + Text(model.localized("设备")) .font(.system(size: 14, weight: .medium)) .foregroundStyle(BitFunTheme.muted) Spacer() - Button { model.connectRemote() } label: { + if model.accountUser != nil { + Button { model.refreshRemoteDevices() } label: { + if model.accountRefreshing { + ProgressView().controlSize(.small) + } else { + Image(systemName: "arrow.clockwise") + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(BitFunTheme.muted) + } + } + .buttonStyle(.plain) + .accessibilityLabel(Text(model.localized("刷新设备"))) + } + Button { model.scanRemote() } label: { ReferenceImage(assetName: "SidebarPlusGlyph", width: 17, height: 20) .frame(width: 32, height: 32) } .buttonStyle(.plain) - .accessibilityLabel("添加连接") + .accessibilityLabel(Text(model.localized("添加连接"))) } .frame(height: 38) .padding(.top, 18) - ForEach(devices) { device in - Button { model.connectRemote() } label: { + if sidebarDevices.isEmpty { + Text(model.localized("尚未连接桌面设备")) + .font(.system(size: 13)) + .foregroundStyle(BitFunTheme.muted) + .padding(.horizontal, 10) + .frame(height: 42, alignment: .leading) + } + + ForEach(sidebarDevices) { device in + Button { + if model.usesDirectPairing && device.name == model.directPairingDeviceName { + model.openRemoteSurface() + } else { + model.selectRemoteDevice(device) + } + } label: { HStack(spacing: 10) { ReferenceImage(assetName: "SidebarDeviceGlyph", width: 22, height: 18) Text(device.name) @@ -136,8 +378,11 @@ struct SidebarView: View { .foregroundStyle(BitFunTheme.ink) .lineLimit(1) Spacer(minLength: 0) + Circle() + .fill(device.online ? BitFunTheme.green : BitFunTheme.muted) + .frame(width: 7, height: 7) ReferenceImage( - assetName: device.online ? "SidebarChevronGlyph" : "SidebarDownGlyph", + assetName: device.selected ? "SidebarDownGlyph" : "SidebarChevronGlyph", width: 14, height: 14 ) @@ -147,20 +392,346 @@ struct SidebarView: View { .contentShape(Rectangle()) } .buttonStyle(.plain) + .disabled(!device.online && !device.selected) + if device.selected { activeRemoteDeviceBody } + } + + } + } + + @ViewBuilder + private var activeRemoteDeviceBody: some View { + if model.workspaceLoading && model.remoteWorkspaces.isEmpty { + HStack(spacing: 8) { + ProgressView().controlSize(.small) + Text(model.localized("正在加载工作区")) + .font(.system(size: 13)) + .foregroundStyle(BitFunTheme.muted) + } + .padding(.horizontal, 10) + .frame(height: 42) + } else if model.workspaceLoadFailed && model.remoteWorkspaces.isEmpty { + Button { model.retryRemoteWorkspaces() } label: { + Text(model.localized("工作区加载失败,点按重试")) + .font(.system(size: 13)) + .foregroundStyle(BitFunTheme.red) + .frame(maxWidth: .infinity, minHeight: 42, alignment: .leading) + .padding(.horizontal, 10) + } + .buttonStyle(.plain) + } + + if model.remoteConnected { + remoteGroupedSessionSections(model.sessionListSections) + } + if model.remoteHasMore { + Button { model.loadMoreRemoteSessions() } label: { + Text(model.localized(model.busy ? "正在加载" : "加载更多会话")) + .font(.system(size: 13)).foregroundStyle(BitFunTheme.muted) + .frame(maxWidth: .infinity, minHeight: 42) + } + .buttonStyle(.plain).disabled(model.busy) + } + } + + @ViewBuilder + private func remoteGroupedSessionSections( + _ sections: [MobileSessionListSectionProjection] + ) -> some View { + let visibleSessions = sections.flatMap(\.sessions) + let chatSessions = sections.first(where: { $0.kind == .chat })?.sessions ?? [] + if visibleSessions.isEmpty && !model.workspaceLoading { + Text(model.localized(hasActiveRemoteViewFilter ? "没有匹配的会话" : "暂无远程会话")) + .font(.system(size: 13)) + .foregroundStyle(BitFunTheme.muted) + .padding(.horizontal, 10) + .frame(height: 42, alignment: .leading) + } else { + switch model.remoteGroupMode { + case "TIME": + remoteTimeSections(sections) + case "CHAT": + if !chatSessions.isEmpty { remoteChatSection(chatSessions) } + remoteProjectSections(sections) + default: + remoteProjectSections(sections) + if !chatSessions.isEmpty { remoteChatSection(chatSessions) } + } + } + } + + private func remoteProjectSections( + _ sections: [MobileSessionListSectionProjection] + ) -> some View { + let workspaces = sections.compactMap { section -> MobileWorkspaceGroup? in + guard section.kind == .project else { return nil } + let source = model.remoteWorkspaces.first { + normalizedWorkspacePath($0.path) == normalizedWorkspacePath(section.path) + } + return MobileWorkspaceGroup( + path: section.path, + name: section.name, + selected: source?.selected ?? false, + sessions: section.sessions + ) + } + return ForEach(workspaces) { workspace in + SidebarWorkspaceRow( + workspace: workspace, + expanded: expandedWorkspacePaths.contains(workspace.path) || workspace.selected, + selectedSessionID: model.surface == .remote ? model.selectedSessionID : nil, + metadata: remoteSessionMetadata, + onToggle: { + if expandedWorkspacePaths.contains(workspace.path) { + expandedWorkspacePaths.remove(workspace.path) + } else { + expandedWorkspacePaths.insert(workspace.path) + } + }, + onToggleCreate: { + workspaceCreatePath = workspaceCreatePath == workspace.path ? nil : workspace.path + }, + onOpenWorkspace: { model.selectRemoteWorkspace(workspace) }, + onOpenSession: { model.surface = .remote; model.select($0) }, + onActions: { session in + model.surface = .remote + if permanent { onPermanentActions?(session) } + else { compactActionSession = session } + } + ) + } + } + + private func remoteTimeSections( + _ sections: [MobileSessionListSectionProjection] + ) -> some View { + let buckets = sections.compactMap { section -> RemoteTimeBucket? in + switch section.kind { + case .today: return RemoteTimeBucket(id: section.id, title: "今天", sessions: section.sessions) + case .yesterday: return RemoteTimeBucket(id: section.id, title: "昨天", sessions: section.sessions) + case .earlier: return RemoteTimeBucket(id: section.id, title: "更早", sessions: section.sessions) + default: return nil + } + } + return ForEach(buckets) { bucket in + VStack(alignment: .leading, spacing: 0) { + Text(model.localized(bucket.title)) + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(BitFunTheme.muted) + .padding(.top, 12) + .padding(.bottom, 4) + ForEach(bucket.sessions) { session in + SidebarRecentRow( + model: model, + session: session, + selected: model.surface == .remote && session.id == model.selectedSessionID, + metadata: remoteSessionMetadata(session), + onOpen: { model.surface = .remote; model.select(session) }, + onActions: { + model.surface = .remote + if permanent { onPermanentActions?(session) } + else { compactActionSession = session } + } + ) + } + } + } + } + + private func remoteChatSection(_ sessions: [ChatSession]) -> some View { + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 8) { + Button { + withAnimation(.easeOut(duration: 0.18)) { remoteChatsCollapsed.toggle() } + } label: { + HStack(spacing: 8) { + Text(model.localized("聊天")) + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(BitFunTheme.muted) + Text(verbatim: "\(sessions.count)") + .font(.system(size: 12)) + .foregroundStyle(BitFunTheme.muted) + Image(systemName: remoteChatsCollapsed ? "chevron.right" : "chevron.down") + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(BitFunTheme.muted) + } + } + .buttonStyle(.plain) + Spacer(minLength: 0) + Button { model.createRemoteAssistantSession() } label: { + Image(systemName: "square.and.pencil") + .font(.system(size: 16, weight: .medium)) + .foregroundStyle(BitFunTheme.muted) + .frame(width: 40, height: 40) + } + .buttonStyle(.plain) + .disabled(model.busy) + .accessibilityLabel(Text(model.localized("新建远程会话"))) } + .frame(height: 44) - ForEach(workspaces) { workspace in - SidebarWorkspaceRow(workspace: workspace) + if !remoteChatsCollapsed { + ForEach(sessions.prefix(4)) { session in + SidebarRecentRow( + model: model, + session: session, + selected: model.surface == .remote && session.id == model.selectedSessionID, + metadata: remoteSessionMetadata(session), + onOpen: { model.surface = .remote; model.select(session) }, + onActions: { + model.surface = .remote + if permanent { onPermanentActions?(session) } + else { compactActionSession = session } + } + ) + } } } + .padding(.top, 8) + } + + private func remoteIsAssistant(_ session: ChatSession) -> Bool { + let agent = session.agentType.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if ["claw", "assistant", "chat"].contains(agent) { return true } + let path = normalizedWorkspacePath(session.workspacePath) + return !path.isEmpty && model.remoteAssistants.contains { + normalizedWorkspacePath($0.path) == path + } + } + + private func remoteWorkspacePath(_ session: ChatSession) -> String { + let own = normalizedWorkspacePath(session.workspacePath) + if !own.isEmpty { return own } + if remoteIsAssistant(session) { return "" } + return normalizedWorkspacePath(model.remoteWorkspaces.first(where: \.selected)?.path) + } + + private func normalizedWorkspacePath(_ path: String?) -> String { + var result = (path ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + while result.count > 1 && (result.hasSuffix("/") || result.hasSuffix("\\")) { + result.removeLast() + } + return result + } + + private func remoteSessionMetadata(_ session: ChatSession) -> String? { + var parts: [String] = [] + if model.remoteShowWorkspaceMetadata { + let name = session.workspaceName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let path = remoteWorkspacePath(session) + if !name.isEmpty { parts.append(name) } + else if !path.isEmpty { parts.append(path) } + } + if model.remoteShowUpdatedMetadata, !session.updatedLabel.isEmpty { + parts.append(relativeUpdatedLabel(session)) + } + if model.remoteShowStatusMetadata, !session.status.isEmpty { + parts.append(remoteStatusLabel(session.status)) + } + return parts.isEmpty ? nil : parts.joined(separator: " · ") + } + + private func remoteStatusLabel(_ status: String) -> String { + switch status.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { + case "active", "running": return model.localized("运行中") + case "ready", "idle": return model.localized("就绪") + case "archived": return model.localized("已归档") + default: return status + } + } + + private func relativeUpdatedLabel(_ session: ChatSession) -> String { + let date = remoteSessionDate(session) + guard date != .distantPast else { return session.updatedLabel } + if abs(date.timeIntervalSinceNow) < 60 { return model.localized("刚刚") } + let formatter = RelativeDateTimeFormatter() + formatter.locale = Locale(identifier: model.appLanguage.rawValue) + formatter.unitsStyle = .full + return formatter.localizedString(for: date, relativeTo: Date()) + } + + private func remoteSessionDate(_ session: ChatSession) -> Date { + parsedRemoteDate(session.updatedLabel) ?? parsedRemoteDate(session.createdAt) ?? .distantPast + } + + private func parsedRemoteDate(_ value: String) -> Date? { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + if let numeric = Double(trimmed) { + return Date(timeIntervalSince1970: numeric > 10_000_000_000 ? numeric / 1_000 : numeric) + } + let fractional = ISO8601DateFormatter() + fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = fractional.date(from: trimmed) { return date } + return ISO8601DateFormatter().date(from: trimmed) + } + + private func pairedDeviceRow(name: String) -> some View { + Button { model.openRemoteSurface() } label: { + HStack(spacing: 10) { + ReferenceImage(assetName: "SidebarDeviceGlyph", width: 22, height: 18) + Text(name) + .font(.system(size: 15)) + .foregroundStyle(BitFunTheme.ink) + .lineLimit(1) + Spacer(minLength: 0) + Circle().fill(BitFunTheme.green).frame(width: 7, height: 7) + ReferenceImage(assetName: "SidebarDownGlyph", width: 14, height: 14) + } + .padding(.horizontal, 10) + .frame(height: 46) + } + .buttonStyle(.plain) + } + + private func workspaceCreateMenu(_ workspace: MobileWorkspaceGroup) -> some View { + VStack(spacing: 0) { + workspaceCreateMenuRow("Code") { + workspaceCreatePath = nil + model.createRemoteSession(in: workspace, agentType: "code") + } + workspaceCreateMenuRow("Cowork") { + workspaceCreatePath = nil + model.createRemoteSession(in: workspace, agentType: "Cowork") + } + } + .bitFunCompactPopoverSurface() + } + + private func workspaceCreateMenuRow(_ title: String, action: @escaping () -> Void) -> some View { + Button(action: action) { + Text(verbatim: title) + .font(.system(size: 15, weight: .medium)) + .foregroundStyle(BitFunTheme.ink) + .frame(maxWidth: .infinity, minHeight: MobileDesignGeometry.compactPopoverActionHeight, alignment: .leading) + .padding(.horizontal, 18) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) } private var footer: some View { + Group { + if model.accountUser == nil { + SignedOutConnectionActions( + scanTitle: model.localized("扫码连接"), + accountTitle: model.localized("登录 BitFun 账号"), + onScan: model.scanRemote, + onOpenAccount: { model.accountSheetOpen = true; model.drawerOpen = false }, + showScan: !model.remoteConnected + ) + } else { + authenticatedFooter + } + } + } + + private var authenticatedFooter: some View { HStack(spacing: 0) { - Button { model.surface = .local; model.drawerOpen = false } label: { + Button { model.newLocalChat() } label: { HStack(spacing: 9) { ReferenceImage(assetName: "SidebarEditGlyph", width: 24, height: 24) - Text("聊天") + Text(model.localized("聊天")) .font(.system(size: 15, weight: .medium)) .foregroundStyle(BitFunTheme.ink) } @@ -180,25 +751,48 @@ struct SidebarView: View { .shadow(color: .black.opacity(0.08), radius: 10, y: 4) } .buttonStyle(.plain) - .accessibilityLabel("设置") + .accessibilityLabel(Text(model.localized("设置"))) } .frame(height: 56) } } +private struct RemoteTimeBucket: Identifiable { + let id: String + let title: String + let sessions: [ChatSession] +} + private struct SidebarRecentRow: View { + @ObservedObject var model: MobileAppModel let session: ChatSession let selected: Bool - let action: () -> Void - + var metadata: String? = nil + let onOpen: () -> Void + let onActions: () -> Void var body: some View { - Button(action: action) { - HStack(spacing: 8) { - Text(session.title) - .font(.system(size: 15)) - .foregroundStyle(BitFunTheme.ink) - .lineLimit(1) - Spacer(minLength: 0) + HStack(spacing: 0) { + Button(action: onOpen) { + VStack(alignment: .leading, spacing: 2) { + Text(session.title) + .font(.system(size: 15, weight: selected ? .medium : .regular)) + .foregroundStyle(BitFunTheme.ink) + .lineLimit(1) + if let metadata, !metadata.isEmpty { + Text(metadata) + .font(MobileDesignTypography.labelSmall.font) + .foregroundStyle(BitFunTheme.muted) + .lineLimit(1) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + + Button { + onActions() + } label: { HStack(spacing: 3) { Circle().fill(BitFunTheme.muted).frame(width: 3.5, height: 3.5) Circle().fill(BitFunTheme.muted).frame(width: 3.5, height: 3.5) @@ -207,61 +801,149 @@ private struct SidebarRecentRow: View { .frame(width: 34, height: 40) .opacity(0.62) } - .padding(.horizontal, 12) - .frame(height: 44) - .background(selected ? BitFunTheme.soft : .clear) - .clipShape(RoundedRectangle(cornerRadius: 10)) + .buttonStyle(.plain) + .accessibilityLabel(Text(model.localized("会话操作"))) + .anchorPreference( + key: SidebarSessionActionsAnchorKey.self, + value: .bounds, + transform: { [session.id: $0] } + ) } - .buttonStyle(.plain) + .padding(.leading, 12) + .padding(.trailing, 4) + .frame(minHeight: metadata == nil ? 44 : 56) + .background(selected ? BitFunTheme.soft : .clear) + .clipShape(RoundedRectangle(cornerRadius: 10)) } } private struct SidebarWorkspaceRow: View { - let workspace: SidebarWorkspace + let workspace: MobileWorkspaceGroup + let expanded: Bool + let selectedSessionID: String? + let metadata: (ChatSession) -> String? + let onToggle: () -> Void + let onToggleCreate: () -> Void + let onOpenWorkspace: () -> Void + let onOpenSession: (ChatSession) -> Void + let onActions: (ChatSession) -> Void var body: some View { VStack(alignment: .leading, spacing: 0) { HStack(spacing: 10) { - ReferenceImage(assetName: "SidebarFolderGlyph", width: 24, height: 20) - Text(workspace.name) - .font(.system(size: 15)) - .foregroundStyle(BitFunTheme.ink) - .lineLimit(1) + Button(action: onOpenWorkspace) { + HStack(spacing: 10) { + ReferenceImage(assetName: "SidebarFolderGlyph", width: 24, height: 20) + Text(workspace.name) + .font(.system(size: 15, weight: workspace.selected ? .medium : .regular)) + .foregroundStyle(BitFunTheme.ink) + .lineLimit(1) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) Spacer(minLength: 0) - ReferenceImage(assetName: "SidebarEditGlyph", width: 22, height: 22) - .opacity(0.62) - ReferenceImage(assetName: "SidebarDownGlyph", width: 14, height: 14) + Button(action: onToggleCreate) { + Image(systemName: "square.and.pencil") + .font(.system(size: 16, weight: .medium)) + .foregroundStyle(BitFunTheme.muted) + .frame(width: 32, height: 40) + } + .buttonStyle(.plain) + .accessibilityLabel(MobileLocalization.text("新建远程会话")) + .anchorPreference( + key: SidebarWorkspaceCreateAnchorKey.self, + value: .bounds, + transform: { [workspace.path: $0] } + ) + Button(action: onToggle) { + ReferenceImage( + assetName: expanded ? "SidebarDownGlyph" : "SidebarChevronGlyph", + width: 14, + height: 14 + ) .opacity(0.62) + .frame(width: 32, height: 40) + } + .buttonStyle(.plain) + .accessibilityLabel( + MobileLocalization.text(expanded ? "收起工作区" : "展开工作区") + ) } .padding(.horizontal, 10) .frame(height: 46) - ForEach(Array(workspace.sessions.enumerated()), id: \.offset) { _, title in - HStack(spacing: 10) { - Image(systemName: "doc") - .font(.system(size: 21, weight: .regular)) + .background(workspace.selected ? BitFunTheme.soft.opacity(0.75) : Color.clear) + .clipShape(RoundedRectangle(cornerRadius: 10)) + + if expanded { + if workspace.sessions.isEmpty { + Text(MobileLocalization.text("此工作区暂无会话")) + .font(.system(size: 13)) .foregroundStyle(BitFunTheme.muted) - .frame(width: 22) - Text(title) - .font(.system(size: 15)) - .foregroundStyle(BitFunTheme.ink) - .lineLimit(1) - Spacer(minLength: 0) + .padding(.leading, 42) + .frame(height: 38, alignment: .leading) + } + ForEach(workspace.sessions.prefix(4)) { session in + HStack(spacing: 0) { + Button { onOpenSession(session) } label: { + HStack(spacing: 10) { + Image(systemName: "doc") + .font(.system(size: 18, weight: .regular)) + .foregroundStyle(BitFunTheme.muted) + .frame(width: 22) + VStack(alignment: .leading, spacing: 2) { + Text(session.title) + .font(.system( + size: 15, + weight: selectedSessionID == session.id ? .medium : .regular + )) + .foregroundStyle(BitFunTheme.ink) + .lineLimit(1) + if let detail = metadata(session), !detail.isEmpty { + Text(detail) + .font(MobileDesignTypography.labelSmall.font) + .foregroundStyle(BitFunTheme.muted) + .lineLimit(1) + } + } + Spacer(minLength: 0) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + Button { onActions(session) } label: { + Image(systemName: "ellipsis") + .font(.system(size: 13, weight: .medium)).foregroundStyle(BitFunTheme.muted) + .frame(width: 36, height: 40) + } + .buttonStyle(.plain) + .accessibilityLabel(MobileLocalization.text("会话操作")) + .anchorPreference( + key: SidebarSessionActionsAnchorKey.self, + value: .bounds, + transform: { [session.id: $0] } + ) + } + .padding(.leading, 32) + .padding(.trailing, 4) + .frame(minHeight: metadata(session) == nil ? 44 : 56) + .background(selectedSessionID == session.id ? BitFunTheme.soft : Color.clear) + .clipShape(RoundedRectangle(cornerRadius: 9)) + } + if workspace.sessions.count > 4 { + Text( + MobileLocalization.format( + "还有 %lld 个会话", + language: MobileLocalization.restoredLanguage(), + Int64(workspace.sessions.count - 4) + ) + ) + .font(.system(size: 13)) + .foregroundStyle(BitFunTheme.muted) + .padding(.leading, 42) + .frame(height: 36, alignment: .leading) } - .padding(.leading, 32) - .frame(height: 44) } } } } - -private struct SidebarDevice: Identifiable { - let id = UUID() - let name: String - let online: Bool -} - -private struct SidebarWorkspace: Identifiable { - let id = UUID() - let name: String - let sessions: [String] -} diff --git a/src/apps/mobile/ios/BitFun/Info.plist b/src/apps/mobile/ios/BitFun/Info.plist index c47bda8ad0..7b1f3c838d 100644 --- a/src/apps/mobile/ios/BitFun/Info.plist +++ b/src/apps/mobile/ios/BitFun/Info.plist @@ -13,11 +13,15 @@ CFBundlePackageType APPL CFBundleShortVersionString - 0.1 + 1.0.0 CFBundleVersion 1 NSCameraUsageDescription 扫描桌面端二维码以连接 BitFun + NSMicrophoneUsageDescription + 使用麦克风把语音转换为聊天输入 + NSSpeechRecognitionUsageDescription + 将语音识别为文字并填入聊天输入框 UILaunchScreen diff --git a/src/apps/mobile/ios/BitFun/Infrastructure/MobileAppModel.swift b/src/apps/mobile/ios/BitFun/Infrastructure/MobileAppModel.swift index 349d0f5ec1..f17948973b 100644 --- a/src/apps/mobile/ios/BitFun/Infrastructure/MobileAppModel.swift +++ b/src/apps/mobile/ios/BitFun/Infrastructure/MobileAppModel.swift @@ -21,54 +21,288 @@ struct ChatMessage: Identifiable, Equatable { enum Role { case user, assistant } } +struct MobileTimelineImage: Identifiable, Equatable { + var id: String { dataURL } + let name: String + let dataURL: String +} + +struct MobileTimelineOption: Identifiable, Equatable { + let label: String + let description: String? + var id: String { label } +} + +struct MobileTimelineQuestion: Identifiable, Equatable { + let index: Int + let header: String + let question: String + let options: [MobileTimelineOption] + let multiSelect: Bool + var id: Int { index } +} + +struct MobileTimelineTool: Identifiable, Equatable { + let id: String + let name: String + let phase: String + let kind: String + let operation: String + let target: String + let filePath: String + let fileLabel: String + let input: String + let output: String + let question: String? + let questions: [MobileTimelineQuestion] + let actions: Set +} + +indirect enum MobileTimelineBlock: Identifiable, Equatable { + case text(id: String, text: String, streaming: Bool) + case thinking(id: String, text: String, streaming: Bool) + case tools(id: String, tools: [MobileTimelineTool]) + case subagent( + id: String, + title: String, + running: Bool, + text: String, + children: [MobileTimelineBlock] + ) + + var id: String { + switch self { + case let .text(id, _, _), let .thinking(id, _, _), let .tools(id, _), + let .subagent(id, _, _, _, _): + return id + } + } +} + +struct MobileConversationRow: Identifiable, Equatable { + let id: String + let kind: String + let text: String + let thinking: String? + let images: [MobileTimelineImage] + let tools: [MobileTimelineTool] + let blocks: [MobileTimelineBlock] + let streaming: Bool + let typing: Bool + let pending: Bool + let showRetry: Bool +} + +struct MobileFilePreview: Identifiable, Equatable { + let id: String + let name: String + let content: String + let mimeType: String + let imageData: Data? + let truncated: Bool + let failure: String? +} + +struct MobilePendingDownload: Identifiable, Equatable { + var id: String { reference } + let reference: String + let remotePath: String + let name: String + let mimeType: String + let data: Data +} + struct ChatSession: Identifiable, Equatable { - let id: UUID + let id: String var title: String var updatedLabel: String var pinned: Bool = false + var status: String = "active" + var agentType: String = "general_chat" + var workspacePath: String? + var workspaceName: String? + var createdAt: String = "" + var messageCount: Int = 0 +} + +struct MobileAccountDevice: Identifiable, Equatable { + let id: String + let name: String + let online: Bool + let selected: Bool +} + +struct MobileWorkspaceGroup: Identifiable, Equatable { + var id: String { path } + let path: String + let name: String + let selected: Bool + let sessions: [ChatSession] +} + +enum MobileSessionListSectionKind: Equatable { + case chat + case project + case today + case yesterday + case earlier +} + +struct MobileSessionListSectionProjection: Identifiable { + let id: String + let kind: MobileSessionListSectionKind + let path: String + let name: String + let sessions: [ChatSession] +} + +struct MobileSessionWorkspaceOption: Identifiable { + var id: String { path } + let path: String + let name: String +} + +struct MobileAssistantOption: Identifiable, Equatable { + var id: String { path } + let path: String + let name: String +} + +struct ComposerAttachment: Identifiable, Equatable { + let id: String + let data: Data + let mimeType: String + + var dataURL: String { + "data:\(mimeType);base64,\(data.base64EncodedString())" + } +} + +struct ComposerModelOption: Identifiable, Equatable { + let id: String + let primaryLabel: String + let secondaryLabel: String + let source: String + let selected: Bool +} + +enum MobileDownloadPhase { + case idle + case preparing + case downloading + case saving + case saved + case failed } @MainActor final class MobileAppModel: ObservableObject { + @Published var appLanguage: MobileLanguage = MobileLocalization.restoredLanguage() @Published var surface: MobileSurface = .local @Published var sessions: [ChatSession] @Published var remoteSessions: [ChatSession] = [] - @Published var selectedSessionID: UUID + @Published var remoteQuery = "" + @Published var remoteAgentFilter = "ALL" + @Published var remoteViewAgentFilter = "" + @Published var remoteGroupMode = "PROJECT" + @Published var remoteWorkspaceFilter = "" + @Published var remoteStatusFilter = "" + @Published var remoteShowWorkspaceMetadata = false + @Published var remoteShowUpdatedMetadata = false + @Published var remoteShowStatusMetadata = false + @Published var remoteViewSettingsOpen = false + @Published var remoteHasMore = false + @Published var remoteHasMoreMessages = false + @Published var remotePermissionMode = "ASK" + @Published var remotePermissionFailure: String? + @Published var remoteAssistants: [MobileAssistantOption] = [] + @Published var remoteCreateOpen = false + @Published var generalConfigOpen = false + @Published var generalConfigured = false + @Published var generalConfigBaseURL = "" + @Published var generalConfigModel = "" + @Published var generalConfigHasAPIKey = false + @Published var generalConfigFailure: String? + @Published var generalConnectionTestRunning = false + @Published var generalConnectionTestMessage: String? + @Published var generalExportOpen = false + @Published var generalExportName = "conversation.md" + @Published var generalExportData = Data() + @Published var selectedSessionID: String @Published var messages: [ChatMessage] + @Published var timelineRows: [MobileConversationRow] = [] @Published var draft = "" @Published var drawerOpen = false @Published var settingsOpen = false + @Published var remoteControlSettingsOpen = false + @Published var accountSheetOpen = false + @Published var languagePickerOpen = false @Published var connectionPhase: ConnectionPhase = .connected @Published var isSending = false + @Published var busy = false + @Published var composerImages: [ComposerAttachment] = [] + @Published var modelOptions: [ComposerModelOption] = [] + @Published var toastMessage: String? @Published var remoteConnected = false @Published var remoteSessionSelected = false @Published var localSessionSelected = false @Published var pairingSheetOpen = false + @Published var pairingScanRequested = false @Published var pairingBusy = false @Published var pairingError: String? @Published var coreErrorMessage: String? @Published var accountUser: String? + @Published var accountUserID: String? + @Published var localDeviceID = "" @Published var accountBusy = false @Published var accountDeviceName: String? + @Published var directPairingDeviceName: String? @Published var accountDeviceCount = 0 + @Published var accountDevices: [MobileAccountDevice] = [] + @Published var accountSelectedDeviceID: String? + @Published var accountRefreshing = false + @Published var remoteWorkspaces: [MobileWorkspaceGroup] = [] + @Published var workspaceLoading = false + @Published var workspaceLoadFailed = false + @Published var filePreview: MobileFilePreview? + @Published var sessionDetails: ChatSession? = nil + @Published var filePreviewLoading = false + @Published var pendingDownload: MobilePendingDownload? + @Published var downloadExporterOpen = false + @Published var downloadTargetPath: String? + @Published var downloadStatusText: String? + @Published var downloadPhase: MobileDownloadPhase = .idle + private var activeTurnID: String? + private var directPairingConnected = false + private var accountLoginPreview = false + private var localActionPreview = false + var composerModelPickerPreview = false + private var workspaceCatalog: [(path: String, name: String, selected: Bool)] = [] + private var pendingRemoteWorkspaceCreate: (path: String, agentType: String)? + private var pendingRemoteAssistantCreate = false + private var selectedRemoteWorkspaceKind = "" private var coreAdapter: MobileCoreAdapter? - init(sessions: [ChatSession], selectedSessionID: UUID, messages: [ChatMessage]) { + init(sessions: [ChatSession], selectedSessionID: String, messages: [ChatMessage]) { self.sessions = sessions self.selectedSessionID = selectedSessionID self.messages = messages + self.timelineRows = messages.map(Self.simpleTimelineRow) self.coreAdapter = nil - self.coreAdapter = MobileCoreAdapter( + let adapter = MobileCoreAdapter( onState: { [weak self] state in self?.apply(coreState: state) }, onPairingState: { [weak self] state in self?.apply(pairingState: state) }, onAccountState: { [weak self] state in self?.apply(accountState: state) }, onRemoteState: { [weak self] state in self?.apply(remoteState: state) }, + onWorkspaceState: { [weak self] state in self?.apply(workspaceState: state) }, ) + self.coreAdapter = adapter + self.localDeviceID = adapter.deviceID } static let preview: MobileAppModel = { - let first = ChatSession(id: UUID(), title: "你好", updatedLabel: "刚刚") + let first = ChatSession(id: UUID().uuidString, title: "你好", updatedLabel: "刚刚") return MobileAppModel( sessions: [first], selectedSessionID: first.id, @@ -82,12 +316,75 @@ final class MobileAppModel: ObservableObject { static var launchConfigured: MobileAppModel { let model = preview let arguments = ProcessInfo.processInfo.arguments + if arguments.contains("--english") { + model.setLanguage(.english) + } else if arguments.contains("--simplified-chinese") { + model.setLanguage(.simplifiedChinese) + } if arguments.contains("--remote") { model.surface = .remote } if arguments.contains("--connected") { model.configureConnectedPreview() } + if arguments.contains("--remote-chat-section") { + if !model.remoteConnected { model.configureConnectedPreview() } + model.remoteSessions.append( + ChatSession( + id: "preview-remote-chat", + title: "移动端体验对齐", + updatedLabel: "刚刚", + status: "idle", + agentType: "Claw", + workspacePath: nil, + workspaceName: nil + ) + ) + model.rebuildRemoteWorkspaceGroups() + } + if arguments.contains("--remote-view-settings") { + if !model.remoteConnected { model.configureConnectedPreview() } + model.remoteViewSettingsOpen = true + } + if arguments.contains("--remote-view-density") { + if !model.remoteConnected { model.configureConnectedPreview() } + let now = ISO8601DateFormatter().string(from: Date()) + for index in model.remoteSessions.indices { + model.remoteSessions[index].updatedLabel = now + } + model.remoteGroupMode = "TIME" + model.remoteShowWorkspaceMetadata = true + model.remoteShowUpdatedMetadata = true + model.remoteShowStatusMetadata = true + model.rebuildRemoteWorkspaceGroups() + } + if arguments.contains("--timeline-preview") { + model.configureTimelinePreview() + } + if arguments.contains("--file-preview") { + model.filePreview = MobileFilePreview( + id: "src/main.rs", + name: "main.rs", + content: "// Remote workspace preview\nfn main() {\n println!(\"Hello from BitFun\");\n}\n", + mimeType: "text/x-rust", + imageData: nil, + truncated: false, + failure: nil + ) + } + if arguments.contains("--download-preview") { + model.pendingDownload = MobilePendingDownload( + reference: "computer://src/main.rs", + remotePath: "src/main.rs", + name: "main.rs", + mimeType: "text/x-rust", + data: Data("fn main() {}\n".utf8) + ) + model.downloadTargetPath = "src/main.rs" + model.downloadPhase = .saving + model.downloadStatusText = model.localized("正在保存") + model.downloadExporterOpen = true + } if let relay = arguments.value(after: "--relay-url"), let username = arguments.value(after: "--username"), let password = arguments.value(after: "--password") { @@ -96,6 +393,97 @@ final class MobileAppModel: ObservableObject { if arguments.contains("--drawer") { model.drawerOpen = true } + if arguments.contains("--settings") { + model.settingsOpen = true + } + if arguments.contains("--remote-settings") { + model.surface = .remote + model.remoteControlSettingsOpen = true + } + if arguments.contains("--model-settings") { + model.settingsOpen = true + model.generalConfigOpen = true + } + if arguments.contains("--composer-model-picker") || + ProcessInfo.processInfo.environment["BITFUN_COMPOSER_MODEL_PICKER"] == "1" { + model.composerModelPickerPreview = true + model.localSessionSelected = true + model.draft = "\n" + model.modelOptions = [ + ComposerModelOption( + id: "preview-codex", + primaryLabel: "GPT-5.6 Codex", + secondaryLabel: "BitFun 账号", + source: "ACCOUNT", + selected: true + ), + ComposerModelOption( + id: "preview-local", + primaryLabel: "本机自定义模型", + secondaryLabel: "OpenAI 兼容服务", + source: "LOCAL", + selected: false + ), + ] + } + if arguments.contains("--pairing") || arguments.contains("--pairing-manual") || + arguments.contains("--pairing-account") { + model.pairingSheetOpen = true + } + if arguments.contains("--remote-create") { + model.remoteCreateOpen = true + } + if arguments.contains("--remote-home-preview") { + model.remoteSessionSelected = false + model.selectedSessionID = "" + model.timelineRows = [] + model.messages = [] + } + if arguments.contains("--local-actions") { + model.localActionPreview = true + model.surface = .local + model.localSessionSelected = true + model.remoteSessionSelected = false + if let localSession = model.sessions.first { + model.selectedSessionID = localSession.id + } + } + if arguments.contains("--account-login") { + model.accountLoginPreview = true + model.accountUser = nil + model.accountDeviceName = nil + model.accountSelectedDeviceID = nil + model.accountDevices = [] + model.accountDeviceCount = 0 + model.coreErrorMessage = nil + model.settingsOpen = false + model.accountSheetOpen = true + } + if arguments.contains("--account-profile") { + model.accountLoginPreview = true + model.accountUser = "bitfun-user" + model.accountUserID = "user-preview-7A31" + model.accountDevices = [ + MobileAccountDevice( + id: "desktop-preview", + name: "Studio Mac", + online: true, + selected: true + ), + MobileAccountDevice( + id: "desktop-offline-preview", + name: "Office PC", + online: false, + selected: false + ), + ] + model.accountDeviceName = "Studio Mac" + model.accountSelectedDeviceID = "desktop-preview" + model.accountDeviceCount = model.accountDevices.count + model.coreErrorMessage = nil + model.settingsOpen = false + model.accountSheetOpen = true + } return model } @@ -116,17 +504,23 @@ final class MobileAppModel: ObservableObject { return } let value = draft.trimmingCharacters(in: .whitespacesAndNewlines) - guard !value.isEmpty, !isSending else { return } + guard !value.isEmpty || !composerImages.isEmpty else { return } + guard !isSending && !busy else { return } if surface == .local { localSessionSelected = true if selectedSession == nil, let first = sessions.first { selectedSessionID = first.id } } - messages.append(ChatMessage(id: UUID(), role: .user, text: value)) + let optimisticMessage = ChatMessage(id: UUID(), role: .user, text: value) + messages.append(optimisticMessage) + timelineRows.append(Self.simpleTimelineRow(optimisticMessage, images: composerImages)) draft = "" isSending = true + busy = true coreAdapter?.updateDraft(value) + coreAdapter?.setGeneralChatImages(composerImages) + composerImages = [] coreAdapter?.send() } @@ -134,8 +528,10 @@ final class MobileAppModel: ObservableObject { selectedSessionID = session.id if surface == .remote { remoteSessionSelected = true + coreAdapter?.openRemoteSession(sessionID: session.id) } else { localSessionSelected = true + coreAdapter?.selectGeneralSession(sessionID: session.id) } drawerOpen = false } @@ -145,24 +541,421 @@ final class MobileAppModel: ObservableObject { drawerOpen = false } + func setLanguage(_ language: MobileLanguage) { + UserDefaults.standard.set(language.rawValue, forKey: MobileLocalization.preferenceKey) + guard appLanguage != language else { + languagePickerOpen = false + return + } + appLanguage = language + languagePickerOpen = false + } + + func localized(_ key: String) -> String { + MobileLocalization.text(key, language: appLanguage) + } + + func localizedFormat(_ key: String, _ arguments: CVarArg...) -> String { + String( + format: localized(key), + locale: Locale(identifier: appLanguage.rawValue), + arguments: arguments + ) + } + func connectRemote() { pairingError = nil + pairingScanRequested = false + pairingSheetOpen = true + } + + func scanRemote() { + pairingError = nil + pairingScanRequested = true pairingSheetOpen = true } + func consumePairingScanRequest() { + pairingScanRequested = false + } + + func openAccountFromPairing() { + pairingSheetOpen = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + self.accountSheetOpen = true + } + } + + var usesDirectPairing: Bool { directPairingConnected } + + var directPairingSidebarDeviceID: String { "qr:\(directPairingDeviceName ?? "desktop")" } + + func dismissPairing() { + pairingError = nil + coreAdapter?.dismissPairingFailure() + } + + func handleScenePhase(_ phase: ScenePhase) { + switch phase { + case .active: coreAdapter?.pairingForeground() + case .background: coreAdapter?.pairingBackground() + default: break + } + } + + func verifyRemoteConnection() { + guard accountUser == nil else { + refreshRemoteDevices() + return + } + connectionPhase = .reconnecting + coreAdapter?.verifyPairing() + } + + func disconnectRemote() { + coreAdapter?.disconnect() + directPairingConnected = false + directPairingDeviceName = nil + remoteConnected = false + remoteSessionSelected = false + remoteSessions = [] + remoteWorkspaces = [] + workspaceCatalog = [] + pendingRemoteWorkspaceCreate = nil + pendingRemoteAssistantCreate = false + selectedRemoteWorkspaceKind = "" + selectedSessionID = "" + timelineRows = [] + messages = [] + surface = .local + connectionPhase = .connected + } + + func openRemoteSurface() { + surface = .remote + drawerOpen = false + } + + func newLocalChat() { + surface = .local + drawerOpen = false + localSessionSelected = false + selectedSessionID = "" + messages = [] + timelineRows = [] + draft = "" + composerImages = [] + coreAdapter?.newGeneralSession() + } + + func selectRemoteDevice(_ device: MobileAccountDevice) { + guard device.online else { + showToast(localized("这台桌面设备当前离线")) + return + } + surface = .remote + drawerOpen = false + guard !device.selected else { return } + directPairingConnected = false + directPairingDeviceName = nil + accountBusy = true + remoteSessionSelected = false + remoteConnected = directPairingConnected + remoteSessions = [] + remoteWorkspaces = [] + workspaceCatalog = [] + pendingRemoteWorkspaceCreate = nil + pendingRemoteAssistantCreate = false + selectedRemoteWorkspaceKind = "" + messages = [] + timelineRows = [] + coreAdapter?.selectAccountDevice(id: device.id) + } + + func refreshRemoteDevices() { + guard accountUser != nil else { return } + coreAdapter?.refreshAccountDevices() + } + + func logoutAccount() { + coreAdapter?.logoutAccount() + accountUser = nil + accountUserID = nil + accountDeviceName = nil + accountDeviceCount = 0 + accountDevices = [] + accountSelectedDeviceID = nil + remoteConnected = directPairingConnected + if !directPairingConnected { + remoteSessionSelected = false + remoteSessions = [] + remoteWorkspaces = [] + workspaceCatalog = [] + pendingRemoteWorkspaceCreate = nil + pendingRemoteAssistantCreate = false + selectedRemoteWorkspaceKind = "" + surface = .local + } + } + + func selectRemoteWorkspace(_ workspace: MobileWorkspaceGroup) { + guard remoteConnected else { + showToast(localized("请先连接桌面设备")) + return + } + surface = .remote + drawerOpen = false + coreAdapter?.selectRemoteWorkspace(path: workspace.path) + } + + func createRemoteSession(in workspace: MobileWorkspaceGroup, agentType: String) { + guard remoteConnected, !busy else { return } + drawerOpen = false + surface = .remote + createRemoteSession( + agentType: agentType, + title: "", + instruction: "", + workspacePath: workspace.path + ) + } + + func createRemoteAssistantSession() { + guard remoteConnected, !busy else { return } + drawerOpen = false + surface = .remote + if selectedRemoteWorkspaceKind.lowercased() == "assistant" { + createRemoteSession(agentType: "Claw", title: "", instruction: "") + return + } + guard let assistant = remoteAssistants.first else { + showToast(localized("暂无可用工作区")) + return + } + pendingRemoteAssistantCreate = true + coreAdapter?.selectRemoteAssistant(path: assistant.path) + } + + func selectRemoteAssistant(_ assistant: MobileAssistantOption) { + guard remoteConnected else { return } + coreAdapter?.selectRemoteAssistant(path: assistant.path) + } + + func createRemoteSession( + agentType: String, + title: String, + instruction: String, + modelID: String? = nil, + workspacePath: String? = nil + ) { + guard remoteConnected, !busy else { return } + let normalizedTitle = title.trimmingCharacters(in: .whitespacesAndNewlines) + let normalizedInstruction = instruction.trimmingCharacters(in: .whitespacesAndNewlines) + let selectedModel = modelID ?? modelOptions.first(where: \.selected)?.id + coreAdapter?.createRemoteSession( + agentType: agentType, + title: normalizedTitle, + instruction: normalizedInstruction, + modelID: selectedModel, + workspacePath: workspacePath + ) + remoteCreateOpen = false + surface = .remote + } + + func deleteRemoteSession(_ session: ChatSession) { + guard !busy else { return } + coreAdapter?.deleteRemoteSession(sessionID: session.id) + if selectedSessionID == session.id { + remoteSessionSelected = false + timelineRows = [] + messages = [] + } + } + + func searchRemoteSessions(_ query: String) { + remoteQuery = query + guard remoteConnected else { return } + coreAdapter?.searchRemoteSessions(query: query) + } + + func loadMoreRemoteSessions() { + guard remoteConnected, remoteHasMore, !busy else { return } + coreAdapter?.loadMoreRemoteSessions() + } + + func loadOlderRemoteMessages() { + guard surface == .remote, remoteConnected, remoteHasMoreMessages, !busy else { return } + coreAdapter?.loadOlderRemoteMessages() + } + + func refreshRemoteSessions() { + guard remoteConnected, !busy else { return } + coreAdapter?.refreshRemoteSessions() + } + + func setRemoteAgentFilter(_ name: String) { + let filter: SessionAgentFilter + switch name { + case "CODE": filter = .code + case "COWORK": filter = .cowork + default: filter = .all + } + remoteAgentFilter = name + coreAdapter?.setRemoteAgentFilter(filter) + } + + func refreshRemotePermissionMode() { + guard remoteConnected else { return } + coreAdapter?.refreshRemotePermissionMode() + } + + func setRemotePermissionMode(_ name: String) { + let mode: SessionPermissionMode + switch name { + case "AUTO": mode = .auto + case "FULL_ACCESS": mode = .fullAccess + default: mode = .ask + } + coreAdapter?.setRemotePermissionMode(mode) + } + + func retryRemoteWorkspaces() { + coreAdapter?.loadRemoteWorkspaces() + } + + func archiveLocalSession(_ session: ChatSession) { + coreAdapter?.archiveGeneralSession( + sessionID: session.id, + archived: session.status.lowercased() != "archived" + ) + } + + func deleteLocalSession(_ session: ChatSession) { + coreAdapter?.deleteGeneralSession(sessionID: session.id) + if selectedSessionID == session.id { + localSessionSelected = false + } + } + + func saveGeneralConfig(baseURL: String, model: String, apiKey: String, clearAPIKey: Bool) { + coreAdapter?.saveGeneralConfig( + baseURL: baseURL, model: model, apiKey: apiKey, clearAPIKey: clearAPIKey + ) + } + + func testGeneralConnection(baseURL: String, model: String, apiKey: String, clearAPIKey: Bool) { + coreAdapter?.testGeneralConnection( + baseURL: baseURL, model: model, apiKey: apiKey, clearAPIKey: clearAPIKey + ) + } + + func exportSelectedSession() { + guard surface == .local, let session = selectedSession else { return } + coreAdapter?.exportGeneralSession(sessionID: session.id) + } + + func exportLocalSession(_ session: ChatSession) { + coreAdapter?.exportGeneralSession(sessionID: session.id) + } + + func showSessionDetails(_ session: ChatSession) { + sessionDetails = session + } + + func dismissSessionDetails() { + sessionDetails = nil + } + + func finishGeneralExport() { + generalExportOpen = false + generalExportData = Data() + coreAdapter?.clearGeneralExport() + } + private func configureConnectedPreview() { + directPairingConnected = true surface = .remote remoteConnected = true connectionPhase = .connected remoteSessionSelected = true + accountUser = "preview@bitfun" accountDeviceName = "DESKTOP-KM3L4UI" - let session = ChatSession(id: UUID(), title: "你好", updatedLabel: "刚刚") + accountSelectedDeviceID = "preview-desktop" + accountDevices = [ + MobileAccountDevice(id: "preview-desktop", name: "DESKTOP-KM3L4UI", online: true, selected: true) + ] + accountDeviceCount = accountDevices.count + let session = ChatSession( + id: UUID().uuidString, + title: "你好", + updatedLabel: "刚刚", + agentType: "code", + workspacePath: "/workspace/BitFun", + workspaceName: "BitFun" + ) remoteSessions = [session] + workspaceCatalog = [(path: "/workspace/BitFun", name: "BitFun", selected: true)] + remoteAssistants = [ + MobileAssistantOption(path: "/workspace/BitFun/.bitfun/assistants/review", name: "代码审查助手") + ] + remoteHasMore = true + rebuildRemoteWorkspaceGroups() selectedSessionID = session.id messages = [ ChatMessage(id: UUID(), role: .user, text: "你好"), ChatMessage(id: UUID(), role: .assistant, text: "这是 BitFun 的远程会话预览。"), ] + timelineRows = messages.map(Self.simpleTimelineRow) + } + + private func configureTimelinePreview() { + configureConnectedPreview() + let userID = UUID().uuidString + let assistantID = UUID().uuidString + let readOne = MobileTimelineTool( + id: "preview-read-1", name: "Read", phase: "COMPLETED", kind: "DOCUMENT", + operation: "READ_FILE", target: "main.rs", filePath: "computer://src/main.rs", + fileLabel: "main.rs", input: "src/main.rs", output: "读取完成", question: nil, questions: [], actions: [] + ) + let readTwo = MobileTimelineTool( + id: "preview-read-2", name: "Search", phase: "COMPLETED", kind: "SEARCH", + operation: "SEARCH_CODE", target: "MobileShellView", filePath: "", fileLabel: "", + input: "MobileShellView", output: "找到 4 处结果", question: nil, questions: [], actions: [] + ) + let approval = MobileTimelineTool( + id: "preview-approval", name: "Bash", phase: "PENDING_CONFIRMATION", kind: "COMMAND", + operation: "RUN_COMMAND", target: "pnpm test", filePath: "", fileLabel: "", + input: "pnpm test", output: "", question: nil, questions: [], actions: ["APPROVE", "REJECT"] + ) + let question = MobileTimelineTool( + id: "preview-question", name: "AskUserQuestion", phase: "PENDING_CONFIRMATION", kind: "QUESTION", + operation: "ASK_CONFIRMATION", target: "", filePath: "", fileLabel: "", input: "", output: "", + question: "要同时运行远程场景回归吗?", questions: [], actions: ["ANSWER"] + ) + timelineRows = [ + MobileConversationRow( + id: userID, kind: "USER", text: "请检查移动端的消息、工具和文件交互。", thinking: nil, + images: [], tools: [], blocks: [], streaming: false, typing: false, pending: false, showRetry: false + ), + MobileConversationRow( + id: assistantID, kind: "ASSISTANT", text: "", thinking: nil, images: [], tools: [], + blocks: [ + .thinking(id: "preview-thinking", text: "先对照 HarmonyOS 的消息顺序与工具状态,再核对 Android 的交互策略。", streaming: false), + .text( + id: "preview-text", + text: "## 检查结果\n\n消息按共享投影顺序显示,文件可直接打开:[main.rs](computer://src/main.rs)。\n\n- Markdown 与代码块\n- 思考过程与子任务\n- 工具确认、提问和取消\n\n```swift\nlet parity = true\n```", + streaming: false + ), + .tools(id: "preview-tools", tools: [readOne, readTwo, approval, question]), + ], + streaming: false, typing: false, pending: false, showRetry: false + ), + ] + messages = [ + ChatMessage(id: UUID(), role: .user, text: "请检查移动端的消息、工具和文件交互。"), + ChatMessage(id: UUID(), role: .assistant, text: "检查结果"), + ] } func submitPairing(url: String) { @@ -171,6 +964,12 @@ final class MobileAppModel: ObservableObject { coreAdapter?.submitPairing(url: url) } + func submitPairing(url: String, userID: String, password: String) { + pairingError = nil + pairingBusy = true + coreAdapter?.submitPairing(url: url, userID: userID, password: password) + } + func loginAccount(relayURL: String, username: String, password: String) { accountBusy = true coreErrorMessage = nil @@ -179,24 +978,257 @@ final class MobileAppModel: ObservableObject { func sendRemote() { let value = draft.trimmingCharacters(in: .whitespacesAndNewlines) - guard !value.isEmpty, let sessionID = visibleSessions.first(where: { $0.id == selectedSessionID })?.id else { return } + guard !value.isEmpty || !composerImages.isEmpty, + !isSending, + connectionPhase != .disconnected, + let sessionID = visibleSessions.first(where: { $0.id == selectedSessionID })?.id else { return } + let images = composerImages draft = "" + composerImages = [] isSending = true - coreAdapter?.sendRemote(sessionID: sessionID.uuidString, content: value) + busy = true + coreAdapter?.sendRemote(sessionID: sessionID, content: value, images: images) } func syncDraftToCore() { - coreAdapter?.updateDraft(draft) + if surface == .local { + coreAdapter?.updateDraft(draft) + } + } + + func addComposerImage(data: Data, mimeType: String) { + guard composerImages.count < 4, data.count <= 10 * 1024 * 1024 else { + showToast(localized("最多添加 4 张且每张不超过 10 MB 的图片")) + return + } + composerImages.append( + ComposerAttachment(id: UUID().uuidString, data: data, mimeType: mimeType) + ) + if surface == .local { + coreAdapter?.setGeneralChatImages(composerImages) + } + } + + func removeComposerImage(id: String) { + composerImages.removeAll { $0.id == id } + if surface == .local { + coreAdapter?.setGeneralChatImages(composerImages) + } + } + + func stopSending() { + if surface == .remote { + guard remoteSessionSelected else { return } + coreAdapter?.cancelRemoteTurn(sessionID: selectedSessionID, turnID: activeTurnID) + } else { + coreAdapter?.cancelGeneralChat() + } + } + + func approveTool(_ toolID: String) { + guard surface == .remote, remoteSessionSelected, !toolID.isEmpty else { return } + coreAdapter?.approveRemoteTool(sessionID: selectedSessionID, toolID: toolID) + } + + func rejectTool(_ toolID: String) { + guard surface == .remote, remoteSessionSelected, !toolID.isEmpty else { return } + coreAdapter?.rejectRemoteTool( + sessionID: selectedSessionID, + toolID: toolID, + reason: "Rejected from the iOS client" + ) + } + + func cancelTool(_ toolID: String) { + guard surface == .remote, remoteSessionSelected, !toolID.isEmpty else { return } + coreAdapter?.cancelRemoteTool( + sessionID: selectedSessionID, + toolID: toolID, + reason: "Cancelled from the iOS client" + ) + } + + func answerTool(_ toolID: String, answer: String) { + let normalized = answer.trimmingCharacters(in: .whitespacesAndNewlines) + guard surface == .remote, + remoteSessionSelected, + !toolID.isEmpty, + !normalized.isEmpty else { return } + coreAdapter?.answerRemoteTool( + sessionID: selectedSessionID, + toolID: toolID, + answer: normalized + ) + } + + func answerTool(_ toolID: String, answers: [QuestionAnswer]) { + guard surface == .remote, + remoteSessionSelected, + !toolID.isEmpty, + !answers.isEmpty else { return } + coreAdapter?.answerRemoteToolStructured( + sessionID: selectedSessionID, + toolID: toolID, + answers: answers + ) + } + + func retryMessage(_ text: String) { + let normalized = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalized.isEmpty, !busy, !isSending else { return } + if surface == .remote { + guard remoteSessionSelected, connectionPhase != .disconnected else { return } + isSending = true + busy = true + coreAdapter?.sendRemote(sessionID: selectedSessionID, content: normalized, images: []) + } else { + draft = normalized + send() + } + } + + func openRemoteFile(reference: String, label: String) { + guard surface == .remote, remoteSessionSelected else { + showToast(localized("仅远程工作区文件支持预览")) + return + } + filePreviewLoading = true + coreAdapter?.openRemoteFile( + reference: reference, + label: label, + sessionID: selectedSessionID + ) + } + + func downloadRemoteFile(reference: String, label: String) { + guard surface == .remote, remoteSessionSelected else { return } + downloadTargetPath = reference + .replacingOccurrences(of: "computer://", with: "", options: [.caseInsensitive]) + downloadPhase = .preparing + downloadStatusText = localized("正在准备下载") + coreAdapter?.downloadRemoteFile( + reference: reference, + label: label, + sessionID: selectedSessionID + ) + } + + func finishDownloadExport(success: Bool) { + guard let download = pendingDownload else { return } + if success { + coreAdapter?.remoteDownloadSaved(reference: download.reference) + downloadPhase = .saved + downloadStatusText = localized("已下载") + showToast(localizedFormat("已保存 %@", download.name)) + } else { + coreAdapter?.remoteDownloadSaveFailed(reference: download.reference) + downloadPhase = .failed + downloadStatusText = localized("保存失败") + showToast(localized("文件保存失败")) + } + pendingDownload = nil + downloadExporterOpen = false + } + + func downloadStatus(for remotePath: String) -> String? { + guard downloadTargetPath == remotePath else { return nil } + return downloadStatusText + } + + func dismissFilePreview() { + filePreview = nil + filePreviewLoading = false + coreAdapter?.dismissRemoteFilePreview() + } + + func renameSelectedSession(_ title: String) { + let normalized = title.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalized.isEmpty, selectedSession != nil else { return } + if surface == .remote { + coreAdapter?.renameRemoteSession(sessionID: selectedSessionID, title: normalized) + } else { + coreAdapter?.renameGeneralSession(sessionID: selectedSessionID, title: normalized) + } + } + + func togglePinSelectedSession() { + guard surface == .local, let session = selectedSession else { return } + coreAdapter?.pinGeneralSession(sessionID: session.id, pinned: !session.pinned) + } + + func archiveSelectedSession() { + guard surface == .local, let session = selectedSession else { return } + coreAdapter?.archiveGeneralSession( + sessionID: session.id, + archived: session.status.lowercased() != "archived" + ) + } + + func deleteSelectedSession() { + guard surface == .local, let session = selectedSession else { return } + coreAdapter?.deleteGeneralSession(sessionID: session.id) + localSessionSelected = false + } + + func selectModel(_ modelID: String) { + guard selectedSession != nil else { return } + if surface == .remote { + coreAdapter?.selectRemoteModel(sessionID: selectedSessionID, modelID: modelID) + } else { + coreAdapter?.selectGeneralModel(modelID: modelID) + } + } + + func showUploadedFiles() { + let count = composerImages.count + showToast( + count == 0 + ? localized("当前会话暂无已上传文件") + : localizedFormat("当前会话已上传 %lld 个文件", Int64(count)) + ) + } + + func showToast(_ message: String) { + toastMessage = message + Task { [weak self] in + try? await Task.sleep(nanoseconds: 2_000_000_000) + guard self?.toastMessage == message else { return } + self?.toastMessage = nil + } } private func apply(coreState state: GeneralChatUiState) { + generalConfigured = state.configured + generalConfigBaseURL = state.config.baseUrl + generalConfigModel = state.config.model + generalConfigHasAPIKey = state.config.hasApiKey + generalConfigFailure = state.configFailure?.name + generalConnectionTestRunning = state.connectionTest.running + if state.connectionTest.passed { + generalConnectionTestMessage = localized("连接成功") + } else if let failure = state.connectionTest.failure { + generalConnectionTestMessage = localizedFormat("连接失败:%@", failure.name) + } else { + generalConnectionTestMessage = nil + } + if let exported = state.export { + let safeTitle = exported.title + .replacingOccurrences(of: "/", with: "-") + .replacingOccurrences(of: "\\", with: "-") + .replacingOccurrences(of: ":", with: "-") + .trimmingCharacters(in: .whitespacesAndNewlines) + generalExportName = safeTitle.isEmpty ? "conversation.md" : "\(safeTitle).md" + generalExportData = Data(exported.markdown.utf8) + generalExportOpen = true + } if !state.sessions.isEmpty { sessions = state.sessions.map { session in ChatSession( - id: UUID(uuidString: session.id) ?? UUID(), - title: session.title.isEmpty ? "未命名会话" : session.title, + id: session.id, + title: session.title.isEmpty ? localized("未命名会话") : session.title, updatedLabel: session.updatedAt, pinned: session.pinned, + status: session.status, ) } } @@ -209,55 +1241,120 @@ final class MobileAppModel: ObservableObject { text: text, ) } + timelineRows = messages.map(Self.simpleTimelineRow) } - if draft != state.draft { draft = state.draft } + if !composerModelPickerPreview, draft != state.draft { draft = state.draft } isSending = state.busy - if let failure = state.failure { - coreErrorMessage = failure.name - } else { - coreErrorMessage = nil + busy = state.busy + if !composerModelPickerPreview { + modelOptions = state.models.map { model in + ComposerModelOption( + id: model.id, + primaryLabel: model.label, + secondaryLabel: model.source.name, + source: model.source.name, + selected: model.id == state.activeModelId + ) + } + } + if !accountLoginPreview { + if let failure = state.failure { + coreErrorMessage = failure.name + } else { + coreErrorMessage = nil + } } } private func apply(pairingState state: PairingUiState) { + guard !localActionPreview else { return } pairingBusy = state is PairingUiStateConnecting if let failed = state as? PairingUiStateFailed { pairingBusy = false pairingError = pairingErrorMessage(failed.failure) - } else if state is PairingUiStatePaired { + } else if let paired = state as? PairingUiStatePaired { pairingBusy = false pairingError = nil + directPairingConnected = true + directPairingDeviceName = paired.workspace.roomLabel remoteConnected = true surface = .remote - connectionPhase = .connected + switch paired.liveness { + case .checking: connectionPhase = .reconnecting + case .lost: connectionPhase = .disconnected + default: connectionPhase = .connected + } pairingSheetOpen = false } } private func apply(accountState state: AccountUiState) { + guard !accountLoginPreview, !localActionPreview else { return } accountBusy = state is AccountUiStateSigningIn if let ready = state as? AccountUiStateReady { accountBusy = false accountUser = ready.username + accountUserID = ready.userId accountDeviceName = ready.selectedDeviceName accountDeviceCount = ready.devices.count - if ready.selectedDeviceId == nil, + accountSelectedDeviceID = ready.selectedDeviceId + accountRefreshing = ready.refreshing + accountDevices = ready.devices.map { device in + MobileAccountDevice( + id: device.id, + name: device.name, + online: device.online, + selected: device.id == ready.selectedDeviceId + ) + } + if !directPairingConnected, + ready.selectedDeviceId == nil, let target = ready.devices.first(where: { $0.online }) { accountBusy = true coreAdapter?.selectAccountDevice(id: target.id) return } - remoteConnected = true + remoteConnected = directPairingConnected || ready.selectedDeviceId != nil surface = .remote connectionPhase = .connected + if ready.refreshFailure != nil { + showToast(localized("设备列表刷新失败,仍显示上次结果")) + } } else if let failed = state as? AccountUiStateFailed { accountBusy = false coreErrorMessage = accountErrorMessage(failed.reason.name) - connectionPhase = .disconnected + if !directPairingConnected { connectionPhase = .disconnected } + if failed.reason.name == "AUTHENTICATION" { + accountUser = nil + accountUserID = nil + accountDevices = [] + accountSelectedDeviceID = nil + accountDeviceName = nil + accountDeviceCount = 0 + accountRefreshing = false + remoteConnected = directPairingConnected + } + } else if state is AccountUiStateSignedOut { + accountBusy = false + accountUser = nil + accountUserID = nil + accountDevices = [] + accountSelectedDeviceID = nil + accountDeviceName = nil + accountDeviceCount = 0 + accountRefreshing = false + if !directPairingConnected { + remoteConnected = false + remoteSessionSelected = false + remoteSessions = [] + remoteWorkspaces = [] + workspaceCatalog = [] + } } } private func apply(remoteState state: RemoteSessionUiState) { + guard !localActionPreview, !accountLoginPreview else { return } guard let ready = state as? RemoteSessionUiStateReady else { if let failed = state as? RemoteSessionUiStateFailed { connectionPhase = .disconnected @@ -270,26 +1367,313 @@ final class MobileAppModel: ObservableObject { connectionPhase = .connected remoteSessions = ready.sessions.map { session in ChatSession( - id: UUID(uuidString: session.id) ?? UUID(), - title: session.title.isEmpty ? "未命名会话" : session.title, + id: session.id, + title: session.title.isEmpty ? localized("未命名会话") : session.title, updatedLabel: session.updatedAt, + status: session.status, + agentType: session.agentType, + workspacePath: session.workspacePath, + workspaceName: session.workspaceName, + createdAt: session.createdAt, + messageCount: Int(session.messageCount), ) } - if let selected = ready.selectedSessionId, let id = UUID(uuidString: selected) { - selectedSessionID = id + rebuildRemoteWorkspaceGroups() + if let selected = ready.selectedSessionId { + selectedSessionID = selected } remoteSessionSelected = ready.selectedSessionId != nil - isSending = ready.busy + busy = ready.busy + remoteQuery = ready.query + remoteAgentFilter = ready.agentFilter.name + remoteHasMore = ready.hasMore + remoteHasMoreMessages = ready.hasMoreMessages + remotePermissionMode = ready.permissionMode?.name ?? remotePermissionMode + remotePermissionFailure = ready.permissionModeFailure?.name + activeTurnID = ready.timeline?.activeTurn?.turnId + isSending = ready.timeline?.activeTurn != nil + modelOptions = ready.createModelOptions(fallbackLabel: localized("模型")).map { option in + ComposerModelOption( + id: option.id, + primaryLabel: option.primaryLabel, + secondaryLabel: option.secondaryLabel, + source: "REMOTE", + selected: option.selected + ) + } if let timeline = ready.timeline { - let allMessages = timeline.persistedMessages + timeline.optimisticMessages - messages = allMessages.map { message in - ChatMessage( - id: UUID(uuidString: message.id) ?? UUID(), - role: message.role.lowercased() == "user" ? .user : .assistant, - text: message.text, + timelineRows = timeline.conversationRows().map(Self.mapConversationRow) + messages = timelineRows.compactMap { row in + guard row.kind != "EMPTY" else { return nil } + return ChatMessage( + id: UUID(uuidString: row.id) ?? UUID(), + role: row.kind == "USER" ? .user : .assistant, + text: row.text + ) + } + } else { + timelineRows = [] + messages = [] + } + } + + private func apply(workspaceState state: RemoteWorkspaceUiState) { + workspaceLoading = state is RemoteWorkspaceUiStateLoading + workspaceLoadFailed = state is RemoteWorkspaceUiStateFailed + if state is RemoteWorkspaceUiStateFailed { + if pendingRemoteWorkspaceCreate != nil || pendingRemoteAssistantCreate { + pendingRemoteWorkspaceCreate = nil + pendingRemoteAssistantCreate = false + showToast(localized("工作区加载失败,点按重试")) + } + return + } + guard let ready = state as? RemoteWorkspaceUiStateReady else { return } + + workspaceLoading = false + workspaceLoadFailed = false + selectedRemoteWorkspaceKind = ready.selected?.kind ?? "" + var seen = Set() + var catalog: [(path: String, name: String, selected: Bool)] = [] + if let selected = ready.selected, + !selected.path.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + seen.insert(selected.path) + catalog.append((selected.path, selected.name, true)) + } + for workspace in ready.workspaces where !workspace.path.isEmpty && !seen.contains(workspace.path) { + seen.insert(workspace.path) + catalog.append((workspace.path, workspace.name, false)) + } + workspaceCatalog = catalog + remoteAssistants = ready.assistants.map { + MobileAssistantOption(path: $0.path, name: $0.name) + } + rebuildRemoteWorkspaceGroups() + apply(filePreviewState: ready.preview) + apply(downloadState: ready.download) + if let pending = pendingRemoteWorkspaceCreate, + ready.selected?.path == pending.path { + pendingRemoteWorkspaceCreate = nil + createRemoteSession(agentType: pending.agentType, title: "", instruction: "") + } + if pendingRemoteAssistantCreate, + ready.selected?.kind.lowercased() == "assistant" { + pendingRemoteAssistantCreate = false + createRemoteSession(agentType: "Claw", title: "", instruction: "") + } + } + + private func apply(downloadState state: RemoteFileDownloadUiState) { + if state is RemoteFileDownloadUiStateNone { return } + if let loading = state as? RemoteFileDownloadUiStateLoading { + downloadTargetPath = loading.target.remotePath + downloadPhase = .downloading + if loading.totalBytes > 0 { + downloadStatusText = localizedFormat( + "正在下载 %@ / %@", + FilePreviewFormat.shared.bytes(value: loading.downloadedBytes), + FilePreviewFormat.shared.bytes(value: loading.totalBytes) + ) + } else { + downloadStatusText = localized("正在下载") + } + } else if let awaiting = state as? RemoteFileDownloadUiStateAwaitingSave { + let reference = awaiting.target.path + downloadTargetPath = awaiting.target.remotePath + downloadPhase = .saving + downloadStatusText = localized("正在保存") + if pendingDownload?.reference != reference { + pendingDownload = MobilePendingDownload( + reference: reference, + remotePath: awaiting.target.remotePath, + name: awaiting.name, + mimeType: awaiting.mimeType, + data: Self.data(from: awaiting.bytes) ) + downloadExporterOpen = true } + } else if let saved = state as? RemoteFileDownloadUiStateSaved { + downloadTargetPath = saved.target.remotePath + downloadPhase = .saved + downloadStatusText = localized("已下载") + } else if let failed = state as? RemoteFileDownloadUiStateFailed { + downloadTargetPath = failed.target.remotePath + downloadPhase = .failed + downloadStatusText = localized("下载失败") + } + } + + private func apply(filePreviewState state: RemoteFilePreviewUiState) { + if let loading = state as? RemoteFilePreviewUiStateLoading { + filePreviewLoading = true + filePreview = MobileFilePreview( + id: loading.target.remotePath, + name: loading.target.displayName, + content: "", + mimeType: "", + imageData: nil, + truncated: false, + failure: nil + ) + return + } + filePreviewLoading = false + if state is RemoteFilePreviewUiStateNone { + filePreview = nil + } else if let text = state as? RemoteFilePreviewUiStateText { + filePreview = MobileFilePreview( + id: text.target.remotePath, + name: text.name, + content: text.content, + mimeType: text.mimeType, + imageData: nil, + truncated: text.truncated, + failure: nil + ) + } else if let image = state as? RemoteFilePreviewUiStateImage { + filePreview = MobileFilePreview( + id: image.target.remotePath, + name: image.name, + content: "", + mimeType: image.mimeType, + imageData: Self.data(from: image.bytes), + truncated: false, + failure: nil + ) + } else if let unsupported = state as? RemoteFilePreviewUiStateUnsupported { + filePreview = MobileFilePreview( + id: unsupported.target.remotePath, + name: unsupported.target.displayName, + content: "", + mimeType: unsupported.mimeType, + imageData: nil, + truncated: false, + failure: localized("此文件类型暂不支持预览") + ) + } else if let failed = state as? RemoteFilePreviewUiStateFailed { + filePreview = MobileFilePreview( + id: failed.target.remotePath, + name: failed.target.displayName, + content: "", + mimeType: failed.mimeType, + imageData: nil, + truncated: false, + failure: localizedFormat("文件预览失败:%@", failed.kind.name) + ) + } + } + + private func rebuildRemoteWorkspaceGroups() { + let selectedPath = workspaceCatalog.first(where: { $0.selected })?.path + remoteWorkspaces = workspaceCatalog.map { workspace in + MobileWorkspaceGroup( + path: workspace.path, + name: workspace.name.isEmpty ? workspace.path : workspace.name, + selected: workspace.selected, + sessions: remoteSessions.filter { session in + (session.workspacePath ?? selectedPath) == workspace.path + } + ) + } + } + + private static func simpleTimelineRow(_ message: ChatMessage) -> MobileConversationRow { + simpleTimelineRow(message, images: []) + } + + private static func simpleTimelineRow( + _ message: ChatMessage, + images: [ComposerAttachment] + ) -> MobileConversationRow { + MobileConversationRow( + id: message.id.uuidString, + kind: message.role == .user ? "USER" : "ASSISTANT", + text: message.text, + thinking: nil, + images: images.map { + MobileTimelineImage(name: "image", dataURL: $0.dataURL) + }, + tools: [], + blocks: [], + streaming: false, + typing: false, + pending: false, + showRetry: false + ) + } + + private static func mapConversationRow(_ row: ConversationRow) -> MobileConversationRow { + MobileConversationRow( + id: row.id, + kind: row.kind.name, + text: row.text, + thinking: row.thinking, + images: row.images.map { + MobileTimelineImage(name: $0.name, dataURL: $0.dataUrl) + }, + tools: row.tools.map(mapTool), + blocks: row.blocks.map(mapBlock), + streaming: row.streaming, + typing: row.typing, + pending: row.pending, + showRetry: row.showRetry + ) + } + + private static func mapTool(_ tool: ToolCard) -> MobileTimelineTool { + MobileTimelineTool( + id: tool.id, + name: tool.name, + phase: tool.phase.name, + kind: tool.kind.name, + operation: tool.operation.name, + target: tool.target, + filePath: tool.filePath, + fileLabel: tool.fileLabel, + input: tool.input, + output: tool.output, + question: tool.question, + questions: tool.questions.map { question in + MobileTimelineQuestion( + index: Int(question.index), + header: question.header, + question: question.question, + options: question.options.map { + MobileTimelineOption(label: $0.label, description: $0.description_) + }, + multiSelect: question.multiSelect + ) + }, + actions: Set(tool.actions.map(\.name)) + ) + } + + private static func mapBlock(_ block: MessageBlock) -> MobileTimelineBlock { + if let text = block as? MessageBlockText { + return .text(id: text.id, text: text.text, streaming: text.streaming) } + if let thinking = block as? MessageBlockThinking { + return .thinking(id: thinking.id, text: thinking.text, streaming: thinking.streaming) + } + if let tools = block as? MessageBlockTools { + return .tools(id: tools.id, tools: tools.tools.map(mapTool)) + } + if let subagent = block as? MessageBlockSubagent { + return .subagent( + id: subagent.id, + title: subagent.title, + running: subagent.running, + text: subagent.text, + children: subagent.children.map(mapBlock) + ) + } + return .text(id: block.id, text: "", streaming: false) + } + + private static func data(from bytes: KotlinByteArray) -> Data { + Data((0.. String { @@ -298,39 +1682,167 @@ final class MobileAppModel: ObservableObject { } switch failure.reason.name { case "PAIRING_LINK_EMPTY", "PAIRING_LINK_INCOMPLETE", "PAIRING_LINK_UNDECODABLE", "PAIRING_LINK_KEY_UNUSABLE": - return "连接链接无效,请重新扫描或粘贴桌面端链接" + return localized("连接链接无效,请重新扫描或粘贴桌面端链接") case "ACCOUNT_USERNAME_REQUIRED": - return "请输入桌面端账号" + return localized("请输入桌面端账号") case "ACCOUNT_PASSWORD_REQUIRED": - return "请输入桌面端密码" + return localized("请输入桌面端密码") case "REJECTED", "DESKTOP_REJECTED": - return "桌面端拒绝了这次连接" + return localized("桌面端拒绝了这次连接") case "ROOM_NOT_FOUND": - return "找不到桌面端房间,请确认桌面端仍在等待连接" + return localized("找不到桌面端房间,请确认桌面端仍在等待连接") case "RATE_LIMITED", "TOO_MANY_ATTEMPTS": - return "尝试次数过多,请稍后再试" + return localized("尝试次数过多,请稍后再试") case "RELAY_UNAVAILABLE", "NETWORK_UNREACHABLE": - return "网络不可用,请检查手机与桌面端的网络" + return localized("网络不可用,请检查手机与桌面端的网络") case "TIMEOUT": - return "连接超时,请重新尝试" + return localized("连接超时,请重新尝试") case "PROTOCOL_MISMATCH": - return "桌面端版本不兼容,请升级后重试" + return localized("桌面端版本不兼容,请升级后重试") default: - return "连接失败,请检查桌面端链接" + return localized("连接失败,请检查桌面端链接") } } private func accountErrorMessage(_ reason: String) -> String { switch reason { case "INVALID_CREDENTIALS", "UNAUTHORIZED": - return "账号或密码错误" + return localized("账号或密码错误") case "NETWORK": - return "网络不可用,请检查 relay 地址" + return localized("网络不可用,请检查 relay 地址") case "TIMEOUT": - return "登录超时,请稍后重试" + return localized("登录超时,请稍后重试") default: - return "登录失败,请检查账号、密码和 relay 地址" + return localized("登录失败,请检查账号、密码和 relay 地址") + } + } +} + +extension MobileAppModel { + var sessionListWorkspaceOptions: [MobileSessionWorkspaceOption] { + SessionListPresentation.shared + .workspaceOptions(sessions: sessionListCoreSessions, workspace: sessionListWorkspaceContext) + .map { MobileSessionWorkspaceOption(path: $0.path, name: $0.name) } + } + + var sessionListAgentGroups: [String] { + SessionListPresentation.shared + .agentGroups(sessions: sessionListCoreSessions, workspace: sessionListWorkspaceContext) + .map(\.name) + } + + var sessionListStatusOptions: [String] { + SessionListPresentation.shared.statusOptions(sessions: sessionListCoreSessions) + } + + var sessionListSections: [MobileSessionListSectionProjection] { + let groupMode: SessionGroupMode = switch remoteGroupMode { + case "TIME": .time + case "CHAT": .chat + default: .project + } + let agentFilter: SessionAgentGroup? = switch remoteViewAgentFilter { + case "CHAT": .chat + case "CODE": .code + case "COWORK": .cowork + default: nil + } + let view = SessionListPresentation.shared.view( + sessions: sessionListCoreSessions, + workspace: sessionListWorkspaceContext, + options: SessionListOptions( + groupMode: groupMode, + query: "", + workspaceFilter: remoteWorkspaceFilter, + agentFilter: agentFilter, + statusFilter: remoteStatusFilter + ), + nowMs: Int64(Date().timeIntervalSince1970 * 1_000) + ) + let byID = Dictionary(uniqueKeysWithValues: remoteSessions.map { ($0.id, $0) }) + return view.sections.compactMap { section in + switch onEnum(of: section) { + case .chat(let value): + return projection(id: "chat", kind: .chat, section: value, byID: byID) + case .project(let value): + return MobileSessionListSectionProjection( + id: "project:\(value.path)", + kind: .project, + path: value.path, + name: value.name, + sessions: value.sessions.compactMap { byID[$0.id] } + ) + case .today(let value): + return projection(id: "today", kind: .today, section: value, byID: byID) + case .yesterday(let value): + return projection(id: "yesterday", kind: .yesterday, section: value, byID: byID) + case .earlier(let value): + return projection(id: "earlier", kind: .earlier, section: value, byID: byID) + } + } + } + + private var sessionListCoreSessions: [RemoteSession] { + remoteSessions.map { session in + RemoteSession( + id: session.id, + title: session.title, + agentType: session.agentType, + status: session.status, + updatedAt: session.updatedLabel, + createdAt: session.createdAt, + messageCount: Int32(session.messageCount), + workspacePath: session.workspacePath, + workspaceName: session.workspaceName + ) + } + } + + private var sessionListWorkspaceContext: SessionWorkspaceContext { + let assistantPaths = Set(remoteAssistants.map { normalizedSessionWorkspacePath($0.path) }) + let selected = remoteWorkspaces.first(where: \.selected) + let recent = remoteWorkspaces.map { workspace in + RecentWorkspace( + path: workspace.path, + name: workspace.name, + lastOpened: "", + kind: assistantPaths.contains(normalizedSessionWorkspacePath(workspace.path)) + ? "assistant" + : "normal" + ) + } + let selectedKind = selected.map { + assistantPaths.contains(normalizedSessionWorkspacePath($0.path)) ? "assistant" : "normal" + } ?? "" + return SessionWorkspaceContext( + selectedPath: selected?.path ?? "", + selectedName: selected?.name ?? "", + selectedKind: selectedKind, + recent: recent + ) + } + + private func projection( + id: String, + kind: MobileSessionListSectionKind, + section: any SessionListSection, + byID: [String: ChatSession] + ) -> MobileSessionListSectionProjection { + MobileSessionListSectionProjection( + id: id, + kind: kind, + path: "", + name: "", + sessions: section.sessions.compactMap { byID[$0.id] } + ) + } + + private func normalizedSessionWorkspacePath(_ path: String) -> String { + var result = path.trimmingCharacters(in: .whitespacesAndNewlines) + while result.count > 1 && (result.hasSuffix("/") || result.hasSuffix("\\")) { + result.removeLast() } + return result } } diff --git a/src/apps/mobile/ios/BitFun/Infrastructure/MobileCoreAdapter.swift b/src/apps/mobile/ios/BitFun/Infrastructure/MobileCoreAdapter.swift index 125f6586ff..4dab9fdf95 100644 --- a/src/apps/mobile/ios/BitFun/Infrastructure/MobileCoreAdapter.swift +++ b/src/apps/mobile/ios/BitFun/Infrastructure/MobileCoreAdapter.swift @@ -4,23 +4,29 @@ import Foundation /// Swift owns presentation state; this adapter owns the shared KMP feature seam. @MainActor final class MobileCoreAdapter { + let deviceID: String private let scope: any CoroutineScope private let generalChat: GeneralChatStore private let pairing: PairingStore private let account: AccountStore private var remoteSession: RemoteSessionStore? + private var remoteWorkspace: RemoteWorkspaceStore? + private var remoteTargetKey: String? private var observations: [Task] = [] + private var remoteObservations: [Task] = [] var onState: ((GeneralChatUiState) -> Void)? var onPairingState: ((PairingUiState) -> Void)? var onAccountState: ((AccountUiState) -> Void)? var onRemoteState: ((RemoteSessionUiState) -> Void)? + var onWorkspaceState: ((RemoteWorkspaceUiState) -> Void)? init( onState: ((GeneralChatUiState) -> Void)? = nil, onPairingState: ((PairingUiState) -> Void)? = nil, onAccountState: ((AccountUiState) -> Void)? = nil, onRemoteState: ((RemoteSessionUiState) -> Void)? = nil, + onWorkspaceState: ((RemoteWorkspaceUiState) -> Void)? = nil, ) { self.scope = MainScope() self.generalChat = GeneralChatStore.companion.create(scope: scope) @@ -32,6 +38,7 @@ final class MobileCoreAdapter { installID = UUID().uuidString defaults.set(installID, forKey: "bitfun.mobile.install_id") } + self.deviceID = installID self.pairing = PairingStore.companion.create( scope: scope, device: DeviceIdentity(installId: installID, displayName: "BitFun iPhone"), @@ -48,6 +55,7 @@ final class MobileCoreAdapter { self.onPairingState = onPairingState self.onAccountState = onAccountState self.onRemoteState = onRemoteState + self.onWorkspaceState = onWorkspaceState let flow = SkieSwiftStateFlow(generalChat.state) onState?(flow.value) @@ -81,6 +89,9 @@ final class MobileCoreAdapter { } } }) + + account.dispatch(intent: AccountIntentRestore.shared) + pairing.dispatch(intent: PairingIntentForeground.shared) } func updateDraft(_ text: String) { @@ -91,10 +102,103 @@ final class MobileCoreAdapter { generalChat.dispatch(intent: GeneralChatIntentSend.shared) } + func cancelGeneralChat() { + generalChat.dispatch(intent: GeneralChatIntentCancel.shared) + } + + func setGeneralChatImages(_ images: [ComposerAttachment]) { + generalChat.dispatch(intent: GeneralChatIntentSetImages(images: images.map(\.coreImage))) + } + + func renameGeneralSession(sessionID: String, title: String) { + generalChat.dispatch(intent: GeneralChatIntentRenameSession(sessionId: sessionID, title: title)) + } + + func pinGeneralSession(sessionID: String, pinned: Bool) { + generalChat.dispatch(intent: GeneralChatIntentPinSession(sessionId: sessionID, pinned: pinned)) + } + + func archiveGeneralSession(sessionID: String, archived: Bool) { + generalChat.dispatch(intent: GeneralChatIntentArchiveSession(sessionId: sessionID, archived: archived)) + } + + func deleteGeneralSession(sessionID: String) { + generalChat.dispatch(intent: GeneralChatIntentDeleteSession(sessionId: sessionID)) + } + + func selectGeneralModel(modelID: String) { + generalChat.dispatch(intent: GeneralChatIntentSelectModel(modelId: modelID)) + } + + func selectGeneralSession(sessionID: String) { + generalChat.dispatch(intent: GeneralChatIntentSelectSession(sessionId: sessionID)) + } + + func saveGeneralConfig(baseURL: String, model: String, apiKey: String, clearAPIKey: Bool) { + generalChat.dispatch( + intent: GeneralChatIntentSaveConfig( + baseUrl: baseURL, model: model, apiKey: apiKey, clearApiKey: clearAPIKey + ) + ) + } + + func testGeneralConnection(baseURL: String, model: String, apiKey: String, clearAPIKey: Bool) { + generalChat.dispatch( + intent: GeneralChatIntentTestConnection( + baseUrl: baseURL, model: model, apiKey: apiKey, clearApiKey: clearAPIKey + ) + ) + } + + func exportGeneralSession(sessionID: String) { + generalChat.dispatch( + intent: GeneralChatIntentExportSession( + sessionId: sessionID, + untitledLabel: "未命名会话", + userLabel: "用户", + assistantLabel: "BitFun" + ) + ) + } + + func clearGeneralExport() { + generalChat.dispatch(intent: GeneralChatIntentClearExport.shared) + } + + func newGeneralSession() { + generalChat.dispatch(intent: GeneralChatIntentNewSession.shared) + } + func submitPairing(url: String) { pairing.dispatch(intent: PairingIntentSubmit(pairingUrl: url)) } + func submitPairing(url: String, userID: String, password: String) { + pairing.dispatch( + intent: PairingIntentSubmit( + pairingUrl: url, + userId: userID, + password: password + ) + ) + } + + func dismissPairingFailure() { + pairing.dispatch(intent: PairingIntentDismiss.shared) + } + + func pairingForeground() { + pairing.dispatch(intent: PairingIntentForeground.shared) + } + + func pairingBackground() { + pairing.dispatch(intent: PairingIntentBackground.shared) + } + + func verifyPairing() { + pairing.dispatch(intent: PairingIntentVerify.shared) + } + func loginAccount(relayURL: String, username: String, password: String) { account.dispatch(intent: AccountIntentLogin(relayUrl: relayURL, username: username, password: password)) } @@ -103,51 +207,264 @@ final class MobileCoreAdapter { account.dispatch(intent: AccountIntentSelectDevice(deviceId: id)) } + func refreshAccountDevices() { + account.dispatch(intent: AccountIntentRefreshDevices.shared) + } + + func logoutAccount() { + resetRemoteStores() + account.dispatch(intent: AccountIntentLogout.shared) + } + func disconnect() { pairing.dispatch(intent: PairingIntentDisconnect.shared) - remoteSession?.dispatch(intent: RemoteSessionIntentStop.shared) - remoteSession = nil + resetRemoteStores() + } + + func sendRemote(sessionID: String, content: String, images: [ComposerAttachment]) { + remoteSession?.dispatch( + intent: RemoteSessionIntentSendMessage( + sessionId: sessionID, + content: content, + images: images.isEmpty ? nil : images.map(\.coreImage), + ) + ) + } + + func cancelRemoteTurn(sessionID: String, turnID: String?) { + remoteSession?.dispatch( + intent: RemoteSessionIntentCancelTurn(sessionId: sessionID, turnId: turnID) + ) + } + + func approveRemoteTool(sessionID: String, toolID: String) { + remoteSession?.dispatch( + intent: RemoteSessionIntentApproveTool(sessionId: sessionID, toolId: toolID) + ) + } + + func rejectRemoteTool(sessionID: String, toolID: String, reason: String) { + remoteSession?.dispatch( + intent: RemoteSessionIntentRejectTool(sessionId: sessionID, toolId: toolID, reason: reason) + ) + } + + func cancelRemoteTool(sessionID: String, toolID: String, reason: String) { + remoteSession?.dispatch( + intent: RemoteSessionIntentCancelTool(sessionId: sessionID, toolId: toolID, reason: reason) + ) + } + + func answerRemoteTool(sessionID: String, toolID: String, answer: String) { + remoteSession?.dispatch( + intent: RemoteSessionIntentAnswerQuestion(sessionId: sessionID, toolId: toolID, answer: answer) + ) + } + + func answerRemoteToolStructured(sessionID: String, toolID: String, answers: [QuestionAnswer]) { + remoteSession?.dispatch( + intent: RemoteSessionIntentAnswerStructuredQuestion( + sessionId: sessionID, + toolId: toolID, + answers: answers + ) + ) + } + + func renameRemoteSession(sessionID: String, title: String) { + remoteSession?.dispatch( + intent: RemoteSessionIntentRenameSession(sessionId: sessionID, title: title) + ) + } + + func selectRemoteModel(sessionID: String, modelID: String) { + remoteSession?.dispatch( + intent: RemoteSessionIntentSelectModel(sessionId: sessionID, modelId: modelID) + ) + } + + func openRemoteSession(sessionID: String) { + remoteSession?.dispatch(intent: RemoteSessionIntentOpen(sessionId: sessionID)) + } + + func createRemoteSession( + agentType: String, + title: String, + instruction: String, + modelID: String?, + workspacePath: String? = nil + ) { + remoteSession?.dispatch( + intent: RemoteSessionIntentCreateSession( + agentType: agentType, + title: title, + instruction: instruction, + modelId: modelID, + workspacePath: workspacePath + ) + ) + } + + func deleteRemoteSession(sessionID: String) { + remoteSession?.dispatch(intent: RemoteSessionIntentDeleteSession(sessionId: sessionID)) + } + + func searchRemoteSessions(query: String) { + remoteSession?.dispatch(intent: RemoteSessionIntentSearch(query: query)) + } + + func loadMoreRemoteSessions() { + remoteSession?.dispatch(intent: RemoteSessionIntentLoadMore.shared) + } + + func loadOlderRemoteMessages() { + remoteSession?.dispatch(intent: RemoteSessionIntentLoadOlderMessages.shared) + } + + func refreshRemoteSessions() { + remoteSession?.dispatch(intent: RemoteSessionIntentRefresh.shared) + } + + func setRemoteAgentFilter(_ filter: SessionAgentFilter) { + remoteSession?.dispatch(intent: RemoteSessionIntentSetAgentFilter(filter: filter)) + } + + func refreshRemotePermissionMode() { + remoteSession?.dispatch(intent: RemoteSessionIntentRefreshPermissionMode.shared) + } + + func setRemotePermissionMode(_ mode: SessionPermissionMode) { + remoteSession?.dispatch(intent: RemoteSessionIntentSetPermissionMode(mode: mode)) + } + + func selectRemoteWorkspace(path: String) { + remoteWorkspace?.dispatch(intent: RemoteWorkspaceIntentSelectWorkspace(path: path)) + } + + func selectRemoteAssistant(path: String) { + remoteWorkspace?.dispatch(intent: RemoteWorkspaceIntentSelectAssistant(path: path)) + } + + func loadRemoteWorkspaces() { + remoteWorkspace?.dispatch(intent: RemoteWorkspaceIntentLoad.shared) + } + + func openRemoteFile(reference: String, label: String, sessionID: String) { + remoteWorkspace?.dispatch( + intent: RemoteWorkspaceIntentOpenFile( + reference: reference, + label: label, + sessionId: sessionID + ) + ) } - func sendRemote(sessionID: String, content: String) { - remoteSession?.dispatch(intent: RemoteSessionIntentSendMessage(sessionId: sessionID, content: content)) + func downloadRemoteFile(reference: String, label: String, sessionID: String) { + remoteWorkspace?.dispatch( + intent: RemoteWorkspaceIntentDownloadFile( + reference: reference, + label: label, + sessionId: sessionID + ) + ) + } + + func remoteDownloadSaved(reference: String) { + remoteWorkspace?.dispatch( + intent: RemoteWorkspaceIntentDownloadSaved(reference: reference) + ) + } + + func remoteDownloadSaveFailed(reference: String) { + remoteWorkspace?.dispatch( + intent: RemoteWorkspaceIntentDownloadSaveFailed(reference: reference) + ) + } + + func dismissRemoteFilePreview() { + remoteWorkspace?.dispatch(intent: RemoteWorkspaceIntentDismissPreview.shared) } private func startRemoteSessionStoreIfNeeded(paired: PairingUiStatePaired) { - guard remoteSession == nil, let store = pairing.createSessionStore(scope: scope) else { return } - remoteSession = store - let flow = SkieSwiftStateFlow(store.state) - onRemoteState?(flow.value) - store.dispatch(intent: RemoteSessionIntentLoad.shared) - observations.append(Task { [weak self] in - for await state in flow { - guard !Task.isCancelled else { return } - self?.onRemoteState?(state) - } - }) + guard remoteTargetKey != "pairing", + let sessionStore = pairing.createSessionStore(scope: scope) else { return } + bindRemoteStores( + targetKey: "pairing", + sessionStore: sessionStore, + workspaceStore: pairing.createWorkspaceStore(scope: scope) + ) } private func startAccountRemoteSessionIfNeeded(ready: AccountUiStateReady) { - guard remoteSession == nil, let store = account.createSessionStore(scope: scope) else { return } - remoteSession = store - let flow = SkieSwiftStateFlow(store.state) - onRemoteState?(flow.value) - store.dispatch(intent: RemoteSessionIntentLoad.shared) - observations.append(Task { [weak self] in - for await state in flow { + guard let deviceID = ready.selectedDeviceId else { + resetRemoteStores() + return + } + let targetKey = "account:\(deviceID)" + guard remoteTargetKey != targetKey, + let sessionStore = account.createSessionStore(scope: scope) else { return } + bindRemoteStores( + targetKey: targetKey, + sessionStore: sessionStore, + workspaceStore: account.createWorkspaceStore(scope: scope) + ) + } + + private func bindRemoteStores( + targetKey: String, + sessionStore: RemoteSessionStore, + workspaceStore: RemoteWorkspaceStore? + ) { + resetRemoteStores() + remoteTargetKey = targetKey + remoteSession = sessionStore + remoteWorkspace = workspaceStore + + let sessionFlow = SkieSwiftStateFlow(sessionStore.state) + onRemoteState?(sessionFlow.value) + sessionStore.dispatch(intent: RemoteSessionIntentLoad.shared) + remoteObservations.append(Task { [weak self] in + for await state in sessionFlow { guard !Task.isCancelled else { return } self?.onRemoteState?(state) } }) + + if let workspaceStore { + let workspaceFlow = SkieSwiftStateFlow(workspaceStore.state) + onWorkspaceState?(workspaceFlow.value) + workspaceStore.dispatch(intent: RemoteWorkspaceIntentLoad.shared) + remoteObservations.append(Task { [weak self] in + for await state in workspaceFlow { + guard !Task.isCancelled else { return } + self?.onWorkspaceState?(state) + } + }) + } + } + + private func resetRemoteStores() { + remoteObservations.forEach { $0.cancel() } + remoteObservations.removeAll() + remoteSession?.dispatch(intent: RemoteSessionIntentStop.shared) + remoteWorkspace?.dispatch(intent: RemoteWorkspaceIntentStop.shared) + remoteSession = nil + remoteWorkspace = nil + remoteTargetKey = nil } func stop() { observations.forEach { $0.cancel() } observations.removeAll() - remoteSession?.stop() - remoteSession = nil + resetRemoteStores() pairing.dispatch(intent: PairingIntentDisconnect.shared) account.stop() generalChat.stop() } } + +private extension ComposerAttachment { + var coreImage: ComposerImage { + ComposerImage(id: id, dataUrl: dataURL, mimeType: mimeType) + } +} diff --git a/src/apps/mobile/ios/BitFun/Infrastructure/MobileLocalization.swift b/src/apps/mobile/ios/BitFun/Infrastructure/MobileLocalization.swift new file mode 100644 index 0000000000..91224e08f9 --- /dev/null +++ b/src/apps/mobile/ios/BitFun/Infrastructure/MobileLocalization.swift @@ -0,0 +1,49 @@ +import Foundation + +enum MobileLanguage: String, CaseIterable, Identifiable { + case simplifiedChinese = "zh-Hans" + case english = "en" + + var id: String { rawValue } + + var nativeName: String { + switch self { + case .simplifiedChinese: return "简体中文" + case .english: return "English" + } + } +} + +enum MobileLocalization { + static let preferenceKey = "bitfun.mobile.language" + + static func restoredLanguage() -> MobileLanguage { + if let saved = UserDefaults.standard.string(forKey: preferenceKey), + let language = MobileLanguage(rawValue: saved) { + return language + } + return Locale.preferredLanguages.first?.hasPrefix("zh") == true ? .simplifiedChinese : .english + } + + static func text(_ key: String, language: MobileLanguage) -> String { + // The catalog's source language is Simplified Chinese and UI call sites + // use those source strings as stable keys. Asking Foundation to resolve + // an untranslated source key with an English development region can + // still fall through to the English localization, so keep the source + // language explicit instead of relying on Bundle fallback order. + if language == .simplifiedChinese { return key } + return String(localized: String.LocalizationValue(key), locale: Locale(identifier: language.rawValue)) + } + + static func text(_ key: String) -> String { + text(key, language: restoredLanguage()) + } + + static func format(_ key: String, language: MobileLanguage, _ arguments: CVarArg...) -> String { + String( + format: text(key, language: language), + locale: Locale(identifier: language.rawValue), + arguments: arguments + ) + } +} diff --git a/src/apps/mobile/ios/BitFun/Resources/Localizable.xcstrings b/src/apps/mobile/ios/BitFun/Resources/Localizable.xcstrings new file mode 100644 index 0000000000..47bb52b80f --- /dev/null +++ b/src/apps/mobile/ios/BitFun/Resources/Localizable.xcstrings @@ -0,0 +1,2688 @@ +{ + "sourceLanguage": "zh-Hans", + "strings": { + "BitFun 桌面版": { + "localizations": { "en": { "stringUnit": { "state": "translated", "value": "BitFun Desktop" } } } + }, + "不再询问,允许桌面端执行所有操作。": { + "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Allow the desktop to perform all operations without asking." } } } + }, + "其他": { + "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Other" } } } + }, + "其他连接方式": { + "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Other ways to connect" } } } + }, + "启用完全访问": { + "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Enable full access" } } } + }, + "尚未连接桌面端": { + "localizations": { "en": { "stringUnit": { "state": "translated", "value": "No desktop connected yet" } } } + }, + "当前远程控制": { + "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Current remote control" } } } + }, + "执行需要授权的操作前先询问。": { + "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Ask before performing an operation that requires approval." } } } + }, + "扫码配对": { + "localizations": { "en": { "stringUnit": { "state": "translated", "value": "QR pairing" } } } + }, + "扫描二维码连接": { + "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Scan QR code to connect" } } } + }, + "控制桌面端执行工具时采用的确认方式。": { + "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Choose how the desktop confirms tool operations." } } } + }, + "断开": { + "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Disconnect" } } } + }, + "未连接": { + "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Not connected" } } } + }, + "正在重新连接": { + "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Reconnecting" } } } + }, + "完全访问会取消所有操作确认。仅在你信任当前桌面端时启用。": { + "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Full access removes all operation confirmations. Enable it only when you trust this desktop." } } } + }, + "确认完全访问": { + "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Confirm full access" } } } + }, + "自动允许": { + "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Allow automatically" } } } + }, + "自动允许常规操作,高风险操作仍会询问。": { + "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Allow routine operations automatically and still ask for high-risk operations." } } } + }, + "账号设备": { + "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Account device" } } } + }, + "连接已断开": { + "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Connection lost" } } } + }, + "连接来源": { + "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Connection source" } } } + }, + "适用于临时配对或未登录账号的桌面端。": { + "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Use this for a temporary pair or a desktop that is not signed in." } } } + }, + "远程控制": { + "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Remote control" } } } + }, + "远程控制设置": { + "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Remote control settings" } } } + }, + "重新连接": { + "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Reconnect" } } } + }, + "会话详情": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Session details" + } + } + } + }, + "删除后无法恢复此会话,是否继续?": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This session cannot be recovered after deletion. Continue?" + } + } + } + }, + "工作区路径": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Workspace path" + } + } + } + }, + "已归档": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Archived" + } + } + } + }, + "执行中": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Running" + } + } + } + }, + "收起侧栏": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Collapse sidebar" + } + } + } + }, + "未知": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Unknown" + } + } + } + }, + "API Key(留空则保留)": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "API Key (leave blank to keep saved key)" + } + } + } + }, + "BitFun iOS版": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "BitFun for iOS" + } + } + } + }, + "English": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "English" + } + } + } + }, + "三端的组件和样式可以保持一致吗?": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Can components and styles stay consistent across all three platforms?" + } + } + } + }, + "下载失败": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Download failed" + } + } + } + }, + "个人资料": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Profile" + } + } + } + }, + "产品": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Product" + } + } + } + }, + "仅远程工作区文件支持预览": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Only files in a remote workspace can be previewed" + } + } + } + }, + "从剪贴板粘贴": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Paste from clipboard" + } + } + } + }, + "从这里开始新的对话": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Start a new conversation here" + } + } + } + }, + "代码": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Code" + } + } + } + }, + "代码审查助手": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Code review assistant" + } + } + } + }, + "会话": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Session" + } + } + } + }, + "会话操作": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Session actions" + } + } + } + }, + "会话标题": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Session title" + } + } + } + }, + "会话标题(可选)": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Session title (optional)" + } + } + } + }, + "会话类型": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Session type" + } + } + } + }, + "你好": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Hello" + } + } + } + }, + "保存": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Save" + } + } + } + }, + "保存失败": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Save failed" + } + } + } + }, + "停止": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Stop" + } + } + } + }, + "停止听写": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Stop dictation" + } + } + } + }, + "停止执行": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Stop task" + } + } + } + }, + "允许": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Allow" + } + } + } + }, + "允许 Agent 直接执行操作": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Allow the agent to perform operations directly" + } + } + } + }, + "先对照 HarmonyOS 的消息顺序与工具状态,再核对 Android 的交互策略。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Compare the HarmonyOS message order and tool states, then verify Android interactions." + } + } + } + }, + "全部": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "All" + } + } + } + }, + "关于": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "About" + } + } + } + }, + "关闭": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Close" + } + } + } + }, + "关闭文件预览": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Close file preview" + } + } + } + }, + "写入文件": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Write file" + } + } + } + }, + "刚刚": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Just now" + } + } + } + }, + "创建会话": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Create session" + } + } + } + }, + "删除": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Delete" + } + } + } + }, + "删除文件": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Delete file" + } + } + } + }, + "制定行动计划": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Make an action plan" + } + } + } + }, + "刷新设备": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Refresh devices" + } + } + } + }, + "刷新远程会话": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Refresh remote sessions" + } + } + } + }, + "加载更多会话": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Load more sessions" + } + } + } + }, + "加载更早消息": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Load older messages" + } + } + } + }, + "协作": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cowork" + } + } + } + }, + "发送": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Send" + } + } + } + }, + "发送回复": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Send reply" + } + } + } + }, + "取消": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cancel" + } + } + } + }, + "取消归档": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Unarchive" + } + } + } + }, + "取消置顶": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Unpin" + } + } + } + }, + "可以。共享视觉契约,三端继续使用原生渲染。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Yes. Share the visual contract while keeping native rendering on each platform." + } + } + } + }, + "向 BitFun 提问": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Ask BitFun" + } + } + } + }, + "启动子任务": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Start subtask" + } + } + } + }, + "告诉 Agent 要做什么": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Tell the agent what to do" + } + } + } + }, + "回复": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reply" + } + } + } + }, + "在线": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Online" + } + } + } + }, + "复制": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Copy" + } + } + } + }, + "子任务": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Subtask" + } + } + } + }, + "完全访问": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Full access" + } + } + } + }, + "导出会话": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Export session" + } + } + } + }, + "尚未连接桌面设备": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No desktop connected" + } + } + } + }, + "尝试次数过多,请稍后再试": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Too many attempts. Try again later." + } + } + } + }, + "展开工作区": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Expand workspace" + } + } + } + }, + "工作区加载失败,点按重试": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Could not load workspaces. Tap to retry." + } + } + } + }, + "工作区助手": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Workspace assistants" + } + } + } + }, + "工具": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Tools" + } + } + } + }, + "已上传文件": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Uploaded files" + } + } + } + }, + "已下载": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Downloaded" + } + } + } + }, + "已达到图片上限": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Image limit reached" + } + } + } + }, + "已连接桌面端": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Desktop connected" + } + } + } + }, + "帮我写点内容": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Help me write" + } + } + } + }, + "归档会话": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Archive session" + } + } + } + }, + "当前会话暂无已上传文件": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No files uploaded in this session" + } + } + } + }, + "当前设备暂时无法使用语音识别": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Speech recognition is unavailable on this device" + } + } + } + }, + "思考过程": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Thinking" + } + } + } + }, + "打开侧栏": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open sidebar" + } + } + } + }, + "打开网页": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open webpage" + } + } + } + }, + "扫描二维码": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Scan QR code" + } + } + } + }, + "扫描桌面端显示的二维码,开始远程处理任务。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Scan the QR code on the desktop to start remote work." + } + } + } + }, + "扫描桌面端显示的二维码,或粘贴连接链接。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Scan the QR code shown on the desktop, or paste the connection link." + } + } + } + }, + "找不到桌面端房间,请确认桌面端仍在等待连接": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Desktop room not found. Make sure the desktop is still waiting." + } + } + } + }, + "找到 4 处结果": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Found 4 results" + } + } + } + }, + "拒绝": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Deny" + } + } + } + }, + "按桌面端策略自动决定": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Decide automatically using desktop policy" + } + } + } + }, + "搜索": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Search" + } + } + } + }, + "搜索代码": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Search code" + } + } + } + }, + "搜索对话": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Search chats" + } + } + } + }, + "搜索网页": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Search the web" + } + } + } + }, + "收起工作区": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Collapse workspace" + } + } + } + }, + "敏感操作前请求确认": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Ask before sensitive operations" + } + } + } + }, + "文件保存失败": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Could not save the file" + } + } + } + }, + "文件较大,当前仅显示部分内容": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This file is large; only part of it is shown" + } + } + } + }, + "断开桌面连接": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Disconnect desktop" + } + } + } + }, + "新建远程会话": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "New remote session" + } + } + } + }, + "无法启动语音输入,请检查麦克风": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Could not start voice input. Check the microphone." + } + } + } + }, + "无法读取所选图片": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Could not read the selected image" + } + } + } + }, + "无法预览": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Preview unavailable" + } + } + } + }, + "普通对话": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "General chat" + } + } + } + }, + "普通对话服务": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "General chat service" + } + } + } + }, + "暂无最近会话": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No recent sessions" + } + } + } + }, + "暂无设备": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No devices" + } + } + } + }, + "更新待办": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Update tasks" + } + } + } + }, + "最多添加 4 张且每张不超过 10 MB 的图片": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Add up to 4 images, each no larger than 10 MB" + } + } + } + }, + "最多添加 4 张图片": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Add up to 4 images" + } + } + } + }, + "最近对话": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Recent chats" + } + } + } + }, + "服务地址": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Service URL" + } + } + } + }, + "未命名会话": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Untitled session" + } + } + } + }, + "未登录": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Not signed in" + } + } + } + }, + "未配置": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Not configured" + } + } + } + }, + "本地会话": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Local session" + } + } + } + }, + "权限状态加载失败,点按重试": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Could not load permission status. Tap to retry." + } + } + } + }, + "查看差异": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "View differences" + } + } + } + }, + "桌面端已连接": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Desktop connected" + } + } + } + }, + "桌面端拒绝了这次连接": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The desktop rejected this connection" + } + } + } + }, + "桌面端版本不兼容,请升级后重试": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The desktop version is incompatible. Upgrade and retry." + } + } + } + }, + "桌面设备": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Desktop devices" + } + } + } + }, + "梳理一个问题": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Organize a problem" + } + } + } + }, + "检查深色模式下的边框对比度": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Check border contrast in dark mode" + } + } + } + }, + "检查结果": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Results" + } + } + } + }, + "模型": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Model" + } + } + } + }, + "正在": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Running" + } + } + } + }, + "正在下载": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Downloading" + } + } + } + }, + "正在保存": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Saving" + } + } + } + }, + "正在准备下载": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Preparing download" + } + } + } + }, + "正在加载": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Loading" + } + } + } + }, + "正在加载工作区": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Loading workspaces" + } + } + } + }, + "正在加载文件": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Loading file" + } + } + } + }, + "正在发送": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Sending" + } + } + } + }, + "正在回复": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Answering" + } + } + } + }, + "正在思考": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Thinking" + } + } + } + }, + "正在恢复连接": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Restoring connection" + } + } + } + }, + "正在生成原生截图,并按相同基线并排展示。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Generating native captures for side-by-side comparison." + } + } + } + }, + "正在由 MacBook Pro 运行": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Running on MacBook Pro" + } + } + } + }, + "正在聆听": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Listening" + } + } + } + }, + "正在连接": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Connecting" + } + } + } + }, + "正在重新连接桌面端": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reconnecting to the desktop" + } + } + } + }, + "此工作区暂无会话": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No sessions in this workspace" + } + } + } + }, + "此文件类型暂不支持预览": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This file type cannot be previewed" + } + } + } + }, + "每次询问": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Ask every time" + } + } + } + }, + "比较三端的输入框、消息气泡和标题栏。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Compare the composer, message bubbles, and header across all three platforms." + } + } + } + }, + "没有匹配的会话": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No matching sessions" + } + } + } + }, + "测试连接": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Test connection" + } + } + } + }, + "添加图片": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Add image" + } + } + } + }, + "添加连接": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Add connection" + } + } + } + }, + "清除已保存的 API Key": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Clear saved API Key" + } + } + } + }, + "滚动到底部": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Scroll to bottom" + } + } + } + }, + "版本": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Version" + } + } + } + }, + "用户": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "User" + } + } + } + }, + "登录失败,请检查账号、密码和 relay 地址": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Sign-in failed. Check the account, password, and relay URL." + } + } + } + }, + "登录超时,请稍后重试": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Sign-in timed out. Try again later." + } + } + } + }, + "离线": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Offline" + } + } + } + }, + "移除图片": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Remove image" + } + } + } + }, + "等待重新连接": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Waiting to reconnect" + } + } + } + }, + "简体中文": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "简体中文" + } + } + } + }, + "粘贴桌面端连接链接": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Paste the desktop connection link" + } + } + } + }, + "统一移动端设计系统": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Unified mobile design system" + } + } + } + }, + "编辑文件": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Edit file" + } + } + } + }, + "网络不可用,请检查 relay 地址": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Network unavailable. Check the relay URL." + } + } + } + }, + "网络不可用,请检查手机与桌面端的网络": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Network unavailable. Check the phone and desktop networks." + } + } + } + }, + "置顶会话": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Pin session" + } + } + } + }, + "聊天": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Chat" + } + } + } + }, + "自动处理": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Automatic" + } + } + } + }, + "要同时运行远程场景回归吗?": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Run remote scenario regression tests too?" + } + } + } + }, + "设备": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Device" + } + } + } + }, + "设备列表刷新失败,仍显示上次结果": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Could not refresh devices; showing the previous results." + } + } + } + }, + "设置": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Settings" + } + } + } + }, + "语言": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Language" + } + } + } + }, + "语音识别已中断,请重试": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Speech recognition was interrupted. Try again." + } + } + } + }, + "语音输入": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Voice input" + } + } + } + }, + "请先连接桌面设备": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Connect a desktop first" + } + } + } + }, + "请在系统设置中允许语音识别": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Allow speech recognition in System Settings" + } + } + } + }, + "请在系统设置中允许麦克风访问": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Allow microphone access in System Settings" + } + } + } + }, + "请检查移动端的消息、工具和文件交互。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Check mobile messages, tools, and file interactions." + } + } + } + }, + "请求确认": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Ask for confirmation" + } + } + } + }, + "请输入 API Key": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enter API Key" + } + } + } + }, + "请输入回复": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enter a reply" + } + } + } + }, + "请输入有效的服务地址": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enter a valid service URL" + } + } + } + }, + "请输入桌面端密码": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enter the desktop password" + } + } + } + }, + "请输入桌面端账号": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enter the desktop account" + } + } + } + }, + "请输入模型名称": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enter the model name" + } + } + } + }, + "请重新连接": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reconnect" + } + } + } + }, + "读取完成": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Read complete" + } + } + } + }, + "读取文件": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Read file" + } + } + } + }, + "账号或密码错误": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Incorrect account or password" + } + } + } + }, + "跨端视觉校验": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cross-platform visual verification" + } + } + } + }, + "输入": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Input" + } + } + } + }, + "输入消息": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enter a message" + } + } + } + }, + "输出": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Output" + } + } + } + }, + "运行命令": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Run command" + } + } + } + }, + "这台桌面设备当前离线": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This desktop is offline" + } + } + } + }, + "这是 BitFun 的移动端会话界面。你可以从手机连接桌面端,查看工作区、会话和 Agent 的执行状态。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This is the BitFun mobile conversation UI. Connect to a desktop to view workspaces, sessions, and agent status." + } + } + } + }, + "这是 BitFun 的远程会话预览。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This is a BitFun remote session preview." + } + } + } + }, + "远程会话": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Remote session" + } + } + } + }, + "远程权限": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Remote permissions" + } + } + } + }, + "连接": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Connect" + } + } + } + }, + "连接不可用": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Connection unavailable" + } + } + } + }, + "连接失败,请检查桌面端链接": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Connection failed. Check the desktop link." + } + } + } + }, + "连接成功": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Connected" + } + } + } + }, + "连接暂时中断。恢复后会从上次游标继续。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The connection was interrupted. It will resume from the last cursor." + } + } + } + }, + "连接桌面端": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Connect desktop" + } + } + } + }, + "连接超时,请重新尝试": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Connection timed out. Try again." + } + } + } + }, + "连接链接无效,请重新扫描或粘贴桌面端链接": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Invalid connection link. Scan or paste it again." + } + } + } + }, + "退出账号": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Sign out" + } + } + } + }, + "选择": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Select" + } + } + } + }, + "选择已有会话,或在当前工作区创建一个新会话。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Choose a session or create one in the current workspace." + } + } + } + }, + "选择模型": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Select model" + } + } + } + }, + "选择语言": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Choose language" + } + } + } + }, + "通用": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "General" + } + } + } + }, + "配置无法保存,请稍后重试": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Could not save the configuration. Try again later." + } + } + } + }, + "重新发送": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Send again" + } + } + } + }, + "重试": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Retry" + } + } + } + }, + "%@失败%@": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ failed%@" + } + } + } + }, + "下载 %@": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Download %@" + } + } + } + }, + "当前会话已上传 %lld 个文件": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%lld files uploaded in this session" + } + } + } + }, + "还有 %lld 个会话": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%lld more sessions" + } + } + } + }, + "连接失败:%@": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Connection failed: %@" + } + } + } + }, + "文件预览失败:%@": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "File preview failed: %@" + } + } + } + }, + "已保存 %@": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Saved %@" + } + } + } + }, + "已完成 %lld 项读取与搜索": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Completed %lld read and search operations" + } + } + } + }, + "正在%@%@": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Running %@%@" + } + } + } + }, + "正在下载 %@ / %@": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Downloading %@ / %@" + } + } + } + }, + "等待执行 · %@": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Waiting to run · %@" + } + } + } + }, + "等待确认 · %@": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Awaiting confirmation · %@" + } + } + } + }, + "Relay 地址": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Relay URL" + } + } + } + }, + "密码": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Password" + } + } + } + }, + "登录": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Sign in" + } + } + } + }, + "登录 BitFun 账号": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Sign in to BitFun" + } + } + } + }, + "登录后可查看并连接账号下的桌面设备。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Sign in to view and connect your desktop devices." + } + } + } + }, + "登录服务器": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Sign-in server" + } + } + } + }, + "返回": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Back" + } + } + } + }, + "正在登录": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Signing in" + } + } + } + }, + "正在刷新": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Refreshing" + } + } + } + }, + "用户名": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Username" + } + } + } + }, + "普通对话模型": { + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Chat model" } } + } + }, + "选择账号模型": { + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Choose account model" } } + } + }, + "本机自定义模型": { + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Local custom model" } } + } + }, + "当前使用": { + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "In use" } } + } + }, + "模型来源": { + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Model sources" } } + } + }, + "云端账号模型": { + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Account models" } } + } + }, + "暂无可用的账号模型": { + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "No account models available" } } + } + }, + "已同步 %d 个": { + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "%d synced" } } + } + }, + "本机": { + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "On this device" } } + } + }, + "云端账号": { + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Account" } } + } + }, + "模型名称": { + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Model name" } } + } + }, + "例如 chat-model": { + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "e.g. chat-model" } } + } + }, + "保留已保存的 Key": { + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Keep the saved key" } } + } + }, + "保留或输入 API Key 后可测试连接。": { + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Keep or enter an API key to test the connection." } } + } + }, + "测试中…": { + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Testing…" } } + } + }, + "从桌面端获取配对码": { + "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Get a pairing code from desktop" } } } + }, + "在桌面端打开 BitFun,然后进入远程连接。": { + "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Open BitFun on desktop, then go to Remote Connect." } } } + }, + "点击“添加设备”获取配对二维码。": { + "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Choose Add device to get a pairing QR code." } } } + }, + "我有配对码": { + "localizations": { "en": { "stringUnit": { "state": "translated", "value": "I have a pairing code" } } } + }, + "手动输入配对码": { + "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Enter pairing code manually" } } } + }, + "输入桌面端显示的配对链接或代码。": { + "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Enter the pairing link or code shown on desktop." } } } + }, + "配对码或连接链接": { + "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Pairing code or connection link" } } } + }, + "账号认证配对": { + "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Account-authenticated pairing" } } } + }, + "此桌面要求使用 BitFun 账号验证身份。": { + "localizations": { "en": { "stringUnit": { "state": "translated", "value": "This desktop requires your BitFun account to verify your identity." } } } + }, + "BitFun 用户名": { + "localizations": { "en": { "stringUnit": { "state": "translated", "value": "BitFun username" } } } + }, + "BitFun 密码": { + "localizations": { "en": { "stringUnit": { "state": "translated", "value": "BitFun password" } } } + }, + "账号凭据只用于本次加密配对,不会保存。": { + "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Account credentials are used only for this encrypted pairing attempt and are not saved." } } } + }, + "配对": { + "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Pair" } } } + }, + "BitFun 账号": { + "localizations": { "en": { "stringUnit": { "state": "translated", "value": "BitFun account" } } } + }, + "已登录": { + "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Signed in" } } } + }, + "当前以 %@ 登录。": { + "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Currently signed in as %@." } } } + }, + "设备管理": { + "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Device management" } } } + }, + "刷新": { + "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Refresh" } } } + }, + "暂无可连接的桌面设备": { + "localizations": { "en": { "stringUnit": { "state": "translated", "value": "No desktop devices are available" } } } + }, + "个人资料详情": { + "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Profile details" } } } + }, + "用户 ID": { + "localizations": { "en": { "stringUnit": { "state": "translated", "value": "User ID" } } } + }, + "设备 ID": { + "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Device ID" } } } + }, + "告诉 BitFun 要做什么": { + "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Tell BitFun what to do" } } } + }, + "选择桌面设备": { + "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Choose a desktop" } } } + }, + "对话": { + "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Chat" } } } + }, + "暂无可用模型": { + "localizations": { "en": { "stringUnit": { "state": "translated", "value": "No models available" } } } + }, + "工作区": { + "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Workspace" } } } + }, + "视图设置": { + "localizations": { "en": { "stringUnit": { "state": "translated", "value": "View settings" } } } + }, + "调整会话列表的分组和信息密度": { + "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Adjust session grouping and information density" } } } + }, + "分组方式": { + "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Grouping" } } } + }, + "按项目": { + "localizations": { "en": { "stringUnit": { "state": "translated", "value": "By project" } } } + }, + "按时间倒序排列": { + "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Newest first" } } } + }, + "聊天优先": { + "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Chat first" } } } + }, + "筛选": { + "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Filters" } } } + }, + "所有工作区": { + "localizations": { "en": { "stringUnit": { "state": "translated", "value": "All workspaces" } } } + }, + "Agent 类型": { + "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Agent type" } } } + }, + "所有 Agent 类型": { + "localizations": { "en": { "stringUnit": { "state": "translated", "value": "All agent types" } } } + }, + "状态": { + "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Status" } } } + }, + "所有状态": { + "localizations": { "en": { "stringUnit": { "state": "translated", "value": "All statuses" } } } + }, + "显示信息": { + "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Shown details" } } } + }, + "更新时间": { + "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Updated" } } } + }, + "运行中": { + "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Running" } } } + }, + "就绪": { + "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Ready" } } } + }, + "今天": { + "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Today" } } } + }, + "昨天": { + "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Yesterday" } } } + }, + "更早": { + "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Earlier" } } } + }, + "暂无远程会话": { + "localizations": { "en": { "stringUnit": { "state": "translated", "value": "No remote sessions yet" } } } + }, + "问问 BitFun": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Ask BitFun" + } + } + } + }, + "查看详情": { + "localizations": { "en": { "stringUnit": { "state": "translated", "value": "View details" } } } + }, + "创建时间": { + "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Created" } } } + }, + "消息数量": { + "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Messages" } } } + }, + "展开侧栏": { + "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Expand sidebar" } } } + }, + "选择连接方式": { + "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Choose how to connect" } } } + }, + "扫码连接": { + "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Scan to connect" } } } + }, + "已连接": { + "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Connected" } } } + } + }, + "version": "1.0" +} diff --git a/src/apps/mobile/ios/README.md b/src/apps/mobile/ios/README.md index 314c2dc06b..daafa38203 100644 --- a/src/apps/mobile/ios/README.md +++ b/src/apps/mobile/ios/README.md @@ -51,7 +51,10 @@ action, and the unified drawer mirrors the HarmonyOS recent-chat, device, workspace, chat, and settings sections. A connected preview exposes the remote empty home through the same conversation chrome. -For repeatable simulator captures, pass `--remote`, `--connected`, and/or -`--drawer` after the bundle identifier in `simctl launch`. These launch flags +For repeatable simulator captures, pass `--remote`, `--connected`, `--drawer`, +`--settings`, `--remote-settings`, `--remote-view-settings`, `--remote-view-density`, `--model-settings`, `--composer-model-picker`, `--pairing`, `--pairing-manual`, `--pairing-account`, `--remote-create`, `--remote-create-workspace-picker`, `--remote-chat-section`, `--project-create-menu`, `--file-preview`, `--session-actions`, `--sidebar-actions`, `--local-actions`, and/or +`--account-login` or `--account-profile` after the bundle identifier in `simctl launch`. The local +actions flag can be combined with the session-actions flag; the account-login +flag opens a deterministic signed-out surface without storing credentials. These launch flags select deterministic inspection states; normal launches use the live KMP pairing/session stores. diff --git a/src/apps/mobile/shared/core-domain/src/commonMain/kotlin/com/bitfun/mobile/core/domain/ChatTimelineProjector.kt b/src/apps/mobile/shared/core-domain/src/commonMain/kotlin/com/bitfun/mobile/core/domain/ChatTimelineProjector.kt index 4c06c21cd1..0f9a8108a2 100644 --- a/src/apps/mobile/shared/core-domain/src/commonMain/kotlin/com/bitfun/mobile/core/domain/ChatTimelineProjector.kt +++ b/src/apps/mobile/shared/core-domain/src/commonMain/kotlin/com/bitfun/mobile/core/domain/ChatTimelineProjector.kt @@ -1,7 +1,6 @@ package com.bitfun.mobile.core.domain import com.bitfun.mobile.core.protocol.ChatMessageItemResponse -import com.bitfun.mobile.core.protocol.ImageAttachment import com.bitfun.mobile.core.protocol.RemoteToolStatusResponse import kotlin.math.absoluteValue @@ -103,7 +102,9 @@ public object ChatTimelineProjector { pendingMessages: List, messages: List, ): List = pendingMessages.filter { pending -> - messages.none { message -> isPersistedUserDuplicate(pending, message) } + messages.none { message -> + pending.role == "user" && message.role == "user" && pending.id == message.id + } } private fun markLatestFailedMessageRetryable(items: MutableList) { @@ -146,9 +147,8 @@ public object ChatTimelineProjector { if (!activeTurn.turnId.isNullOrEmpty() && message.id == "${activeTurn.turnId}_assistant") { return true } - val activeText = activeTurn.text.trim() - val messageText = message.text.trim() - return activeText.isNotEmpty() && activeText == messageText + return !activeTurn.turnId.isNullOrEmpty() && !message.turnId.isNullOrEmpty() && + activeTurn.turnId == message.turnId } private fun hasDisplayableAssistantFinal(message: ChatMessage): Boolean { @@ -213,20 +213,6 @@ public object ChatTimelineProjector { ).joinToString(":") }.joinToString(",") - private fun isPersistedUserDuplicate( - pending: ChatMessage, - message: ChatMessage, - ): Boolean { - if (pending.role != "user" || message.role != "user") return false - if (pending.id == message.id) return true - val pendingText = pending.text.trim() - val messageText = message.text.trim() - if (pendingText.isEmpty() || pendingText != messageText) return false - return imageSignature(pending.images.orEmpty()) == imageSignature(message.images.orEmpty()) - } - - private fun imageSignature(images: List): String = - images.map { image -> "${image.name}:${image.dataUrl}" }.sorted().joinToString("|") } private fun stableTextHash(text: String): String { diff --git a/src/apps/mobile/shared/core-domain/src/commonMain/kotlin/com/bitfun/mobile/core/domain/ChatTimelineStore.kt b/src/apps/mobile/shared/core-domain/src/commonMain/kotlin/com/bitfun/mobile/core/domain/ChatTimelineStore.kt index e15ca78a00..0e0536a93c 100644 --- a/src/apps/mobile/shared/core-domain/src/commonMain/kotlin/com/bitfun/mobile/core/domain/ChatTimelineStore.kt +++ b/src/apps/mobile/shared/core-domain/src/commonMain/kotlin/com/bitfun/mobile/core/domain/ChatTimelineStore.kt @@ -29,11 +29,13 @@ public data class ChatTimelineState public constructor( public class ChatTimelineStore public constructor() { private var state: ChatTimelineState = emptyState("") + private var recentlyCoveredTurn: CoveredTurn? = null public fun reset(): Unit = reset("") public fun reset(sessionId: String) { state = emptyState(sessionId) + recentlyCoveredTurn = null } public fun snapshot(): ChatTimelineState = state.copy( @@ -64,19 +66,34 @@ public class ChatTimelineStore public constructor() { public fun setPersistedMessages(messages: List) { val persisted = realMessages(messages) + val previousIds = state.persistedMessages.mapTo(mutableSetOf()) { it.id } + val newlyPersisted = persisted.filterNot { it.id in previousIds } + val activeCovered = state.activeTurn?.let { active -> + val coveredByContent = coveredByNewAssistantContent(active, newlyPersisted) + if (coveredByContent) recentlyCoveredTurn = CoveredTurn.from(active) + isActiveTurnCoveredByMessages(active, persisted) || coveredByContent + } == true state = state.copy( persistedMessages = persisted, - optimisticMessages = optimisticMessagesNotPersisted(state.optimisticMessages, persisted), - activeTurn = state.activeTurn?.takeUnless { isActiveTurnCoveredByMessages(it, persisted) }, + optimisticMessages = optimisticMessagesNotPersisted(state.optimisticMessages, newlyPersisted), + activeTurn = state.activeTurn?.takeUnless { activeCovered }, ) } public fun mergePersistedMessages(messages: List) { - val persisted = mergeMessages(state.persistedMessages, realMessages(messages)) + val incoming = realMessages(messages) + val previousIds = state.persistedMessages.mapTo(mutableSetOf()) { it.id } + val newlyPersisted = incoming.filterNot { it.id in previousIds } + val persisted = mergeMessages(state.persistedMessages, incoming) + val activeCovered = state.activeTurn?.let { active -> + val coveredByContent = coveredByNewAssistantContent(active, newlyPersisted) + if (coveredByContent) recentlyCoveredTurn = CoveredTurn.from(active) + isActiveTurnCoveredByMessages(active, persisted) || coveredByContent + } == true state = state.copy( persistedMessages = persisted, - optimisticMessages = optimisticMessagesNotPersisted(state.optimisticMessages, persisted), - activeTurn = state.activeTurn?.takeUnless { isActiveTurnCoveredByMessages(it, persisted) }, + optimisticMessages = optimisticMessagesNotPersisted(state.optimisticMessages, newlyPersisted), + activeTurn = state.activeTurn?.takeUnless { activeCovered }, ) } @@ -152,6 +169,13 @@ public class ChatTimelineStore public constructor() { public fun setActiveTurn(activeTurn: ChatMessage?) { val normalized = activeTurn?.takeIf { it.id.isNotEmpty() } + if (normalized == null) { + recentlyCoveredTurn = null + } else if (recentlyCoveredTurn?.matches(normalized) == true) { + recentlyCoveredTurn = null + state = state.copy(activeTurn = null, syncPhase = ChatSyncPhase.IDLE) + return + } val merged = activeTurnForUpdate(state.activeTurn, normalized) val next = activeTurnForState(merged) state = state.copy( @@ -161,9 +185,24 @@ public class ChatTimelineStore public constructor() { } public fun clearActiveTurn() { + recentlyCoveredTurn = null state = state.copy(activeTurn = null, syncPhase = ChatSyncPhase.IDLE) } + private data class CoveredTurn(val turnId: String?, val text: String) { + fun matches(message: ChatMessage): Boolean = when { + !turnId.isNullOrEmpty() && !message.turnId.isNullOrEmpty() -> turnId == message.turnId + else -> text.isNotEmpty() && text == message.text.trim() + } + + companion object { + fun from(message: ChatMessage): CoveredTurn = CoveredTurn( + turnId = message.turnId?.takeIf(String::isNotEmpty), + text = message.text.trim(), + ) + } + } + public fun applySnapshot(snapshot: ChatSessionSnapshot) { setCursor(snapshot.cursor) if (snapshot.newMessages.isNotEmpty()) mergePersistedMessages(snapshot.newMessages) @@ -270,8 +309,20 @@ public class ChatTimelineStore public constructor() { public fun optimisticMessagesNotPersisted( optimisticMessages: List, persistedMessages: List, - ): List = optimisticMessages.filter { pending -> - persistedMessages.none { message -> isPersistedUserDuplicate(pending, message) } + ): List { + val acknowledgedIndexes = mutableSetOf() + return optimisticMessages.filter { pending -> + val acknowledgedIndex = persistedMessages.indices.firstOrNull { index -> + index !in acknowledgedIndexes && + isPersistedUserDuplicate(pending, persistedMessages[index]) + } + if (acknowledgedIndex == null) { + true + } else { + acknowledgedIndexes += acknowledgedIndex + false + } + } } public fun isActiveTurnCoveredByMessages( @@ -439,8 +490,7 @@ public class ChatTimelineStore public constructor() { if (existingIndex >= 0) { merged[existingIndex] = mergeMessageSnapshot(merged[existingIndex], message) } else { - val optimisticIndex = merged.indexOfFirst { isOptimisticDuplicate(it, message) } - if (optimisticIndex >= 0) merged[optimisticIndex] = message else merged += message + merged += message } } return merged @@ -463,15 +513,24 @@ public class ChatTimelineStore public constructor() { ) } - private fun isOptimisticDuplicate(local: ChatMessage, remote: ChatMessage): Boolean = - local.role == "user" && local.id.startsWith("msg-") && local.role == remote.role && - local.text.trim().isNotEmpty() && local.text.trim() == remote.text.trim() - private fun isPersistedAssistantDuplicate(activeTurn: ChatMessage, message: ChatMessage): Boolean { if (message.role != "assistant" || !hasDisplayableAssistantFinal(message)) return false if (message.id == activeTurn.id) return true if (!activeTurn.turnId.isNullOrEmpty() && message.id == "${activeTurn.turnId}_assistant") return true - return activeTurn.text.trim().isNotEmpty() && activeTurn.text.trim() == message.text.trim() + return !activeTurn.turnId.isNullOrEmpty() && !message.turnId.isNullOrEmpty() && + activeTurn.turnId == message.turnId + } + + private fun coveredByNewAssistantContent( + activeTurn: ChatMessage, + messages: List, + ): Boolean { + val activeText = activeTurn.text.trim() + if (activeText.isEmpty()) return false + return messages.any { message -> + message.role == "assistant" && hasDisplayableAssistantFinal(message) && + message.text.trim() == activeText + } } private fun hasDisplayableAssistantFinal(message: ChatMessage): Boolean { diff --git a/src/apps/mobile/shared/core-domain/src/commonMain/kotlin/com/bitfun/mobile/core/domain/SessionListPolicy.kt b/src/apps/mobile/shared/core-domain/src/commonMain/kotlin/com/bitfun/mobile/core/domain/SessionListPolicy.kt index 4d1ae2287a..2f9ecb8bf9 100644 --- a/src/apps/mobile/shared/core-domain/src/commonMain/kotlin/com/bitfun/mobile/core/domain/SessionListPolicy.kt +++ b/src/apps/mobile/shared/core-domain/src/commonMain/kotlin/com/bitfun/mobile/core/domain/SessionListPolicy.kt @@ -25,6 +25,14 @@ public object SessionAgentTypes { */ public fun isAssistant(agentType: String): Boolean = agentType.lowercase() in setOf("claw", "assistant", "chat") + + /** + * ACP sessions are owned by Desktop integrations and cannot be controlled + * by any native mobile surface. Keep this beside the other agent-type + * semantics so Android and iOS cannot drift on the same relay page. + */ + public fun isMobileVisible(agentType: String): Boolean = + !agentType.trim().lowercase().startsWith("acp:") } /** diff --git a/src/apps/mobile/shared/core-domain/src/commonMain/kotlin/com/bitfun/mobile/core/domain/ToolQuestionPolicy.kt b/src/apps/mobile/shared/core-domain/src/commonMain/kotlin/com/bitfun/mobile/core/domain/ToolQuestionPolicy.kt new file mode 100644 index 0000000000..aa7a1c0d09 --- /dev/null +++ b/src/apps/mobile/shared/core-domain/src/commonMain/kotlin/com/bitfun/mobile/core/domain/ToolQuestionPolicy.kt @@ -0,0 +1,69 @@ +package com.bitfun.mobile.core.domain + +import com.bitfun.mobile.core.protocol.RelayJson +import com.bitfun.mobile.core.protocol.RemoteToolStatusResponse +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.jsonPrimitive + +public data class QuestionOptionSpec public constructor( + public val label: String, + public val description: String?, +) + +public data class QuestionSpec public constructor( + public val index: Int, + public val header: String, + public val question: String, + public val options: List, + public val multiSelect: Boolean, +) + +public object ToolQuestionPolicy { + public fun parse(tool: RemoteToolStatusResponse): List { + val candidates = listOfNotNull( + tool.toolInput?.let(::asObject), + tool.inputPreview?.takeIf(String::isNotBlank)?.let { preview -> + runCatching { RelayJson.parseToJsonElement(preview) }.getOrNull()?.let(::asObject) + }, + ) + return candidates.firstNotNullOfOrNull { root -> parseQuestions(root).takeIf(List::isNotEmpty) } + ?: emptyList() + } + + private fun asObject(element: JsonElement): JsonObject? { + val candidate = if (element is JsonPrimitive && element.isString) { + runCatching { RelayJson.parseToJsonElement(element.content) }.getOrNull() + } else { + element + } + return candidate as? JsonObject + } + + private fun parseQuestions(root: JsonObject): List = + (root["questions"] as? JsonArray).orEmpty().mapIndexedNotNull { index, element -> + val question = element as? JsonObject ?: return@mapIndexedNotNull null + val prompt = question.text("question") ?: return@mapIndexedNotNull null + val options = (question["options"] as? JsonArray).orEmpty().mapNotNull { optionElement -> + val option = optionElement as? JsonObject ?: return@mapNotNull null + val label = option.text("label") ?: return@mapNotNull null + QuestionOptionSpec(label, option.text("description")) + } + if (options.isEmpty()) return@mapIndexedNotNull null + QuestionSpec( + index = index, + header = question.text("header").orEmpty(), + question = prompt, + options = options, + multiSelect = (question["multiSelect"] as? JsonPrimitive)?.booleanOrNull ?: false, + ) + } + + private fun JsonObject.text(key: String): String? = + (this[key] as? JsonPrimitive)?.takeIf { it.isString }?.jsonPrimitive?.content + ?.trim() + ?.takeIf(String::isNotEmpty) +} diff --git a/src/apps/mobile/shared/core-domain/src/commonTest/kotlin/com/bitfun/mobile/core/domain/ChatTimelineProjectorTest.kt b/src/apps/mobile/shared/core-domain/src/commonTest/kotlin/com/bitfun/mobile/core/domain/ChatTimelineProjectorTest.kt index 4399564575..d3187d1448 100644 --- a/src/apps/mobile/shared/core-domain/src/commonTest/kotlin/com/bitfun/mobile/core/domain/ChatTimelineProjectorTest.kt +++ b/src/apps/mobile/shared/core-domain/src/commonTest/kotlin/com/bitfun/mobile/core/domain/ChatTimelineProjectorTest.kt @@ -25,7 +25,7 @@ class ChatTimelineProjectorTest { fun deduplicatesOptimisticUserMessagesReplacedByPersistedMessages() { val items = ChatTimelineProjector.project( messages = listOf(message("remote-user-1", "user", "Explain this file")), - pendingMessages = listOf(message("msg-local-1", "user", " Explain this file ")), + pendingMessages = listOf(message("remote-user-1", "user", " Explain this file ")), activeTurn = null, hasMoreMessages = false, ) @@ -35,6 +35,19 @@ class ChatTimelineProjectorTest { assertEquals(ChatTimelineItemType.USER_MESSAGE, items[0].type) } + @Test + fun projectorKeepsARepeatedPendingMessageWithDifferentIdentity() { + val items = ChatTimelineProjector.project( + messages = listOf(message("remote-user-1", "user", "Explain this file")), + pendingMessages = listOf(message("msg-local-2", "user", "Explain this file")), + activeTurn = null, + hasMoreMessages = false, + ) + + assertEquals(2, items.size) + assertEquals("pending-msg-local-2", items[1].id) + } + @Test fun keepsOptimisticImageMessagesWhenPersistedImagePayloadDiffers() { val items = ChatTimelineProjector.project( @@ -69,6 +82,7 @@ class ChatTimelineProjectorTest { role = "assistant", text = "Done with the edit", status = "completed", + turnId = "turn-1", ) val beforeFinal = ChatTimelineProjector.project( messages = listOf(message("remote-user-1", "user", "Please edit")), @@ -79,7 +93,7 @@ class ChatTimelineProjectorTest { val afterFinal = ChatTimelineProjector.project( messages = listOf( message("remote-user-1", "user", "Please edit"), - message("assistant-final-1", "assistant", "Done with the edit"), + message("assistant-final-1", "assistant", "Done with the edit", turnId = "turn-1"), ), pendingMessages = emptyList(), activeTurn = activeTurn, diff --git a/src/apps/mobile/shared/core-domain/src/commonTest/kotlin/com/bitfun/mobile/core/domain/ChatTimelineStoreTest.kt b/src/apps/mobile/shared/core-domain/src/commonTest/kotlin/com/bitfun/mobile/core/domain/ChatTimelineStoreTest.kt index ae11e3d684..b3817d6548 100644 --- a/src/apps/mobile/shared/core-domain/src/commonTest/kotlin/com/bitfun/mobile/core/domain/ChatTimelineStoreTest.kt +++ b/src/apps/mobile/shared/core-domain/src/commonTest/kotlin/com/bitfun/mobile/core/domain/ChatTimelineStoreTest.kt @@ -5,6 +5,7 @@ import com.bitfun.mobile.core.protocol.RemoteToolStatusResponse import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertNull import kotlin.test.assertTrue class ChatTimelineStoreTest { @@ -28,6 +29,44 @@ class ChatTimelineStoreTest { assertEquals(null, state.activeTurn) } + @Test + fun onePersistedMessageAcknowledgesOnlyOneRepeatedOptimisticSend() { + val store = ChatTimelineStore() + store.reset("session-repeated") + store.appendOptimisticMessage(message("msg-local-1", "user", "same request")) + store.appendOptimisticMessage(message("msg-local-2", "user", "same request")) + + store.mergePersistedMessages(listOf(message("remote-user-1", "user", "same request"))) + + assertEquals(listOf("msg-local-2"), store.snapshot().optimisticMessages.map { it.id }) + } + + @Test + fun replayedHistoryCannotAcknowledgeANewRepeatedSend() { + val store = ChatTimelineStore() + store.reset("session-replay") + val history = message("remote-user-1", "user", "same request") + store.setPersistedMessages(listOf(history)) + store.appendOptimisticMessage(message("msg-local-2", "user", "same request")) + + store.mergePersistedMessages(listOf(history)) + + assertEquals(listOf("msg-local-2"), store.snapshot().optimisticMessages.map { it.id }) + } + + @Test + fun anOlderIdenticalAssistantMessageDoesNotHideTheActiveTurn() { + val store = ChatTimelineStore() + store.reset("session-repeat-assistant") + store.setPersistedMessages(listOf(message("older", "assistant", "same answer"))) + store.setActiveTurn(activeMessage("new-turn", "same answer", "completed")) + + assertEquals("active-new-turn", store.snapshot().activeTurn?.id) + + store.mergePersistedMessages(listOf(message("new-final", "assistant", "same answer"))) + assertNull(store.snapshot().activeTurn) + } + @Test fun appliesTransportNeutralTurnEvents() { val store = ChatTimelineStore() diff --git a/src/apps/mobile/shared/core-domain/src/commonTest/kotlin/com/bitfun/mobile/core/domain/SessionListPolicyTest.kt b/src/apps/mobile/shared/core-domain/src/commonTest/kotlin/com/bitfun/mobile/core/domain/SessionListPolicyTest.kt index 7aaec8a7fd..3c11ff5485 100644 --- a/src/apps/mobile/shared/core-domain/src/commonTest/kotlin/com/bitfun/mobile/core/domain/SessionListPolicyTest.kt +++ b/src/apps/mobile/shared/core-domain/src/commonTest/kotlin/com/bitfun/mobile/core/domain/SessionListPolicyTest.kt @@ -26,6 +26,13 @@ class SessionListPolicyTest { assertFalse(SessionAgentTypes.isCowork("claw")) } + @Test + fun acpSessionsStayOffNativeMobileSurfaces() { + assertFalse(SessionAgentTypes.isMobileVisible("acp:codex")) + assertFalse(SessionAgentTypes.isMobileVisible(" ACP:custom ")) + assertTrue(SessionAgentTypes.isMobileVisible("code")) + } + @Test fun untitledSessionsGetTheAgentSpecificWireName() { assertEquals("Remote Code Session", SessionNaming.wireSessionName("code", " ")) diff --git a/src/apps/mobile/shared/core-domain/src/commonTest/kotlin/com/bitfun/mobile/core/domain/ToolQuestionPolicyTest.kt b/src/apps/mobile/shared/core-domain/src/commonTest/kotlin/com/bitfun/mobile/core/domain/ToolQuestionPolicyTest.kt new file mode 100644 index 0000000000..aae17df974 --- /dev/null +++ b/src/apps/mobile/shared/core-domain/src/commonTest/kotlin/com/bitfun/mobile/core/domain/ToolQuestionPolicyTest.kt @@ -0,0 +1,85 @@ +package com.bitfun.mobile.core.domain + +import com.bitfun.mobile.core.protocol.RelayJson +import com.bitfun.mobile.core.protocol.RemoteToolStatusResponse +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonPrimitive +import kotlin.test.Test +import kotlin.test.assertEquals + +class ToolQuestionPolicyTest { + @Test + fun parsesStructuredQuestionsFromToolInput() { + val tool = tool( + toolInput = RelayJson.parseToJsonElement( + """{"questions":[{"header":"Branch","question":"Which branch?","options":[{"label":"main","description":"Stable"},{"label":"dev"}],"multiSelect":false},{"question":"Checks","options":[{"label":"lint"},{"label":"tests"}],"multiSelect":true}]}""", + ), + ) + + val questions = ToolQuestionPolicy.parse(tool) + + assertEquals(2, questions.size) + assertEquals(0, questions[0].index) + assertEquals("Branch", questions[0].header) + assertEquals("Which branch?", questions[0].question) + assertEquals(listOf(QuestionOptionSpec("main", "Stable"), QuestionOptionSpec("dev", null)), questions[0].options) + assertEquals(false, questions[0].multiSelect) + assertEquals(1, questions[1].index) + assertEquals(true, questions[1].multiSelect) + } + + @Test + fun parsesStringifiedInputPreviewWhenToolInputIsAbsent() { + val questions = ToolQuestionPolicy.parse( + tool(inputPreview = """{"questions":[{"header":"Pick","question":"Choose","options":[{"label":"one"}]}]}"""), + ) + + assertEquals(listOf(QuestionSpec(0, "Pick", "Choose", listOf(QuestionOptionSpec("one", null)), false)), questions) + } + + @Test + fun dropsHeaderOnlyQuestionsAndPreservesOriginalIndexAfterDroppedEntry() { + val questions = ToolQuestionPolicy.parse( + tool( + toolInput = RelayJson.parseToJsonElement( + """{"questions":[{"header":"Missing question","options":[{"label":"ignored"}]},{"question":"Valid","options":[{"label":"keep"}]}]}""", + ), + ), + ) + + assertEquals(listOf(QuestionSpec(1, "", "Valid", listOf(QuestionOptionSpec("keep", null)), false)), questions) + } + + @Test + fun malformedToolInputFallsBackToInputPreview() { + val questions = ToolQuestionPolicy.parse( + tool( + toolInput = JsonPrimitive("not valid json"), + inputPreview = """{"questions":[{"question":"Fallback","options":[{"label":"yes"}]}]}""", + ), + ) + + assertEquals(listOf(QuestionSpec(0, "", "Fallback", listOf(QuestionOptionSpec("yes", null)), false)), questions) + } + + @Test + fun dropsQuestionsWithoutOptionsAndLegacyShapes() { + assertEquals( + emptyList(), + ToolQuestionPolicy.parse(tool(toolInput = RelayJson.parseToJsonElement("""{"questions":[{"question":"No choices","options":[]}]}"""))), + ) + assertEquals(emptyList(), ToolQuestionPolicy.parse(tool(inputPreview = """{"question":"Old"}"""))) + assertEquals(emptyList(), ToolQuestionPolicy.parse(tool(inputPreview = """{"prompt":"Old"}"""))) + assertEquals(emptyList(), ToolQuestionPolicy.parse(tool(inputPreview = "{}"))) + } + + private fun tool( + toolInput: JsonElement? = null, + inputPreview: String? = null, + ): RemoteToolStatusResponse = RemoteToolStatusResponse( + name = "AskUserQuestion", + status = "sent", + toolInput = toolInput, + inputPreview = inputPreview, + ) +} diff --git a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/bitfun/mobile/core/feature/account/AccountDefaults.kt b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/bitfun/mobile/core/feature/account/AccountDefaults.kt new file mode 100644 index 0000000000..2025d0c6a0 --- /dev/null +++ b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/bitfun/mobile/core/feature/account/AccountDefaults.kt @@ -0,0 +1,8 @@ +package com.bitfun.mobile.core.feature.account + +import com.bitfun.mobile.core.transport.DEFAULT_CLOUD_RELAY_URL + +/** Stable account defaults exposed to native presentation adapters. */ +public object AccountDefaults { + public const val CLOUD_RELAY_URL: String = DEFAULT_CLOUD_RELAY_URL +} diff --git a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/bitfun/mobile/core/feature/account/AccountStore.kt b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/bitfun/mobile/core/feature/account/AccountStore.kt index 86e6ad8e4c..6d2a11fc49 100644 --- a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/bitfun/mobile/core/feature/account/AccountStore.kt +++ b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/bitfun/mobile/core/feature/account/AccountStore.kt @@ -129,7 +129,11 @@ public class AccountStore internal constructor( } catch (cancelled: CancellationException) { throw cancelled } catch (error: CloudAccountException) { - _state.value = AccountUiState.Failed(error.failure.toUiReason(), true) + if (error.failure == CloudAccountFailure.AUTHENTICATION) { + expireSession(error.failure.toUiReason()) + } else { + _state.value = AccountUiState.Failed(error.failure.toUiReason(), true) + } } catch (_: Throwable) { _state.value = AccountUiState.Failed(AccountFailureReason.NETWORK, true) } @@ -209,7 +213,11 @@ public class AccountStore internal constructor( } catch (cancelled: CancellationException) { throw cancelled } catch (error: CloudAccountException) { - _state.value = ready.copy(refreshing = false, refreshFailure = error.failure.toUiReason()) + if (error.failure == CloudAccountFailure.AUTHENTICATION) { + expireSession(error.failure.toUiReason()) + } else { + _state.value = ready.copy(refreshing = false, refreshFailure = error.failure.toUiReason()) + } } catch (_: Throwable) { _state.value = ready.copy(refreshing = false, refreshFailure = AccountFailureReason.NETWORK) } @@ -228,6 +236,18 @@ public class AccountStore internal constructor( } } + /** Clears every observable and persisted fact owned by an expired token. */ + private fun expireSession(reason: AccountFailureReason) { + session = null + _state.value = AccountUiState.Failed(reason, true) + try { + secureStore.delete(SESSION_KEY) + } catch (_: Throwable) { + // The in-memory projection is already safe. A storage failure must + // not put stale account devices back on screen. + } + } + /** * The one place the relay's list becomes the list a screen renders, so the * filter cannot be forgotten by a caller — or applied twice with two diff --git a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/bitfun/mobile/core/feature/layout/SettingsPlacementPolicy.kt b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/bitfun/mobile/core/feature/layout/SettingsPlacementPolicy.kt new file mode 100644 index 0000000000..5367c46f5e --- /dev/null +++ b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/bitfun/mobile/core/feature/layout/SettingsPlacementPolicy.kt @@ -0,0 +1,197 @@ +package com.bitfun.mobile.core.feature.layout + +/** A horizontal hinge crossing the window, in density-independent units. */ +public data class HorizontalWindowCrease( + val top: Int, + val height: Int, +) + +/** Platform-neutral window facts consumed by overlay placement policies. */ +public data class AdaptiveLayoutInput( + val viewportWidth: Int, + val viewportHeight: Int, + val isFolded: Boolean, + val isExpandedFoldable: Boolean, + val isHoverOperate: Boolean, + val wideLayoutMatched: Boolean, + val verticalCreases: List, + val horizontalCreases: List, + val isRtl: Boolean, +) + +public enum class SettingsPlacementMode { + BOTTOM, + SIDE, + FOLD_OPERATE, +} + +public enum class SettingsSheetKind { + SETTINGS, + CONNECT, + SESSION_DETAILS, + REMOTE_VIEW_SETTINGS, +} + +public data class SettingsPlacement( + val mode: SettingsPlacementMode, + val width: Int, + val height: Int, + val maxHeight: Int, +) + +/** + * Overlay placement shared by HarmonyOS, Android and iOS. + * + * This is a direct port of HarmonyOS `SettingsPlacementPolicy.ets`. Platform + * adapters still own their native sheet/popover lifecycle; the shared policy + * only decides which physical region may contain the surface. + */ +public object SettingsPlacementPolicy { + public const val TABLET_MIN_WIDTH: Int = 400 + public const val TABLET_MAX_WIDTH: Int = 520 + public const val SIDE_MAX_HEIGHT: Int = 760 + public const val SIDE_MIN_HEIGHT: Int = 560 + public const val SIDE_VERTICAL_MARGIN: Int = 80 + public const val SESSION_DETAILS_HEIGHT: Int = 560 + public const val REMOTE_VIEW_SETTINGS_HEIGHT: Int = 520 + + private const val TABLET_WIDTH_RATIO: Double = 0.32 + private const val LANDSCAPE_WIDTH_RATIO: Double = 0.45 + + public fun compactBottom(kind: SettingsSheetKind): SettingsPlacement = bottomPlacement(kind) + + public fun resolve(input: AdaptiveLayoutInput, kind: SettingsSheetKind): SettingsPlacement { + if (input.isHoverOperate) return hoverPlacement(input, kind) + if (input.isFolded) return bottomPlacement(kind) + return creaseFreeOrSidePlacement(input, kind) + } + + /** The leading coordinate of ArkUI/Compose/SwiftUI's trailing side sheet. */ + public fun sheetLeft( + placement: SettingsPlacement, + viewportWidth: Int, + isRtl: Boolean, + ): Int { + if (placement.mode != SettingsPlacementMode.SIDE || + placement.width <= 0 || viewportWidth <= 0 + ) return 0 + return if (isRtl) 0 else maxOf(0, viewportWidth - placement.width) + } + + public fun sheetIntersectsVerticalCrease( + placement: SettingsPlacement, + crease: WindowCrease, + viewportWidth: Int, + isRtl: Boolean, + ): Boolean { + if (placement.mode != SettingsPlacementMode.SIDE || + placement.width <= 0 || viewportWidth <= 0 + ) return false + val left = sheetLeft(placement, viewportWidth, isRtl) + val right = left + placement.width + val creaseRight = crease.left + crease.width + return left < creaseRight && right > crease.left + } + + private fun hoverPlacement( + input: AdaptiveLayoutInput, + kind: SettingsSheetKind, + ): SettingsPlacement { + val horizontal = input.horizontalCreases + .filter { it.top > 0 && it.height >= 0 && it.top + it.height < input.viewportHeight } + .sortedBy { it.top } + .firstOrNull() + if (horizontal != null) { + val maxHeight = maxOf(0, input.viewportHeight - horizontal.top - horizontal.height) + return SettingsPlacement( + mode = SettingsPlacementMode.FOLD_OPERATE, + width = 0, + height = kindOperateHeight(maxHeight, kind), + maxHeight = maxHeight, + ) + } + return creaseFreeOrSidePlacement(input, kind) + } + + private fun creaseFreeOrSidePlacement( + input: AdaptiveLayoutInput, + kind: SettingsSheetKind, + ): SettingsPlacement { + val usable = input.verticalCreases + .filter { it.left > 0 && it.width >= 0 && it.left + it.width < input.viewportWidth } + .sortedBy { it.left } + if (usable.isNotEmpty() && canUseSideSheet(input)) { + val crease = if (input.isRtl) usable.first() else usable.last() + val width = if (input.isRtl) { + maxOf(0, crease.left) + } else { + maxOf(0, input.viewportWidth - crease.left - crease.width) + } + return SettingsPlacement( + SettingsPlacementMode.SIDE, + width, + kindSideHeight(input.viewportHeight, kind), + 0, + ) + } + if (isHeightScarceLandscape(input) && canUseSideSheet(input)) { + return SettingsPlacement( + SettingsPlacementMode.SIDE, + (input.viewportWidth * LANDSCAPE_WIDTH_RATIO).roundToInt(), + kindSideHeight(input.viewportHeight, kind), + 0, + ) + } + if (isWideViewport(input) && canUseSideSheet(input)) { + val ratioWidth = (input.viewportWidth * TABLET_WIDTH_RATIO).roundToInt() + val width = minOf(input.viewportWidth, ratioWidth.coerceIn(TABLET_MIN_WIDTH, TABLET_MAX_WIDTH)) + return SettingsPlacement( + SettingsPlacementMode.SIDE, + width, + kindSideHeight(input.viewportHeight, kind), + 0, + ) + } + return bottomPlacement(kind) + } + + private fun bottomPlacement(kind: SettingsSheetKind): SettingsPlacement = SettingsPlacement( + mode = SettingsPlacementMode.BOTTOM, + width = 0, + height = if (kind == SettingsSheetKind.REMOTE_VIEW_SETTINGS) REMOTE_VIEW_SETTINGS_HEIGHT else 0, + maxHeight = 0, + ) + + private fun kindSideHeight(viewportHeight: Int, kind: SettingsSheetKind): Int { + val computed = sideHeight(viewportHeight) + return when (kind) { + SettingsSheetKind.SESSION_DETAILS -> minOf(SESSION_DETAILS_HEIGHT, computed) + SettingsSheetKind.REMOTE_VIEW_SETTINGS -> minOf(REMOTE_VIEW_SETTINGS_HEIGHT, computed) + else -> computed + } + } + + private fun kindOperateHeight(maxHeight: Int, kind: SettingsSheetKind): Int = when (kind) { + SettingsSheetKind.REMOTE_VIEW_SETTINGS -> minOf(REMOTE_VIEW_SETTINGS_HEIGHT, maxHeight) + SettingsSheetKind.SESSION_DETAILS -> minOf(SESSION_DETAILS_HEIGHT, maxHeight) + else -> maxHeight + } + + private fun sideHeight(viewportHeight: Int): Int { + if (viewportHeight <= 0) return SIDE_MAX_HEIGHT + return (viewportHeight - SIDE_VERTICAL_MARGIN).coerceIn(SIDE_MIN_HEIGHT, SIDE_MAX_HEIGHT) + } + + private fun canUseSideSheet(input: AdaptiveLayoutInput): Boolean = + input.viewportWidth >= ConversationLayoutPolicy.MD_MIN_WIDTH + + private fun isWideViewport(input: AdaptiveLayoutInput): Boolean = + input.wideLayoutMatched || input.viewportWidth >= ConversationLayoutPolicy.MD_MIN_WIDTH + + private fun isHeightScarceLandscape(input: AdaptiveLayoutInput): Boolean = + input.viewportWidth > input.viewportHeight && + input.viewportHeight > 0 && + input.viewportHeight < ConversationLayoutPolicy.MD_MIN_WIDTH + + private fun Double.roundToInt(): Int = (this + 0.5).toInt() +} diff --git a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/bitfun/mobile/core/feature/session/ConversationModelPresentation.kt b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/bitfun/mobile/core/feature/session/ConversationModelPresentation.kt index 716f8fcf89..69194ea496 100644 --- a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/bitfun/mobile/core/feature/session/ConversationModelPresentation.kt +++ b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/bitfun/mobile/core/feature/session/ConversationModelPresentation.kt @@ -2,6 +2,7 @@ package com.bitfun.mobile.core.feature.session import com.bitfun.mobile.core.domain.ChatTimelineState import com.bitfun.mobile.core.domain.ModelLabelPolicy +import com.bitfun.mobile.core.protocol.RemoteModelCatalog /** * One row of the model picker. @@ -51,6 +52,42 @@ public fun ChatTimelineState.modelOptions(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) +} + +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, + ) +} + /** * Which model the desktop would actually use. * diff --git a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/bitfun/mobile/core/feature/session/ConversationPresentation.kt b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/bitfun/mobile/core/feature/session/ConversationPresentation.kt index a40090d114..6719ac141c 100644 --- a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/bitfun/mobile/core/feature/session/ConversationPresentation.kt +++ b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/bitfun/mobile/core/feature/session/ConversationPresentation.kt @@ -5,6 +5,7 @@ import com.bitfun.mobile.core.domain.ChatTimelineItemType import com.bitfun.mobile.core.domain.ChatTimelineProjector import com.bitfun.mobile.core.domain.ChatTimelineState import com.bitfun.mobile.core.domain.ToolInputPolicy +import com.bitfun.mobile.core.domain.ToolQuestionPolicy import com.bitfun.mobile.core.domain.ToolStatusPolicy import com.bitfun.mobile.core.protocol.ChatMessageItemResponse import com.bitfun.mobile.core.protocol.RemoteToolStatusResponse @@ -81,8 +82,38 @@ public data class ToolCard public constructor( * preview to quote — the app asks in its own words. */ public val question: String?, + public val questions: List, public val actions: Set, -) +) { + public constructor( + id: String, + name: String, + phase: ToolPhase, + kind: ToolKind, + operation: ToolOperation, + target: String, + filePath: String, + fileLabel: String, + input: String, + output: String, + question: String?, + actions: Set, + ) : this( + id, + name, + phase, + kind, + operation, + target, + filePath, + fileLabel, + input, + output, + question, + emptyList(), + actions, + ) +} /** * One bubble in the conversation. @@ -223,6 +254,19 @@ internal fun toolCard(tool: RemoteToolStatusResponse): ToolCard { } else { null } + val questions = if (ToolStatusPolicy.isQuestion(tool)) { + ToolQuestionPolicy.parse(tool).map { spec -> + ToolQuestion( + index = spec.index, + header = spec.header, + question = spec.question, + options = spec.options.map { QuestionOption(it.label, it.description) }, + multiSelect = spec.multiSelect, + ) + } + } else { + emptyList() + } // Every action is addressed by tool id, so a tool without one gets none of // them: offering a button that cannot be delivered is worse than offering // nothing. The phase below is still shown, because that much is knowable. @@ -251,6 +295,7 @@ internal fun toolCard(tool: RemoteToolStatusResponse): ToolCard { input = ToolStatusPolicy.inputText(tool), output = ToolStatusPolicy.outputText(tool), question = question, + questions = questions, actions = actions, ) } diff --git a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/bitfun/mobile/core/feature/session/RemoteSessionStore.kt b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/bitfun/mobile/core/feature/session/RemoteSessionStore.kt index 72ec6ae009..a039e6bb92 100644 --- a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/bitfun/mobile/core/feature/session/RemoteSessionStore.kt +++ b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/bitfun/mobile/core/feature/session/RemoteSessionStore.kt @@ -10,15 +10,18 @@ import com.bitfun.mobile.core.domain.ChatTimelineStore import com.bitfun.mobile.core.domain.PollSessionResult import com.bitfun.mobile.core.domain.RemoteSession import com.bitfun.mobile.core.domain.SessionNaming +import com.bitfun.mobile.core.domain.SessionAgentTypes import com.bitfun.mobile.core.feature.connection.ConnectionPhase import com.bitfun.mobile.core.protocol.ActiveTurnSnapshotResponse import com.bitfun.mobile.core.protocol.ChatMessageItemResponse import com.bitfun.mobile.core.protocol.ChatMessageResponse import com.bitfun.mobile.core.protocol.CreateSessionResponse import com.bitfun.mobile.core.protocol.InitialSyncResponse +import com.bitfun.mobile.core.protocol.ModelCatalogResponse import com.bitfun.mobile.core.protocol.PollSessionResponse import com.bitfun.mobile.core.protocol.RemoteCommand import com.bitfun.mobile.core.protocol.RemotePermissionMode +import com.bitfun.mobile.core.protocol.RemoteModelCatalog import com.bitfun.mobile.core.protocol.CommandStatusResponse import com.bitfun.mobile.core.protocol.PermissionModeResponse import com.bitfun.mobile.core.protocol.SetSessionModelResponse @@ -36,6 +39,8 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.put import kotlin.time.Clock @@ -52,6 +57,8 @@ public class RemoteSessionStore internal constructor( private val timelineStore = ChatTimelineStore() private val controller = ChatSessionController.create(scope, RoomPoller(transport), ControllerCallbacks()) private var work: Job? = null + private var modelCatalog: RemoteModelCatalog? = null + private val locallyCreatedSessions: MutableMap = mutableMapOf() /** * The re-read of the transcript that follows a turn ending. @@ -77,6 +84,7 @@ public class RemoteSessionStore internal constructor( RemoteSessionIntent.Load, RemoteSessionIntent.Refresh -> load(current?.query.orEmpty(), current?.agentFilter ?: SessionAgentFilter.ALL) RemoteSessionIntent.LoadMore -> loadMore() + RemoteSessionIntent.LoadOlderMessages -> loadOlderMessages() is RemoteSessionIntent.Search -> load(intent.query, current?.agentFilter ?: SessionAgentFilter.ALL) is RemoteSessionIntent.SetAgentFilter -> load(current?.query.orEmpty(), intent.filter) @@ -97,6 +105,24 @@ public class RemoteSessionStore internal constructor( }, ), ) + is RemoteSessionIntent.AnswerStructuredQuestion -> runAction( + intent.sessionId, + RemoteCommand( + cmd = "answer_question", + toolId = intent.toolId, + answers = buildJsonObject { + intent.answers.forEach { answer -> + put( + answer.index.toString(), + when (val value = answer.value) { + is QuestionAnswerValue.Text -> JsonPrimitive(value.text) + is QuestionAnswerValue.Choice -> JsonArray(value.values.map(::JsonPrimitive)) + }, + ) + } + }, + ), + ) is RemoteSessionIntent.SendMessage -> sendMessage(intent) is RemoteSessionIntent.CancelTurn -> cancelTurn(intent) is RemoteSessionIntent.ApproveTool -> runAction(intent.sessionId, RemoteCommand(cmd = "confirm_tool", toolId = intent.toolId)) @@ -140,6 +166,7 @@ public class RemoteSessionStore internal constructor( return@launch } val page = listSessions(0, query, filter) + val catalog = loadModelCatalogIfNeeded() _state.value = RemoteSessionUiState.Ready( sessions = page.sessions, selectedSessionId = current?.selectedSessionId, @@ -150,6 +177,8 @@ public class RemoteSessionStore internal constructor( query = query, agentFilter = filter, hasMore = page.hasMore, + hasMoreMessages = current?.hasMoreMessages ?: false, + modelCatalog = catalog ?: current?.modelCatalog, ) markConnected() } catch (cancelled: CancellationException) { @@ -198,32 +227,70 @@ public class RemoteSessionStore internal constructor( private suspend fun listSessions(offset: Int, query: String, filter: SessionAgentFilter): SessionPage { val trimmedQuery = query.trim() - if (filter == SessionAgentFilter.ALL) { - val response = sendListSessions(PAGE_SIZE, offset, trimmedQuery) - return SessionPage(response.sessions.map(RemoteResponseMapper::session), response.hasMore) - } - // `list_sessions` cannot filter by agent type, so pages are pulled at the - // desktop's maximum window and narrowed here. Ported from - // `RemoteSessionManager.listSessions`, including the over-fetch that lets - // `hasMore` stay honest after filtering. + // `list_sessions` cannot apply either the mobile ACP visibility rule or + // the agent tab. Pull from the start until there are enough visible rows + // so an invisible server row never creates a short page or a dishonest + // `hasMore` result. val targetCount = offset + PAGE_SIZE val filtered = mutableListOf() var pageOffset = 0 var hasMore = true + val pageSize = if (filter == SessionAgentFilter.ALL) PAGE_SIZE else FILTER_PAGE_SIZE while (hasMore && filtered.size < targetCount) { - val response = sendListSessions(FILTER_PAGE_SIZE, pageOffset, trimmedQuery) + val response = sendListSessions(pageSize, pageOffset, trimmedQuery) val sessions = response.sessions.map(RemoteResponseMapper::session) - sessions.filterTo(filtered) { filter.matches(it.agentType) } + sessions.filterTo(filtered) { + SessionAgentTypes.isMobileVisible(it.agentType) && filter.matches(it.agentType) + } hasMore = response.hasMore - pageOffset += FILTER_PAGE_SIZE + pageOffset += sessions.size if (sessions.isEmpty()) break } + val serverIds = filtered.mapTo(mutableSetOf()) { it.id } + serverIds.forEach(locallyCreatedSessions::remove) + val projected = mergeLocallyCreated(filtered, trimmedQuery, filter) return SessionPage( - sessions = filtered.subList(minOf(offset, filtered.size), minOf(targetCount, filtered.size)).toList(), - hasMore = filtered.size > targetCount || hasMore, + sessions = projected.subList(minOf(offset, projected.size), minOf(targetCount, projected.size)).toList(), + hasMore = projected.size > targetCount || hasMore, ) } + private fun mergeLocallyCreated( + sessions: List, + query: String, + filter: SessionAgentFilter, + ): List { + val known = sessions.mapTo(mutableSetOf()) { it.id } + val local = locallyCreatedSessions.values.filter { session -> + session.id !in known && + SessionAgentTypes.isMobileVisible(session.agentType) && + filter.matches(session.agentType) && + (query.isEmpty() || session.title.contains(query, ignoreCase = true) || + session.workspaceName.orEmpty().contains(query, ignoreCase = true) || + session.workspacePath.orEmpty().contains(query, ignoreCase = true)) + } + return local + sessions + } + + /** + * A model catalog is useful before a session exists. Failure is deliberately + * non-fatal: older desktops may not implement this command, in which case + * the create screen hides the picker and every other remote feature remains + * available. + */ + private suspend fun loadModelCatalogIfNeeded(): RemoteModelCatalog? { + modelCatalog?.let { return it } + return try { + transport.send(RemoteCommand(cmd = "get_model_catalog")) + .catalog + ?.also { modelCatalog = it } + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Throwable) { + null + } + } + private suspend fun sendListSessions(limit: Int, offset: Int, query: String): SessionListResponse = transport.send( RemoteCommand( @@ -248,17 +315,19 @@ public class RemoteSessionStore internal constructor( _state.value = current?.copy(busy = true) ?: RemoteSessionUiState.Loading work = scope.launch { try { - val permission = openSession(normalized) + val opened = openSession(normalized) _state.value = RemoteSessionUiState.Ready( sessions = current?.sessions.orEmpty(), selectedSessionId = normalized, timeline = timelineStore.snapshot(), busy = false, - permissionMode = permission.mode, - permissionModeFailure = permission.failure, + permissionMode = opened.permission.mode, + permissionModeFailure = opened.permission.failure, query = current?.query.orEmpty(), agentFilter = current?.agentFilter ?: SessionAgentFilter.ALL, hasMore = current?.hasMore ?: false, + hasMoreMessages = opened.hasMoreMessages, + modelCatalog = modelCatalog ?: current?.modelCatalog, ) markConnected() } catch (cancelled: CancellationException) { @@ -270,14 +339,57 @@ public class RemoteSessionStore internal constructor( } /** Loads a session's history, starts polling it, and reports its permission mode. */ - private suspend fun openSession(sessionId: String): OpenedPermission { + private suspend fun openSession(sessionId: String): OpenedSession { val response = transport.send( RemoteCommand(cmd = "get_session_messages", sessionId = sessionId, limit = 100), ) timelineStore.reset(sessionId) timelineStore.setPersistedMessages(response.messages.map(RemoteResponseMapper::chatMessage)) controller.start(sessionId, ChatSessionCursor(0, response.messages.size, 0)) - return readPermissionMode() + return OpenedSession(readPermissionMode(), response.hasMore) + } + + private data class OpenedSession( + val permission: OpenedPermission, + val hasMoreMessages: Boolean, + ) + + private fun loadOlderMessages() { + val current = _state.value as? RemoteSessionUiState.Ready ?: return + val sessionId = current.selectedSessionId.orEmpty() + val beforeMessageId = current.timeline?.persistedMessages?.firstOrNull()?.id.orEmpty() + if (sessionId.isEmpty() || beforeMessageId.isEmpty() || !current.hasMoreMessages || current.busy) return + setBusy(current, true) + work?.cancel() + work = scope.launch { + try { + val response = transport.send( + RemoteCommand( + cmd = "get_session_messages", + sessionId = sessionId, + limit = 100, + beforeMessageId = beforeMessageId, + ), + ) + if (timelineStore.snapshot().sessionId != sessionId) return@launch + val visible = timelineStore.snapshot().persistedMessages + val visibleIds = visible.mapTo(mutableSetOf()) { it.id } + val older = response.messages + .map(RemoteResponseMapper::chatMessage) + .filterNot { it.id in visibleIds } + timelineStore.setPersistedMessages(older + visible) + val ready = (_state.value as? RemoteSessionUiState.Ready) ?: current + _state.value = ready.copy( + timeline = timelineStore.snapshot(), + busy = false, + hasMoreMessages = response.hasMore, + ) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Throwable) { + setBusy((_state.value as? RemoteSessionUiState.Ready) ?: current, false) + } + } } /** @@ -316,20 +428,22 @@ public class RemoteSessionStore internal constructor( _state.value = current?.copy(busy = true) ?: RemoteSessionUiState.Loading work = scope.launch { try { - // Always re-read, unlike everywhere else that reuses the cached - // path: the new-session screen can switch the desktop's - // workspace seconds before this, and a stale path would file the - // session under the workspace the user just navigated away from. - if (!resolveWorkspacePath()) { + val requestedWorkspacePath = intent.workspacePath?.trim().orEmpty() + // An explicit path is the cross-workspace sidebar flow: creating + // there must not change the desktop's active workspace. The + // ordinary create flow still re-reads the active path so a + // recent workspace selection cannot race a cached value. + if (requestedWorkspacePath.isEmpty() && !resolveWorkspacePath()) { failKnown(RemoteSessionFailureReason.NO_WORKSPACE, current) return@launch } + val targetWorkspacePath = requestedWorkspacePath.ifEmpty { workspacePath } val created = transport.send( RemoteCommand( cmd = "create_session", agentType = intent.agentType, sessionName = SessionNaming.wireSessionName(intent.agentType, intent.title), - workspacePath = workspacePath, + workspacePath = targetWorkspacePath, ), ) val sessionId = created.resolvedSessionId?.trim().orEmpty() @@ -342,7 +456,7 @@ public class RemoteSessionStore internal constructor( RemoteCommand(cmd = "set_session_model", sessionId = sessionId, modelId = modelId), ) } - val permission = openSession(sessionId) + val opened = openSession(sessionId) intent.instruction.trim().takeIf(String::isNotEmpty)?.let { instruction -> val sent = transport.send( RemoteCommand(cmd = "send_message", sessionId = sessionId, content = instruction), @@ -350,17 +464,32 @@ public class RemoteSessionStore internal constructor( sent.turnId?.let(timelineStore::setLocalActiveTurn) controller.nudge() } + val now = Clock.System.now().toString() + locallyCreatedSessions[sessionId] = RemoteSession( + id = sessionId, + title = created.title?.takeIf(String::isNotBlank) + ?: SessionNaming.fallbackTitle(intent.agentType), + agentType = intent.agentType, + status = "active", + updatedAt = now, + createdAt = now, + messageCount = if (intent.instruction.isBlank()) 0 else 1, + workspacePath = targetWorkspacePath, + workspaceName = null, + ) val page = listSessions(0, current?.query.orEmpty(), current?.agentFilter ?: SessionAgentFilter.ALL) _state.value = RemoteSessionUiState.Ready( sessions = page.sessions, selectedSessionId = sessionId, timeline = timelineStore.snapshot(), busy = false, - permissionMode = permission.mode, - permissionModeFailure = permission.failure, + permissionMode = opened.permission.mode, + permissionModeFailure = opened.permission.failure, query = current?.query.orEmpty(), agentFilter = current?.agentFilter ?: SessionAgentFilter.ALL, hasMore = page.hasMore, + hasMoreMessages = opened.hasMoreMessages, + modelCatalog = modelCatalog ?: current?.modelCatalog, ) markConnected() } catch (cancelled: CancellationException) { @@ -382,6 +511,7 @@ public class RemoteSessionStore internal constructor( transport.send( RemoteCommand(cmd = "delete_session", sessionId = normalized), ) + locallyCreatedSessions.remove(normalized) val closingOpenSession = current.selectedSessionId == normalized if (closingOpenSession) { controller.stop() @@ -438,9 +568,15 @@ public class RemoteSessionStore internal constructor( private fun updateTimeline(snapshot: ChatSessionSnapshot) { if (snapshot.sessionId != timelineStore.snapshot().sessionId) return timelineStore.applySnapshot(snapshot) + snapshot.modelCatalog?.let { catalog -> + if (catalog.version > 0L || catalog.models.isNotEmpty()) modelCatalog = catalog + } val current = _state.value if (current is RemoteSessionUiState.Ready) { - _state.value = current.copy(timeline = timelineStore.snapshot()) + _state.value = current.copy( + timeline = timelineStore.snapshot(), + modelCatalog = modelCatalog ?: current.modelCatalog, + ) markConnected() } if (snapshot.shouldSyncAfterTurnEnded) syncAfterTurnEnded(snapshot.sessionId) @@ -478,7 +614,10 @@ public class RemoteSessionStore internal constructor( controller.updateCursor(cursor) val current = _state.value if (current is RemoteSessionUiState.Ready) { - _state.value = current.copy(timeline = timelineStore.snapshot()) + _state.value = current.copy( + timeline = timelineStore.snapshot(), + hasMoreMessages = response.hasMore, + ) } } catch (cancelled: CancellationException) { throw cancelled diff --git a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/bitfun/mobile/core/feature/session/RemoteSessionUiState.kt b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/bitfun/mobile/core/feature/session/RemoteSessionUiState.kt index 8c9168913b..d0b4f81a38 100644 --- a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/bitfun/mobile/core/feature/session/RemoteSessionUiState.kt +++ b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/bitfun/mobile/core/feature/session/RemoteSessionUiState.kt @@ -3,6 +3,7 @@ package com.bitfun.mobile.core.feature.session import com.bitfun.mobile.core.domain.ChatTimelineState import com.bitfun.mobile.core.domain.RemoteSession import com.bitfun.mobile.core.domain.SessionAgentTypes +import com.bitfun.mobile.core.protocol.RemoteModelCatalog /** * Which agent kinds the session list is narrowed to. @@ -98,6 +99,16 @@ public sealed interface RemoteSessionUiState { public val agentFilter: SessionAgentFilter, /** Whether another `list_sessions` page is worth asking for. */ public val hasMore: Boolean, + /** Whether the open transcript has an older page above what is visible. */ + public val hasMoreMessages: Boolean, + /** + * Desktop model choices are account/device facts, not session facts. + * + * Keeping the catalog beside the session list lets a new-session surface + * offer the same model picker before any transcript has been opened. + * Older peers that do not expose the command simply leave this null. + */ + public val modelCatalog: RemoteModelCatalog?, ) : RemoteSessionUiState /** @@ -123,6 +134,9 @@ public sealed interface RemoteSessionIntent { /** Fetch the next page of the session list, keeping what is already shown. */ public data object LoadMore : RemoteSessionIntent + /** Fetch the transcript page immediately before the oldest visible message. */ + public data object LoadOlderMessages : RemoteSessionIntent + public data class Search public constructor( public val query: String, ) : RemoteSessionIntent @@ -147,8 +161,16 @@ public sealed interface RemoteSessionIntent { public val title: String, public val instruction: String, public val modelId: String?, + public val workspacePath: String?, ) : RemoteSessionIntent { - public constructor(agentType: String) : this(agentType, "", "", null) + public constructor( + agentType: String, + title: String, + instruction: String, + modelId: String?, + ) : this(agentType, title, instruction, modelId, null) + + public constructor(agentType: String) : this(agentType, "", "", null, null) } public data class DeleteSession public constructor( @@ -167,6 +189,12 @@ public sealed interface RemoteSessionIntent { public val answer: String, ) : RemoteSessionIntent + public data class AnswerStructuredQuestion public constructor( + public val sessionId: String, + public val toolId: String, + public val answers: List, + ) : RemoteSessionIntent + public data class SendMessage public constructor( public val sessionId: String, public val content: String, diff --git a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/bitfun/mobile/core/feature/session/ToolQuestion.kt b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/bitfun/mobile/core/feature/session/ToolQuestion.kt new file mode 100644 index 0000000000..f42ea0f475 --- /dev/null +++ b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/bitfun/mobile/core/feature/session/ToolQuestion.kt @@ -0,0 +1,29 @@ +package com.bitfun.mobile.core.feature.session + +public data class QuestionOption public constructor( + public val label: String, + public val description: String?, +) + +public data class ToolQuestion public constructor( + public val index: Int, + public val header: String, + public val question: String, + public val options: List, + public val multiSelect: Boolean, +) + +public sealed interface QuestionAnswerValue { + public data class Text public constructor( + public val text: String, + ) : QuestionAnswerValue + + public data class Choice public constructor( + public val values: List, + ) : QuestionAnswerValue +} + +public data class QuestionAnswer public constructor( + public val index: Int, + public val value: QuestionAnswerValue, +) diff --git a/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/bitfun/mobile/core/feature/account/AccountStoreTest.kt b/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/bitfun/mobile/core/feature/account/AccountStoreTest.kt index 8d4b794b8b..54b42b0a13 100644 --- a/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/bitfun/mobile/core/feature/account/AccountStoreTest.kt +++ b/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/bitfun/mobile/core/feature/account/AccountStoreTest.kt @@ -100,6 +100,25 @@ class AccountStoreTest { assertEquals("desktop-1", failed.selectedDeviceId) } + @Test + fun expiredRefreshClearsDevicesAndThePersistedSession() = runTest { + val secure = MemorySecureStore() + val backend = FakeAccountBackend() + val store = AccountStore.create(this, backend, secure, "phone-1", "Android") + store.dispatch(AccountIntent.Login("https://relay.test", "user", "password")) + advanceUntilIdle() + assertTrue(assertIs(store.state.value).devices.isNotEmpty()) + + backend.listFailure = CloudAccountFailure.AUTHENTICATION + store.dispatch(AccountIntent.RefreshDevices) + advanceUntilIdle() + + val failed = assertIs(store.state.value) + assertEquals(AccountFailureReason.AUTHENTICATION, failed.reason) + assertNull(secure.read("cloud_account_session")) + assertNull(store.createSessionStore(this)) + } + @Test fun signInWithNothingOnlinePicksNoTarget() = runTest { val backend = FakeAccountBackend().also { it.desktop1Online = false } diff --git a/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/bitfun/mobile/core/feature/layout/SettingsPlacementPolicyTest.kt b/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/bitfun/mobile/core/feature/layout/SettingsPlacementPolicyTest.kt new file mode 100644 index 0000000000..5951857239 --- /dev/null +++ b/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/bitfun/mobile/core/feature/layout/SettingsPlacementPolicyTest.kt @@ -0,0 +1,105 @@ +package com.bitfun.mobile.core.feature.layout + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse + +class SettingsPlacementPolicyTest { + @Test + fun phoneUsesBottomSheet() { + assertEquals(SettingsPlacementMode.BOTTOM, resolve(width = 390, height = 844).mode) + } + + @Test + fun tabletUsesClampedTrailingSideSheet() { + assertEquals(SettingsPlacement(SettingsPlacementMode.SIDE, 400, 760, 0), resolve(1024, 1366)) + assertEquals(SettingsPlacement(SettingsPlacementMode.SIDE, 520, 760, 0), resolve(2000, 1200)) + } + + @Test + fun scarceLandscapeUsesWiderSideSheet() { + assertEquals(SettingsPlacement(SettingsPlacementMode.SIDE, 360, 560, 0), resolve(800, 500)) + } + + @Test + fun sideSheetFitsTrailingPhysicalLeaf() { + val input = input(1000, 800, vertical = listOf(WindowCrease(480, 20))) + val placement = SettingsPlacementPolicy.resolve(input, SettingsSheetKind.SETTINGS) + assertEquals(SettingsPlacement(SettingsPlacementMode.SIDE, 500, 720, 0), placement) + assertFalse( + SettingsPlacementPolicy.sheetIntersectsVerticalCrease( + placement, + input.verticalCreases[0], + 1000, + false, + ), + ) + } + + @Test + fun rtlSideSheetFitsLeadingPhysicalLeaf() { + val input = input(1000, 800, vertical = listOf(WindowCrease(480, 20)), isRtl = true) + val placement = SettingsPlacementPolicy.resolve(input, SettingsSheetKind.SETTINGS) + assertEquals(SettingsPlacement(SettingsPlacementMode.SIDE, 480, 720, 0), placement) + assertEquals(0, SettingsPlacementPolicy.sheetLeft(placement, 1000, isRtl = true)) + } + + @Test + fun hoverUsesOperateRegionAndKindCap() { + val input = input( + width = 700, + height = 900, + hover = true, + horizontal = listOf(HorizontalWindowCrease(420, 20)), + ) + assertEquals( + SettingsPlacement(SettingsPlacementMode.FOLD_OPERATE, 0, 460, 460), + SettingsPlacementPolicy.resolve(input, SettingsSheetKind.SETTINGS), + ) + assertEquals( + SettingsPlacement(SettingsPlacementMode.FOLD_OPERATE, 0, 460, 460), + SettingsPlacementPolicy.resolve(input, SettingsSheetKind.SESSION_DETAILS), + ) + } + + @Test + fun invalidCreaseFallsBackWithoutExceedingViewport() { + val placement = SettingsPlacementPolicy.resolve( + input(640, 900, vertical = listOf(WindowCrease(640, 20))), + SettingsSheetKind.SETTINGS, + ) + assertEquals(SettingsPlacement(SettingsPlacementMode.SIDE, 400, 760, 0), placement) + } + + @Test + fun foldedWindowUsesBottomEvenWhenWide() { + val placement = SettingsPlacementPolicy.resolve( + input(900, 900, folded = true), + SettingsSheetKind.REMOTE_VIEW_SETTINGS, + ) + assertEquals(SettingsPlacement(SettingsPlacementMode.BOTTOM, 0, 520, 0), placement) + } + + private fun resolve(width: Int, height: Int): SettingsPlacement = + SettingsPlacementPolicy.resolve(input(width, height), SettingsSheetKind.SETTINGS) + + private fun input( + width: Int, + height: Int, + folded: Boolean = false, + hover: Boolean = false, + vertical: List = emptyList(), + horizontal: List = emptyList(), + isRtl: Boolean = false, + ): AdaptiveLayoutInput = AdaptiveLayoutInput( + viewportWidth = width, + viewportHeight = height, + isFolded = folded, + isExpandedFoldable = vertical.isNotEmpty() || horizontal.isNotEmpty(), + isHoverOperate = hover, + wideLayoutMatched = width >= ConversationLayoutPolicy.MD_MIN_WIDTH, + verticalCreases = vertical, + horizontalCreases = horizontal, + isRtl = isRtl, + ) +} diff --git a/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/bitfun/mobile/core/feature/session/ConversationPresentationTest.kt b/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/bitfun/mobile/core/feature/session/ConversationPresentationTest.kt index 67259a7cfe..8a3f83ade7 100644 --- a/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/bitfun/mobile/core/feature/session/ConversationPresentationTest.kt +++ b/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/bitfun/mobile/core/feature/session/ConversationPresentationTest.kt @@ -7,6 +7,7 @@ import com.bitfun.mobile.core.domain.ChatTimelineState import com.bitfun.mobile.core.protocol.ChatMessageItemResponse import com.bitfun.mobile.core.protocol.RemoteModelCatalog import com.bitfun.mobile.core.protocol.RemoteToolStatusResponse +import com.bitfun.mobile.core.protocol.RelayJson import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue @@ -24,10 +25,10 @@ class ConversationPresentationTest { val optimistic = timeline(optimistic = listOf(message("local-1", "user", "ship it"))) assertEquals(listOf(true), optimistic.conversationRows().map { it.pending }) - // Same text persisted: one row, no longer pending. Reading + // The same message identity persisted: one row, no longer pending. Reading // persistedMessages directly would have shown it twice. val persisted = timeline( - persisted = listOf(message("remote-1", "user", "ship it")), + persisted = listOf(message("local-1", "user", "ship it")), optimistic = listOf(message("local-1", "user", "ship it")), ) val rows = persisted.conversationRows() @@ -97,6 +98,34 @@ class ConversationPresentationTest { assertEquals("Which branch?", card.question) } + @Test + fun aStructuredQuestionKeepsTheLegacyPromptAndExposesChoices() { + val card = cardsFor( + tool( + name = "AskUserQuestion", + status = "sent", + inputPreview = "Which branch?", + toolInput = RelayJson.parseToJsonElement( + """{"questions":[{"header":"Dropped","options":[{"label":"ignored"}]},{"header":"Branch","question":"Which branch?","options":[{"label":"main","description":"Stable"},{"label":"dev"}],"multiSelect":true}]}""", + ), + ), + ).single() + + assertEquals("Which branch?", card.question) + assertEquals( + listOf( + ToolQuestion( + index = 1, + header = "Branch", + question = "Which branch?", + options = listOf(QuestionOption("main", "Stable"), QuestionOption("dev", null)), + multiSelect = true, + ), + ), + card.questions, + ) + } + @Test fun aFinishedToolOffersNothing() { val card = cardsFor(tool(status = "completed", resultPreview = "ok")).single() @@ -189,10 +218,12 @@ private fun tool( status: String, inputPreview: String? = null, resultPreview: String? = null, + toolInput: kotlinx.serialization.json.JsonElement? = null, ) = RemoteToolStatusResponse( id = id, name = name, status = status, inputPreview = inputPreview, resultPreview = resultPreview, + toolInput = toolInput, ) diff --git a/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/bitfun/mobile/core/feature/session/RemoteSessionStoreTest.kt b/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/bitfun/mobile/core/feature/session/RemoteSessionStoreTest.kt index a58a8f317b..954dd74ffb 100644 --- a/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/bitfun/mobile/core/feature/session/RemoteSessionStoreTest.kt +++ b/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/bitfun/mobile/core/feature/session/RemoteSessionStoreTest.kt @@ -15,6 +15,7 @@ import kotlinx.coroutines.test.runTest import kotlinx.serialization.DeserializationStrategy import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertNull import kotlin.test.assertIs import kotlin.test.assertTrue @@ -38,6 +39,19 @@ class RemoteSessionStoreTest { assertEquals(30, list.limit) assertEquals(0, list.offset) assertNull(list.query) + assertEquals("model-primary", ready.modelCatalog?.defaultModels?.primary) + } + + @Test + fun hidesDesktopOnlyAcpSessions() = runTest { + val transport = FakeSessionTransport() + val store = RemoteSessionStore.create(this, transport) + + store.dispatch(RemoteSessionIntent.Load) + advanceUntilIdle() + + val ready = assertIs(store.state.value) + assertEquals(listOf("s-code", "s-cowork", "s-agentic"), ready.sessions.map { it.id }) } @Test @@ -88,19 +102,16 @@ class RemoteSessionStoreTest { } @Test - fun loadMoreAppendsTheNextPageWithoutRepeatingRows() = runTest { + fun initialLoadFollowsServerPagesWithoutRepeatingRows() = runTest { val transport = FakeSessionTransport() transport.paged = true val store = RemoteSessionStore.create(this, transport) store.dispatch(RemoteSessionIntent.Load) advanceUntilIdle() - assertTrue(assertIs(store.state.value).hasMore) - - store.dispatch(RemoteSessionIntent.LoadMore) - advanceUntilIdle() val ready = assertIs(store.state.value) assertEquals(listOf("page-0", "page-1"), ready.sessions.map { it.id }) + assertFalse(ready.hasMore) assertEquals(1, transport.commands.last { it.cmd == "list_sessions" }.offset) } @@ -123,6 +134,28 @@ class RemoteSessionStoreTest { store.dispatch(RemoteSessionIntent.Stop) } + @Test + fun loadOlderMessagesPrependsThePreviousTranscriptPage() = runTest { + val transport = FakeSessionTransport() + transport.messages = """[{"id":"m-new","role":"assistant","content":"new"}]""" + transport.olderMessages = """[{"id":"m-old","role":"user","content":"old"}]""" + val store = RemoteSessionStore.create(this, transport) + + store.dispatch(RemoteSessionIntent.Open("s-code")) + runCurrent() + assertTrue(assertIs(store.state.value).hasMoreMessages) + + store.dispatch(RemoteSessionIntent.LoadOlderMessages) + runCurrent() + + val ready = assertIs(store.state.value) + assertEquals(listOf("m-old", "m-new"), ready.timeline?.persistedMessages?.map { it.id }) + assertEquals(false, ready.hasMoreMessages) + val request = transport.commands.last { it.cmd == "get_session_messages" } + assertEquals("m-new", request.beforeMessageId) + store.dispatch(RemoteSessionIntent.Stop) + } + @Test fun createSessionUsesTheWorkspaceTheDesktopIsOnNow() = runTest { val transport = FakeSessionTransport() @@ -141,6 +174,36 @@ class RemoteSessionStoreTest { store.dispatch(RemoteSessionIntent.Stop) } + @Test + fun crossWorkspaceCreateDoesNotSwitchTheDesktopAndStaysProjected() = runTest { + val transport = FakeSessionTransport() + val store = RemoteSessionStore.create(this, transport) + store.dispatch(RemoteSessionIntent.Load) + advanceUntilIdle() + transport.commands.clear() + + store.dispatch( + RemoteSessionIntent.CreateSession( + agentType = "code", + title = "", + instruction = "", + modelId = null, + workspacePath = "/other", + ), + ) + runCurrent() + + assertTrue(transport.commands.none { it.cmd == "get_workspace_info" }) + assertEquals("/other", transport.commands.first { it.cmd == "create_session" }.workspacePath) + val created = assertIs(store.state.value) + .sessions.first { it.id == "s-new" } + assertEquals("/other", created.workspacePath) + store.dispatch(RemoteSessionIntent.Refresh) + runCurrent() + assertTrue(assertIs(store.state.value).sessions.any { it.id == "s-new" }) + store.dispatch(RemoteSessionIntent.Stop) + } + @Test fun deleteSessionDropsTheRowAndClosesTheOpenConversation() = runTest { val transport = FakeSessionTransport() @@ -191,6 +254,30 @@ class RemoteSessionStoreTest { assertEquals("""{"answer":"yes","0":"yes"}""", answer.answers.toString()) } + @Test + fun structuredQuestionAnswersUseIndexedTextAndChoiceValues() = runTest { + val transport = FakeSessionTransport() + val store = RemoteSessionStore.create(this, transport) + store.dispatch(RemoteSessionIntent.Load) + advanceUntilIdle() + + store.dispatch( + RemoteSessionIntent.AnswerStructuredQuestion( + "s-code", + "tool-1", + listOf( + QuestionAnswer(0, QuestionAnswerValue.Text("yes")), + QuestionAnswer(1, QuestionAnswerValue.Choice(listOf("a", "b"))), + ), + ), + ) + advanceUntilIdle() + + val answer = transport.commands.first { it.cmd == "answer_question" } + assertEquals("tool-1", answer.toolId) + assertEquals("""{"0":"yes","1":["a","b"]}""", answer.answers.toString()) + } + @Test fun theDesktopsOwnRejectionReachesTheScreen() = runTest { val transport = FakeSessionTransport() @@ -320,7 +407,7 @@ class RemoteSessionStoreTest { assertTrue(transport.commands.count { it.cmd == "get_session_messages" } >= 2) val ready = assertIs(store.state.value) - assertNull(ready.timeline?.activeTurn) + assertNull(ready.timeline?.activeTurn, ready.timeline.toString()) // The re-read replaced the transcript wholesale, so the next poll is // asked to describe everything rather than a delta from a spent version. assertEquals(0, transport.commands.last { it.cmd == "poll_session" }.sinceVersion) @@ -372,6 +459,9 @@ private class FakeSessionTransport : RemoteCommandTransport { /** What `get_session_messages` is holding right now, re-read on every call. */ var messages: String = "[]" + /** Optional previous page, served only for a cursor-bearing message request. */ + var olderMessages: String? = null + override suspend fun send( deserializer: DeserializationStrategy, command: RemoteCommand, @@ -391,8 +481,23 @@ private class FakeSessionTransport : RemoteCommandTransport { val json = when (command.cmd) { "get_workspace_info" -> """{"resp":"ok","has_workspace":${workspacePath.isNotEmpty()},"path":"$workspacePath"}""" + "get_model_catalog" -> """{ + "resp":"ok", + "catalog":{ + "version":7, + "models":[{ + "id":"model-primary","name":"Primary","provider":"account", + "base_url":"","model_name":"primary","enabled":true + }], + "default_models":{"primary":"model-primary"} + } + }""".trimIndent() "list_sessions" -> if (paged) pagedSessions(command.offset ?: 0) else allSessions() - "get_session_messages" -> """{"resp":"ok","messages":$messages,"has_more":false}""" + "get_session_messages" -> if (command.beforeMessageId != null) { + """{"resp":"ok","messages":${olderMessages ?: "[]"},"has_more":false}""" + } else { + """{"resp":"ok","messages":$messages,"has_more":${olderMessages != null}}""" + } "get_permission_mode" -> """{"resp":"ok","mode":"ask"}""" "poll_session" -> polls[minOf(pollIndex++, polls.lastIndex)] "create_session" -> """{"resp":"ok","session_id":"s-new"}""" @@ -408,7 +513,8 @@ private class FakeSessionTransport : RemoteCommandTransport { {"resp":"ok","has_more":false,"sessions":[ {"id":"s-code","title":"Code","agent_type":"code"}, {"id":"s-cowork","title":"Cowork","agent_type":"cowork"}, - {"id":"s-agentic","title":"Legacy","agent_type":"agentic"} + {"id":"s-agentic","title":"Legacy","agent_type":"agentic"}, + {"id":"s-acp","title":"Desktop ACP","agent_type":"acp:codex"} ]} """.trimIndent() diff --git a/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/bitfun/mobile/core/feature/shell/RemoteSidebarPresentationTest.kt b/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/bitfun/mobile/core/feature/shell/RemoteSidebarPresentationTest.kt index 1c5ec69cfe..6e5eb40725 100644 --- a/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/bitfun/mobile/core/feature/shell/RemoteSidebarPresentationTest.kt +++ b/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/bitfun/mobile/core/feature/shell/RemoteSidebarPresentationTest.kt @@ -70,6 +70,8 @@ class RemoteSidebarPresentationTest { query = "", agentFilter = SessionAgentFilter.ALL, hasMore = false, + hasMoreMessages = false, + modelCatalog = null, ) private fun selected(path: String, name: String) = SelectedWorkspace(path, name, "main", "git", null) diff --git a/src/apps/mobile/shared/core-transport/src/commonMain/kotlin/com/bitfun/mobile/core/transport/CloudAccountClient.kt b/src/apps/mobile/shared/core-transport/src/commonMain/kotlin/com/bitfun/mobile/core/transport/CloudAccountClient.kt index 8997cd8d37..307edb700d 100644 --- a/src/apps/mobile/shared/core-transport/src/commonMain/kotlin/com/bitfun/mobile/core/transport/CloudAccountClient.kt +++ b/src/apps/mobile/shared/core-transport/src/commonMain/kotlin/com/bitfun/mobile/core/transport/CloudAccountClient.kt @@ -43,6 +43,18 @@ private const val DEVICE_KIND_DESKTOP = "desktop" */ private const val DEVICE_KIND_MOBILE = "mobile" +/** + * Names used by our pre-`device_kind` mobile clients. + * + * This belongs to the shared transport rather than an Android/iOS adapter: both + * native clients can receive the same legacy account rows, and both must hide + * them from the desktop target picker. + */ +private val KNOWN_NON_DESKTOP_DEVICE_NAMES = setOf( + "HarmonyOS Phone", + "HarmonyOS Watch", +) + /** * Whether a relay device row is a desktop, and so controllable from a phone. * @@ -130,9 +142,10 @@ public class CloudAccountClient internal constructor( private val log: TransportLog = TransportLog.None, legacyMobileDeviceNames: Set = emptySet(), ) { - private val normalizedLegacyMobileDeviceNames = legacyMobileDeviceNames.mapTo(mutableSetOf()) { - it.trim().lowercase() - } + private val normalizedLegacyMobileDeviceNames = + (KNOWN_NON_DESKTOP_DEVICE_NAMES + legacyMobileDeviceNames).mapTo(mutableSetOf()) { + it.trim().lowercase() + } public suspend fun login( relayUrl: String, diff --git a/src/apps/mobile/shared/core-transport/src/commonTest/kotlin/com/bitfun/mobile/core/transport/CloudAccountClientTest.kt b/src/apps/mobile/shared/core-transport/src/commonTest/kotlin/com/bitfun/mobile/core/transport/CloudAccountClientTest.kt index 0bb4b9e0ad..22aee8460a 100644 --- a/src/apps/mobile/shared/core-transport/src/commonTest/kotlin/com/bitfun/mobile/core/transport/CloudAccountClientTest.kt +++ b/src/apps/mobile/shared/core-transport/src/commonTest/kotlin/com/bitfun/mobile/core/transport/CloudAccountClientTest.kt @@ -79,6 +79,8 @@ class CloudAccountClientTest { {"device_id":"phone-2","device_name":"Pixel 8","online":true,"device_kind":"mobile"}, {"device_id":"watch-1","device_name":"Watch","online":false,"device_kind":"watch"}, {"device_id":"phone-1","device_name":"Pixel 8","online":true}, + {"device_id":"harmony-phone","device_name":"HarmonyOS Phone","online":true}, + {"device_id":"harmony-watch","device_name":"HarmonyOS Watch","online":false}, {"device_id":"phone-3","device_name":"Legacy Phone","online":true}, {"device_id":"watch-2","device_name":"Legacy Watch","online":false}, {"device_id":"legacy-1","device_name":"DESKTOP-KM3L4UI","online":false,"last_seen_at":9} From d6872ce3ded0836981956b66fdaec4708d38a184 Mon Sep 17 00:00:00 2001 From: wgqqqqq Date: Thu, 27 Aug 2026 09:22:52 +0800 Subject: [PATCH 2/5] fix(mobile): align Android streaming and visual parity - timeline: streaming message order, scroll anchoring, and dedup of repeated/older paged items - protocol: normalize agent_type/version mapping and model catalog contracts across shared core - persistence: force-stop draft restore, model/error state, and migration (2.sqm) coverage - tools: subagent, approval/edit, and permission-mode rendering - drawer: compact drawer and sidebar performance improvements - parity: Android and HarmonyOS preview scenarios, snapshots, and focused unit/instrumentation tests Co-authored-by: BitFun <318544290+bitfun-ai@users.noreply.github.com> --- .../mobile/app/AccountRemoteScreenTest.kt | 30 ++ .../mobile/app/ChatMessageBubbleTest.kt | 199 +++++++- .../mobile/app/ConversationHeaderTest.kt | 21 + .../bitfun/mobile/app/ConversationViewTest.kt | 171 ++++++- .../app/MobileDesignPreviewVisualTest.kt | 179 +++++++ .../app/MobilePreviewStatesVisualTest.kt | 141 ++++++ .../com/bitfun/mobile/app/MobileScreenTest.kt | 16 +- .../mobile/app/PermissionModeCardTest.kt | 128 ++++++ .../mobile/app/RemoteSettingsSheetTest.kt | 99 ++++ .../mobile/app/ToolConfirmationPanelTest.kt | 35 ++ .../bitfun/mobile/app/ToolStatusRowTest.kt | 32 ++ .../app/platform/AppLocaleController.kt | 20 +- .../mobile/app/platform/WindowMetrics.kt | 75 ++- .../bitfun/mobile/app/state/AppShellState.kt | 14 + .../mobile/app/ui/account/AccountScreen.kt | 19 +- .../mobile/app/ui/chat/ChatMessageBubble.kt | 9 +- .../mobile/app/ui/chat/ChatStatusBar.kt | 15 +- .../bitfun/mobile/app/ui/chat/ComposerBar.kt | 50 +- .../mobile/app/ui/chat/ConversationHeader.kt | 13 +- .../app/ui/chat/ConversationTimelineView.kt | 49 +- .../mobile/app/ui/chat/ConversationView.kt | 126 +++-- .../mobile/app/ui/chat/FileReferenceCards.kt | 5 +- .../bitfun/mobile/app/ui/chat/InlineImage.kt | 5 +- .../mobile/app/ui/chat/MarkdownContent.kt | 27 +- .../app/ui/chat/message/MessageBlockList.kt | 65 ++- .../app/ui/chat/tool/ToolInteractionPanels.kt | 4 + .../mobile/app/ui/chat/tool/ToolStatusList.kt | 4 +- .../app/ui/common/AdaptiveModalSurface.kt | 4 +- .../mobile/app/ui/common/CircleControl.kt | 1 + .../ui/common/SignedOutConnectionActions.kt | 13 +- .../app/ui/preview/MobileDesignGallery.kt | 25 +- .../app/ui/preview/MobilePreviewStates.kt | 189 ++++++++ .../ui/remote/ConnectAccountDeviceScreen.kt | 31 +- .../app/ui/remote/FilePreviewSurface.kt | 32 +- .../app/ui/remote/RemoteSessionListView.kt | 7 - .../app/ui/remote/SessionActionSheet.kt | 42 +- .../app/ui/settings/ModelServiceScreen.kt | 9 +- .../app/ui/settings/PermissionModeCard.kt | 12 +- .../app/ui/settings/RemoteSettingsSheet.kt | 117 ++++- .../app/ui/shell/BitFunCompactDrawer.kt | 137 +++++- .../mobile/app/ui/shell/MobileScreen.kt | 10 +- .../mobile/app/ui/shell/sidebar/AppSidebar.kt | 59 +++ .../sidebar/RemoteWorkspacePathPolicy.kt | 14 + .../app/ui/shell/sidebar/SidebarChrome.kt | 3 +- .../app/ui/shell/sidebar/SidebarFooter.kt | 11 +- .../app/ui/shell/sidebar/SidebarHeader.kt | 11 +- .../sidebar/SidebarRemoteWorkspaceSection.kt | 176 ++++++- .../ui/shell/sidebar/SidebarSessionList.kt | 15 +- .../mobile/app/ui/theme/BitFunMotion.kt | 19 +- .../app/src/main/res/values-zh/strings.xml | 6 + .../app/src/main/res/values/strings.xml | 6 + .../app/platform/AppLocaleResolverTest.kt | 21 + .../mobile/app/platform/FoldFactsTest.kt | 115 +++++ .../app/ui/chat/ComposerBreakpointTest.kt | 30 ++ .../ui/chat/ConversationComposerDraftTest.kt | 34 ++ .../ui/chat/ConversationScrollPolicyTest.kt | 59 +++ .../ui/chat/tool/ToolInteractionPanelsTest.kt | 7 + .../ui/shell/ContentCardCoordinatorTest.kt | 100 ++++ .../sidebar/RemoteWorkspacePathPolicyTest.kt | 42 ++ .../preview/preview-scenarios.smoke.test.mjs | 63 +++ .../design-system/preview/snapshots/README.md | 4 + .../main/ets/entryability/EntryAbility.ets | 25 +- .../ets/pages/preview/MobileDesignGallery.ets | 41 +- .../test/MobilePreviewScenarioUnit.test.ets | 86 ++++ .../core/domain/ChatTimelineProjector.kt | 80 ++-- .../mobile/core/domain/ChatTimelineStore.kt | 264 +++++++++-- .../mobile/core/domain/ToolStatusPolicy.kt | 18 +- .../core/domain/ChatTimelineProjectorTest.kt | 70 ++- .../core/domain/ChatTimelineStoreTest.kt | 435 +++++++++++++++++- .../core/domain/ToolStatusPolicyTest.kt | 30 +- .../feature/account/AccountStore.android.kt | 19 +- .../feature/pairing/PairingStore.android.kt | 17 +- .../core/feature/account/AccountStore.kt | 12 +- .../core/feature/pairing/PairingStore.kt | 15 +- .../session/ConversationPresentation.kt | 4 + .../feature/session/ModelCatalogContract.kt | 19 + .../session/RemoteSessionFailureMapping.kt | 6 +- .../feature/session/RemoteSessionStore.kt | 307 +++++++++++- .../feature/session/RemoteSessionUiState.kt | 80 +++- .../session/ToolApprovalEditContract.kt | 19 + .../ConversationModelPresentationTest.kt | 1 + .../session/ConversationPresentationTest.kt | 40 ++ .../session/RemoteResponseMapperTest.kt | 59 +++ .../session/RemoteSessionPersistenceTest.kt | 211 +++++++++ .../feature/session/RemoteSessionStoreTest.kt | 260 ++++++++++- .../core/feature/account/AccountStore.ios.kt | 19 +- .../core/feature/pairing/PairingStore.ios.kt | 17 +- .../shared/core-persistence/build.gradle.kts | 2 + .../mobile/core/persistence/ChatLocalStore.kt | 170 ++++++- .../bitfun/mobile/core/persistence/db/2.sqm | 41 ++ .../mobile/core/persistence/db/Mobile.sq | 75 +++ .../persistence/RemotePersistenceStoreTest.kt | 80 ++++ .../mobile/core/protocol/ModelCatalogDtos.kt | 20 +- .../protocol/ProvisionPeerDeviceContract.kt | 18 + .../mobile/core/protocol/RemoteCommand.kt | 46 +- .../core/protocol/ModelCatalogDtoTest.kt | 126 +++++ .../mobile/core/protocol/RemoteCommandTest.kt | 103 +++++ 97 files changed, 5377 insertions(+), 433 deletions(-) create mode 100644 src/apps/mobile/android/app/src/androidTest/kotlin/com/bitfun/mobile/app/MobileDesignPreviewVisualTest.kt create mode 100644 src/apps/mobile/android/app/src/androidTest/kotlin/com/bitfun/mobile/app/MobilePreviewStatesVisualTest.kt create mode 100644 src/apps/mobile/android/app/src/androidTest/kotlin/com/bitfun/mobile/app/PermissionModeCardTest.kt create mode 100644 src/apps/mobile/android/app/src/androidTest/kotlin/com/bitfun/mobile/app/RemoteSettingsSheetTest.kt create mode 100644 src/apps/mobile/android/app/src/androidTest/kotlin/com/bitfun/mobile/app/ToolConfirmationPanelTest.kt create mode 100644 src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/preview/MobilePreviewStates.kt create mode 100644 src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/shell/sidebar/RemoteWorkspacePathPolicy.kt create mode 100644 src/apps/mobile/android/app/src/test/kotlin/com/bitfun/mobile/app/platform/AppLocaleResolverTest.kt create mode 100644 src/apps/mobile/android/app/src/test/kotlin/com/bitfun/mobile/app/platform/FoldFactsTest.kt create mode 100644 src/apps/mobile/android/app/src/test/kotlin/com/bitfun/mobile/app/ui/chat/ComposerBreakpointTest.kt create mode 100644 src/apps/mobile/android/app/src/test/kotlin/com/bitfun/mobile/app/ui/chat/ConversationComposerDraftTest.kt create mode 100644 src/apps/mobile/android/app/src/test/kotlin/com/bitfun/mobile/app/ui/chat/ConversationScrollPolicyTest.kt create mode 100644 src/apps/mobile/android/app/src/test/kotlin/com/bitfun/mobile/app/ui/shell/ContentCardCoordinatorTest.kt create mode 100644 src/apps/mobile/android/app/src/test/kotlin/com/bitfun/mobile/app/ui/shell/sidebar/RemoteWorkspacePathPolicyTest.kt create mode 100644 src/apps/mobile/design-system/preview/preview-scenarios.smoke.test.mjs create mode 100644 src/apps/mobile/harmonyos/entry/src/test/MobilePreviewScenarioUnit.test.ets create mode 100644 src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/bitfun/mobile/core/feature/session/ModelCatalogContract.kt create mode 100644 src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/bitfun/mobile/core/feature/session/ToolApprovalEditContract.kt create mode 100644 src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/bitfun/mobile/core/feature/session/RemoteSessionPersistenceTest.kt create mode 100644 src/apps/mobile/shared/core-persistence/src/commonMain/sqldelight/com/bitfun/mobile/core/persistence/db/2.sqm create mode 100644 src/apps/mobile/shared/core-persistence/src/jvmTest/kotlin/com/bitfun/mobile/core/persistence/RemotePersistenceStoreTest.kt create mode 100644 src/apps/mobile/shared/core-protocol/src/commonMain/kotlin/com/bitfun/mobile/core/protocol/ProvisionPeerDeviceContract.kt create mode 100644 src/apps/mobile/shared/core-protocol/src/commonTest/kotlin/com/bitfun/mobile/core/protocol/ModelCatalogDtoTest.kt diff --git a/src/apps/mobile/android/app/src/androidTest/kotlin/com/bitfun/mobile/app/AccountRemoteScreenTest.kt b/src/apps/mobile/android/app/src/androidTest/kotlin/com/bitfun/mobile/app/AccountRemoteScreenTest.kt index cecbad1b48..84b15f6ee1 100644 --- a/src/apps/mobile/android/app/src/androidTest/kotlin/com/bitfun/mobile/app/AccountRemoteScreenTest.kt +++ b/src/apps/mobile/android/app/src/androidTest/kotlin/com/bitfun/mobile/app/AccountRemoteScreenTest.kt @@ -108,4 +108,34 @@ class AccountRemoteScreenTest { assertEquals("desk-1", selected) assertEquals(1, scans) } + + @Test + fun aSelectedOfflineAccountDeviceCanReconnect() { + var selected = "" + composeRule.setContent { + BitFunTheme(dark = false) { + ConnectAccountDeviceScreen( + state = AccountUiState.Ready( + userId = "user-1", + username = "tester", + devices = listOf( + AccountDeviceUi("desk-1", "Studio Mac", online = true, lastSeenAt = null), + AccountDeviceUi("desk-2", "Office PC", online = false, lastSeenAt = null), + ), + selectedDeviceId = "desk-2", + selectedDeviceName = "Office PC", + ), + onBack = {}, + onRefresh = {}, + onSelect = { selected = it }, + onOpenScanner = {}, + modifier = Modifier, + ) + } + } + + composeRule.onNodeWithTag(CONNECT_ACCOUNT_DEVICE_ROW_TEST_TAG_PREFIX + "desk-2").performClick() + + assertEquals("desk-2", selected) + } } diff --git a/src/apps/mobile/android/app/src/androidTest/kotlin/com/bitfun/mobile/app/ChatMessageBubbleTest.kt b/src/apps/mobile/android/app/src/androidTest/kotlin/com/bitfun/mobile/app/ChatMessageBubbleTest.kt index 8bf4656f0a..1b86fd9e31 100644 --- a/src/apps/mobile/android/app/src/androidTest/kotlin/com/bitfun/mobile/app/ChatMessageBubbleTest.kt +++ b/src/apps/mobile/android/app/src/androidTest/kotlin/com/bitfun/mobile/app/ChatMessageBubbleTest.kt @@ -1,11 +1,17 @@ package com.bitfun.mobile.app +import android.content.ClipboardManager +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.getUnclippedBoundsInRoot import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick import androidx.test.platform.app.InstrumentationRegistry import com.bitfun.mobile.app.ui.chat.ChatMessageBubble import com.bitfun.mobile.app.ui.chat.message.SUBAGENT_GROUP_TEST_TAG @@ -77,9 +83,91 @@ class ChatMessageBubbleTest { composeRule.onNodeWithTag(SUBAGENT_GROUP_TEST_TAG).assertIsDisplayed() composeRule.onNodeWithText("Audit the auth flow").assertIsDisplayed() + assertTrue(composeRule.onAllNodesWithText("No leaks found.").fetchSemanticsNodes().isEmpty()) + + composeRule.onNodeWithText("Audit the auth flow").performClick() composeRule.onNodeWithText("No leaks found.").assertIsDisplayed() } + @Test + fun aRunningSubagentShowsItsChildrenByDefault() { + composeRule.setContent { + Bubble( + row( + kind = ConversationRowKind.ASSISTANT, + blocks = listOf( + MessageBlock.Subagent( + id = "running-subagent", + title = "Inspect the build", + running = true, + text = "Still working", + children = listOf(MessageBlock.Text("child", "Build step started.", false)), + ), + ), + ), + ) + } + + composeRule.onNodeWithText("Build step started.").assertIsDisplayed() + } + + @Test + fun aCompletedSubagentCanBeExpandedAndCollapsed() { + composeRule.setContent { + Bubble( + row( + kind = ConversationRowKind.ASSISTANT, + blocks = listOf( + MessageBlock.Subagent( + id = "completed-subagent", + title = "Inspect the build", + running = false, + text = "Finished", + children = listOf(MessageBlock.Text("child", "Build step finished.", false)), + ), + ), + ), + ) + } + + assertTrue(composeRule.onAllNodesWithText("Build step finished.").fetchSemanticsNodes().isEmpty()) + composeRule.onNodeWithText("Inspect the build").performClick() + composeRule.onNodeWithText("Build step finished.").assertIsDisplayed() + composeRule.onNodeWithText("Inspect the build").performClick() + assertTrue(composeRule.onAllNodesWithText("Build step finished.").fetchSemanticsNodes().isEmpty()) + } + + @Test + fun aRunningSubagentPreservesUserCollapseAcrossStreamUpdates() { + var block by mutableStateOf( + MessageBlock.Subagent( + id = "streaming-subagent", + title = "Inspect the build", + running = true, + text = "Working", + children = listOf(MessageBlock.Text("child", "Initial step.", false)), + ), + ) + composeRule.setContent { + Bubble(row(kind = ConversationRowKind.ASSISTANT, blocks = listOf(block))) + } + + composeRule.onNodeWithText("Initial step.").assertIsDisplayed() + composeRule.onNodeWithText("Inspect the build").performClick() + assertTrue(composeRule.onAllNodesWithText("Initial step.").fetchSemanticsNodes().isEmpty()) + + composeRule.runOnIdle { + block = block.copy( + text = "Still working", + children = listOf(MessageBlock.Text("child-2", "Updated step.", false)), + ) + } + assertTrue(composeRule.onAllNodesWithText("Updated step.").fetchSemanticsNodes().isEmpty()) + + composeRule.runOnIdle { block = block.copy(running = false) } + assertTrue(composeRule.onAllNodesWithText("Updated step.").fetchSemanticsNodes().isEmpty()) + } + @Test fun aFailureSaysWhichHalfOfTheExchangeFailed() { composeRule.setContent { @@ -126,8 +214,11 @@ class ChatMessageBubbleTest { ) } - composeRule.onNodeWithText("Working on it").assertIsDisplayed() composeRule.onNodeWithTag(TYPING_DOTS_TEST_TAG).assertDoesNotExist() + composeRule.waitUntil(timeoutMillis = 3_000L) { + composeRule.onAllNodesWithText("Working on it").fetchSemanticsNodes().isNotEmpty() + } + composeRule.onNodeWithText("Working on it").assertIsDisplayed() } @androidx.compose.runtime.Composable @@ -139,6 +230,7 @@ class ChatMessageBubbleTest { onRejectTool = { _, _ -> }, onCancelTool = { _, _ -> }, onAnswerTool = { _, _ -> }, + onAnswerToolStructured = { _, _ -> }, onRetry = {}, onOpenLink = { _, _ -> }, previewingRemotePath = "", @@ -185,4 +277,109 @@ class ChatMessageBubbleTest { private fun string(resource: Int): String = InstrumentationRegistry.getInstrumentation().targetContext.getString(resource) + + @Test + fun thinkingCollapseSurvivesStreamedTokenUpdates() { + var block by mutableStateOf(MessageBlock.Thinking("stable-thinking", "Initial reasoning", true)) + + composeRule.setContent { + Bubble( + row( + kind = ConversationRowKind.ASSISTANT, + blocks = listOf(block), + ).copy(id = "thinking-row"), + ) + } + + composeRule.onNodeWithText("Initial reasoning").assertIsDisplayed() + composeRule.onNodeWithText(string(R.string.chat_thinking_in_progress)).performClick() + assertTrue(composeRule.onAllNodesWithText("Initial reasoning").fetchSemanticsNodes().isEmpty()) + + composeRule.runOnIdle { + block = MessageBlock.Thinking("stable-thinking", "Initial reasoning with more streamed tokens", true) + } + assertTrue( + composeRule.onAllNodesWithText("Initial reasoning with more streamed tokens") + .fetchSemanticsNodes() + .isEmpty(), + ) + + composeRule.runOnIdle { + block = MessageBlock.Thinking("stable-thinking", "Initial reasoning with more streamed tokens", false) + } + assertTrue( + composeRule.onAllNodesWithText("Initial reasoning with more streamed tokens") + .fetchSemanticsNodes() + .isEmpty(), + ) + } + + @Test + fun streamingCodeBlockCopyCopiesTheFullText() { + val fullCode = "```\necho full-command\n```" + composeRule.setContent { + Bubble( + row( + kind = ConversationRowKind.ASSISTANT, + text = fullCode, + streaming = true, + ).copy(id = "streaming-copy-row"), + ) + } + + val copyLabel = string(R.string.chat_copy) + composeRule.waitUntil(timeoutMillis = 3_000L) { + composeRule.onAllNodesWithText(copyLabel).fetchSemanticsNodes().isNotEmpty() + } + composeRule.onNodeWithText(copyLabel).performClick() + composeRule.waitUntil(timeoutMillis = 3_000L) { + val clipboard = InstrumentationRegistry.getInstrumentation().targetContext + .getSystemService(ClipboardManager::class.java) + clipboard.primaryClip?.getItemAt(0)?.text?.toString() == fullCode + } + val clipboard = InstrumentationRegistry.getInstrumentation().targetContext + .getSystemService(ClipboardManager::class.java) + assertTrue(clipboard.primaryClip?.getItemAt(0)?.text?.toString() == fullCode) + } + + @Test + fun streamingTextShowsEachDeltaImmediately() { + var currentRow by mutableStateOf( + row(kind = ConversationRowKind.ASSISTANT, text = "first.", streaming = true), + ) + composeRule.setContent { Bubble(currentRow) } + composeRule.onNodeWithText("first.").assertIsDisplayed() + + composeRule.runOnIdle { currentRow = currentRow.copy(text = "first. second.") } + composeRule.onNodeWithText("first. second.").assertIsDisplayed() + + composeRule.runOnIdle { currentRow = currentRow.copy(text = "first. second. third.") } + composeRule.onNodeWithText("first. second. third.").assertIsDisplayed() + } + + @Test + fun streamingTextRewriteShowsTheNewTextWithoutBlanking() { + var currentRow by mutableStateOf( + row(kind = ConversationRowKind.ASSISTANT, text = "old response.", streaming = true), + ) + composeRule.setContent { Bubble(currentRow) } + composeRule.onNodeWithText("old response.").assertIsDisplayed() + + composeRule.runOnIdle { currentRow = currentRow.copy(text = "new response.") } + composeRule.onNodeWithText("new response.").assertIsDisplayed() + assertTrue(composeRule.onAllNodesWithText("old response.").fetchSemanticsNodes().isEmpty()) + } + + @Test + fun streamingToCompleteShowsTheFullTextWithoutAnEndJump() { + var currentRow by mutableStateOf( + row(kind = ConversationRowKind.ASSISTANT, text = "A complete answer.", streaming = true), + ) + composeRule.setContent { Bubble(currentRow) } + composeRule.onNodeWithText("A complete answer.").assertIsDisplayed() + + composeRule.runOnIdle { currentRow = currentRow.copy(streaming = false) } + composeRule.onNodeWithText("A complete answer.").assertIsDisplayed() + } + } diff --git a/src/apps/mobile/android/app/src/androidTest/kotlin/com/bitfun/mobile/app/ConversationHeaderTest.kt b/src/apps/mobile/android/app/src/androidTest/kotlin/com/bitfun/mobile/app/ConversationHeaderTest.kt index bed5d7a248..a687a5e47e 100644 --- a/src/apps/mobile/android/app/src/androidTest/kotlin/com/bitfun/mobile/app/ConversationHeaderTest.kt +++ b/src/apps/mobile/android/app/src/androidTest/kotlin/com/bitfun/mobile/app/ConversationHeaderTest.kt @@ -158,6 +158,27 @@ class ConversationHeaderTest { assertEquals(1, stopped) } + @Test + fun tappingTheMenuAgainDismissesIt() { + composeRule.setContent { + ConversationHeader( + title = "Session", + contextTitle = "Studio", + canStop = false, + enabled = true, + onBack = {}, + onRename = {}, + onStop = {}, + modifier = Modifier, + ) + } + + composeRule.onNodeWithTag(CONVERSATION_MENU_TEST_TAG).performClick() + composeRule.onNodeWithTag(HEADER_ACTION_MENU_TEST_TAG).assertIsDisplayed() + composeRule.onNodeWithTag(CONVERSATION_MENU_TEST_TAG).performClick() + composeRule.onNodeWithTag(HEADER_ACTION_MENU_TEST_TAG).assertDoesNotExist() + } + @Test fun anIdleSessionStillOffersUploadedFilesButNotStop() { composeRule.setContent { diff --git a/src/apps/mobile/android/app/src/androidTest/kotlin/com/bitfun/mobile/app/ConversationViewTest.kt b/src/apps/mobile/android/app/src/androidTest/kotlin/com/bitfun/mobile/app/ConversationViewTest.kt index fa11131136..f09224053b 100644 --- a/src/apps/mobile/android/app/src/androidTest/kotlin/com/bitfun/mobile/app/ConversationViewTest.kt +++ b/src/apps/mobile/android/app/src/androidTest/kotlin/com/bitfun/mobile/app/ConversationViewTest.kt @@ -7,6 +7,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.graphics.toPixelMap import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertTextEquals import androidx.compose.ui.test.captureToImage import androidx.compose.ui.test.getUnclippedBoundsInRoot import androidx.compose.ui.test.junit4.v2.createComposeRule @@ -14,19 +15,30 @@ import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextReplacement import androidx.compose.ui.test.performTouchInput import androidx.compose.ui.test.swipeDown import androidx.compose.ui.unit.dp import androidx.test.platform.app.InstrumentationRegistry import com.bitfun.mobile.app.ui.chat.CONVERSATION_LIST_TEST_TAG +import com.bitfun.mobile.app.ui.chat.CONVERSATION_LOADING_TEST_TAG import com.bitfun.mobile.app.ui.chat.CHAT_STATUS_DOT_TEST_TAG import com.bitfun.mobile.app.ui.chat.CHAT_STATUS_BAR_TEST_TAG import com.bitfun.mobile.app.ui.chat.ChatStatusBar +import com.bitfun.mobile.app.ui.chat.ConversationEmptyState import com.bitfun.mobile.app.ui.chat.ConversationTimelineView +import com.bitfun.mobile.app.ui.chat.COMPOSER_INPUT_TEST_TAG +import com.bitfun.mobile.app.ui.chat.COMPOSER_SEND_TEST_TAG +import com.bitfun.mobile.app.ui.chat.ConversationView import com.bitfun.mobile.app.ui.theme.BitFunTheme import com.bitfun.mobile.core.feature.connection.ConnectionPhase +import com.bitfun.mobile.core.feature.layout.SettingsPlacement +import com.bitfun.mobile.core.feature.layout.SettingsPlacementMode import com.bitfun.mobile.core.feature.session.ConversationRow import com.bitfun.mobile.core.feature.session.ConversationRowKind +import com.bitfun.mobile.core.feature.session.RemoteSessionIntent +import com.bitfun.mobile.core.feature.session.RemoteSessionUiState +import com.bitfun.mobile.core.feature.session.SessionAgentFilter import com.bitfun.mobile.core.feature.workspace.RemoteFileDownloadUiState import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue @@ -52,6 +64,7 @@ class ConversationViewTest { onRejectTool = { _, _ -> }, onCancelTool = { _, _ -> }, onAnswerTool = { _, _ -> }, + onAnswerToolStructured = { _, _ -> }, onRetry = {}, onOpenFile = { _, _ -> }, previewingRemotePath = "", @@ -137,6 +150,116 @@ class ConversationViewTest { .assertDoesNotExist() } + @Test + fun withLoadOlderHeaderStreamingGrowthStaysOnTheRealTail() { + val rows = mutableStateOf( + (1..40).map { index -> assistantRow("message-$index", id = "message-$index") }, + ) + + composeRule.setContent { + BitFunTheme(dark = false) { + TimelineForTest(rows.value, hasMoreMessages = true) + } + } + composeRule.waitForIdle() + + composeRule.runOnIdle { + rows.value = rows.value.dropLast(1) + + assistantRow( + List(500) { "Growing tail line." }.joinToString(" ") + " header-tail-marker", + id = "message-40", + streaming = true, + ) + } + composeRule.waitForIdle() + + val listBounds = composeRule.onNodeWithTag(CONVERSATION_LIST_TEST_TAG) + .getUnclippedBoundsInRoot() + val tailBounds = composeRule.onNodeWithText("header-tail-marker", substring = true) + .getUnclippedBoundsInRoot() + assertTrue(tailBounds.bottom <= listBounds.bottom + 1.dp) + composeRule.onNodeWithContentDescription(string(R.string.chat_scroll_to_bottom)) + .assertDoesNotExist() + } + + @Test + fun conversationWithNoTimelineShowsLoadingStateInsteadOfBlankSurface() { + setConversationContent(state = { readyState() }) + + composeRule.onNodeWithTag(CONVERSATION_LOADING_TEST_TAG).assertIsDisplayed() + composeRule.onNodeWithText(string(R.string.chat_empty_loading)).assertIsDisplayed() + } + + @Test + fun composerShowsTheStoreDraftAndTypingDispatchesUpdateDraft() { + val intents = mutableListOf() + val state = mutableStateOf(readyState(sessionId = "s-code", draft = "existing draft")) + + setConversationContent(state = { state.value }, onIntent = { intents += it }) + + composeRule.onNodeWithTag(COMPOSER_INPUT_TEST_TAG).assertTextEquals("existing draft") + composeRule.onNodeWithTag(COMPOSER_INPUT_TEST_TAG).performTextReplacement("replaced draft") + + assertEquals( + listOf(RemoteSessionIntent.UpdateDraft("replaced draft")), + intents, + ) + } + + @Test + fun composerFollowsStoreDraftUpdatesWithinTheSameSession() { + val state = mutableStateOf(readyState(sessionId = "s-code", draft = "first")) + + setConversationContent(state = { state.value }) + + composeRule.onNodeWithTag(COMPOSER_INPUT_TEST_TAG).assertTextEquals("first") + composeRule.runOnIdle { state.value = state.value.copy(draft = "second") } + composeRule.waitForIdle() + composeRule.onNodeWithTag(COMPOSER_INPUT_TEST_TAG).assertTextEquals("second") + } + + @Test + fun switchingSessionsShowsTheRestoredDraft() { + val state = mutableStateOf(readyState(sessionId = "s-a", draft = "draft-a")) + + setConversationContent(state = { state.value }) + + composeRule.onNodeWithTag(COMPOSER_INPUT_TEST_TAG).assertTextEquals("draft-a") + composeRule.runOnIdle { + state.value = readyState(sessionId = "s-b", draft = "draft-b") + } + composeRule.waitForIdle() + composeRule.onNodeWithTag(COMPOSER_INPUT_TEST_TAG).assertTextEquals("draft-b") + } + + @Test + fun sendUsesTheStoreDraftAndDoesNotFakeClearIt() { + val intents = mutableListOf() + + setConversationContent( + state = { readyState(sessionId = "s-code", draft = "send me") }, + onIntent = { intents += it }, + ) + + composeRule.onNodeWithTag(COMPOSER_SEND_TEST_TAG).performClick() + + assertEquals( + listOf(RemoteSessionIntent.SendMessage("s-code", "send me", null)), + intents, + ) + } + + @Test + fun emptyStateShowsInvitationCopy() { + composeRule.setContent { + BitFunTheme(dark = false) { + ConversationEmptyState(modifier = Modifier.fillMaxSize()) + } + } + composeRule.onNodeWithText(string(R.string.chat_empty_title)).assertIsDisplayed() + composeRule.onNodeWithText(string(R.string.chat_empty_hint)).assertIsDisplayed() + } + @Test fun reconnectingStatusBarMatchesTheFixedHeightColorAndCopyContract() { composeRule.setContent { @@ -173,17 +296,61 @@ class ConversationViewTest { composeRule.onNodeWithText(string(R.string.chat_status_executing)).assertExists() } + private fun setConversationContent( + state: () -> RemoteSessionUiState.Ready, + onIntent: (RemoteSessionIntent) -> Unit = {}, + ) { + composeRule.setContent { + BitFunTheme(dark = false) { + ConversationView( + state = state(), + phase = ConnectionPhase.CONNECTED, + settingsPlacement = SettingsPlacement(SettingsPlacementMode.BOTTOM, 0, 0, 0), + onBack = {}, + onIntent = onIntent, + contextTitle = "Test desktop", + onOpenFile = { _, _ -> }, + previewingRemotePath = "", + previewLoading = false, + download = RemoteFileDownloadUiState.None, + onDownloadFile = { _, _ -> }, + modifier = Modifier.fillMaxSize(), + ) + } + } + } + + private fun readyState( + sessionId: String = "", + draft: String = "", + ) = RemoteSessionUiState.Ready( + sessions = emptyList(), + selectedSessionId = sessionId, + timeline = null, + busy = false, + permissionMode = null, + permissionModeFailure = null, + query = "", + agentFilter = SessionAgentFilter.ALL, + hasMore = false, + hasMoreMessages = false, + modelCatalog = null, + modelCatalogFailure = null, + draft = draft, + ) + @Composable - private fun TimelineForTest(rows: List) { + private fun TimelineForTest(rows: List, hasMoreMessages: Boolean = false) { ConversationTimelineView( rows = rows, - hasMoreMessages = false, + hasMoreMessages = hasMoreMessages, onLoadOlder = {}, enabled = true, onApproveTool = {}, onRejectTool = { _, _ -> }, onCancelTool = { _, _ -> }, onAnswerTool = { _, _ -> }, + onAnswerToolStructured = { _, _ -> }, onRetry = {}, onOpenFile = { _, _ -> }, previewingRemotePath = "", diff --git a/src/apps/mobile/android/app/src/androidTest/kotlin/com/bitfun/mobile/app/MobileDesignPreviewVisualTest.kt b/src/apps/mobile/android/app/src/androidTest/kotlin/com/bitfun/mobile/app/MobileDesignPreviewVisualTest.kt new file mode 100644 index 0000000000..270ada0f1c --- /dev/null +++ b/src/apps/mobile/android/app/src/androidTest/kotlin/com/bitfun/mobile/app/MobileDesignPreviewVisualTest.kt @@ -0,0 +1,179 @@ +package com.bitfun.mobile.app + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.graphics.toPixelMap +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.captureToImage +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.test.platform.app.InstrumentationRegistry +import com.bitfun.mobile.app.ui.preview.MOBILE_DESIGN_GALLERY_PLATFORM_TEST_TAG +import com.bitfun.mobile.app.ui.preview.MOBILE_DESIGN_GALLERY_TEST_TAG +import com.bitfun.mobile.app.ui.preview.MobileDesignGallery +import com.bitfun.mobile.app.ui.preview.generated.MobilePreviewScenario +import com.bitfun.mobile.app.ui.preview.generated.MobilePreviewScenarios +import com.bitfun.mobile.app.ui.theme.BitFunTheme +import com.bitfun.mobile.app.ui.theme.generated.MobileDesignColors +import com.bitfun.mobile.app.R +import kotlin.math.abs +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test + +class MobileDesignPreviewVisualTest { + @get:Rule + val composeRule = createComposeRule() + + private val targetContext = InstrumentationRegistry.getInstrumentation().targetContext + + @Test + fun connectedConversationScenarioExposesStableSemanticsAndPixels() { + composeRule.setContent { + MobileDesignGallery(MobilePreviewScenarios.ConnectedConversation, dark = false) + } + assertGallerySemantics(MobilePreviewScenarios.ConnectedConversation) + val gallery = composeRule.onNodeWithTag(MOBILE_DESIGN_GALLERY_TEST_TAG) + .captureToImage() + assertNonBlankAndVaried(gallery) + } + + @Test + fun streamingDarkScenarioExposesStableSemanticsAndPixels() { + composeRule.setContent { + MobileDesignGallery(MobilePreviewScenarios.StreamingDark, dark = true) + } + assertGallerySemantics(MobilePreviewScenarios.StreamingDark) + val gallery = composeRule.onNodeWithTag(MOBILE_DESIGN_GALLERY_TEST_TAG) + .captureToImage() + assertNonBlankAndVaried(gallery) + } + + @Test + fun reconnectingWideScenarioExposesStableSemanticsAndPixels() { + composeRule.setContent { + MobileDesignGallery(MobilePreviewScenarios.ReconnectingWide, dark = false) + } + assertGallerySemantics(MobilePreviewScenarios.ReconnectingWide) + assertNonBlankAndVaried( + composeRule.onNodeWithTag(MOBILE_DESIGN_GALLERY_TEST_TAG).captureToImage() + ) + } + + @Test + fun darkGalleryIsDarkerThanLightGallery() { + var scenario by mutableStateOf(MobilePreviewScenarios.ConnectedConversation) + composeRule.setContent { + MobileDesignGallery(scenario, dark = scenario.appearance == "dark") + } + val lightGallery = composeRule.onNodeWithTag(MOBILE_DESIGN_GALLERY_TEST_TAG) + .captureToImage() + + composeRule.runOnIdle { scenario = MobilePreviewScenarios.StreamingDark } + composeRule.waitForIdle() + val darkGallery = composeRule.onNodeWithTag(MOBILE_DESIGN_GALLERY_TEST_TAG) + .captureToImage() + + assertNonBlankAndVaried(lightGallery) + assertNonBlankAndVaried(darkGallery) + assertTrue( + "Light gallery should be brighter than dark gallery", + meanLuminance(lightGallery) > meanLuminance(darkGallery), + ) + } + + @Test + fun lightThemeBackgroundProbeMatchesContract() { + assertBackgroundProbe(dark = false, expected = MobileDesignColors.Light.PageBg) + } + + @Test + fun darkThemeBackgroundProbeMatchesContract() { + assertBackgroundProbe(dark = true, expected = MobileDesignColors.Dark.PageBg) + } + + private fun assertGallerySemantics(scenario: MobilePreviewScenario) { + composeRule.onNodeWithTag(MOBILE_DESIGN_GALLERY_TEST_TAG).assertIsDisplayed() + composeRule.onNodeWithTag(MOBILE_DESIGN_GALLERY_PLATFORM_TEST_TAG).assertIsDisplayed() + composeRule.onNodeWithText("${scenario.viewportWidth} × ${scenario.viewportHeight}") + .assertIsDisplayed() + composeRule.onNodeWithText(scenario.headerTitle).assertIsDisplayed() + composeRule.onNodeWithText(scenario.headerSubtitle).assertIsDisplayed() + scenario.messages.forEach { message -> + composeRule.onNodeWithText(message.text).assertIsDisplayed() + } + if (scenario.composerDraft.isEmpty()) { + composeRule.onNodeWithText(scenario.composerPlaceholder).assertIsDisplayed() + } else { + composeRule.onNodeWithText(scenario.composerDraft).assertIsDisplayed() + } + if (scenario == MobilePreviewScenarios.StreamingDark) { + composeRule.onNodeWithContentDescription(targetContext.getString(R.string.message_stop)) + .assertIsDisplayed() + } + } + + @OptIn(ExperimentalTestApi::class, ExperimentalComposeUiApi::class) + private fun assertBackgroundProbe(dark: Boolean, expected: androidx.compose.ui.graphics.Color) { + composeRule.setContent { + BitFunTheme(dark = dark) { + Box( + Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + .testTag("token-probe"), + ) + } + } + val image = composeRule.onNodeWithTag("token-probe").captureToImage() + val actual = image.toPixelMap()[image.width / 2, image.height / 2].toArgb() + val expectedArgb = expected.toArgb() + assertTrue( + "Theme background pixel differs: expected $expectedArgb, actual $actual", + channelDistance(actual, expectedArgb) <= 4, + ) + } + + private fun assertNonBlankAndVaried(image: ImageBitmap) { + assertTrue("Captured gallery must have pixels", image.width > 0 && image.height > 0) + val samples = sampledArgb(image) + assertTrue("Captured gallery must contain visual variation", samples.distinct().size > 1) + } + + private fun sampledArgb(image: ImageBitmap): List { + val map = image.toPixelMap() + return (0 until 8).flatMap { row -> + (0 until 8).map { column -> + val x = column * (image.width - 1) / 7 + val y = row * (image.height - 1) / 7 + map[x, y].toArgb() + } + } + } + + private fun meanLuminance(image: ImageBitmap): Double = sampledArgb(image).map { argb -> + val red = (argb shr 16) and 0xff + val green = (argb shr 8) and 0xff + val blue = argb and 0xff + 0.2126 * red + 0.7152 * green + 0.0722 * blue + }.average() + + private fun channelDistance(first: Int, second: Int): Int = maxOf( + abs(((first shr 16) and 0xff) - ((second shr 16) and 0xff)), + abs(((first shr 8) and 0xff) - ((second shr 8) and 0xff)), + abs((first and 0xff) - (second and 0xff)), + ) +} diff --git a/src/apps/mobile/android/app/src/androidTest/kotlin/com/bitfun/mobile/app/MobilePreviewStatesVisualTest.kt b/src/apps/mobile/android/app/src/androidTest/kotlin/com/bitfun/mobile/app/MobilePreviewStatesVisualTest.kt new file mode 100644 index 0000000000..afbfb39763 --- /dev/null +++ b/src/apps/mobile/android/app/src/androidTest/kotlin/com/bitfun/mobile/app/MobilePreviewStatesVisualTest.kt @@ -0,0 +1,141 @@ +package com.bitfun.mobile.app + +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.graphics.toPixelMap +import androidx.compose.ui.test.assertHeightIsEqualTo +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertWidthIsEqualTo +import androidx.compose.ui.test.captureToImage +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import androidx.compose.ui.unit.dp +import androidx.test.platform.app.InstrumentationRegistry +import com.bitfun.mobile.app.R +import com.bitfun.mobile.app.ui.chat.COMPOSER_INPUT_TEST_TAG +import com.bitfun.mobile.app.ui.chat.COMPOSER_SEND_TEST_TAG +import com.bitfun.mobile.app.ui.chat.MODEL_CONTROL_TEST_TAG +import com.bitfun.mobile.app.ui.preview.MOBILE_PREVIEW_CIRCLE_BACK_TEST_TAG +import com.bitfun.mobile.app.ui.preview.MOBILE_PREVIEW_CIRCLE_MORE_TEST_TAG +import com.bitfun.mobile.app.ui.preview.MOBILE_PREVIEW_CIRCLE_SIDEBAR_TEST_TAG +import com.bitfun.mobile.app.ui.preview.MOBILE_PREVIEW_CIRCLE_STATES_TEST_TAG +import com.bitfun.mobile.app.ui.preview.MOBILE_PREVIEW_COMPOSER_ATTACHMENTS_TEST_TAG +import com.bitfun.mobile.app.ui.preview.MOBILE_PREVIEW_COMPOSER_FOCUSED_TEST_TAG +import com.bitfun.mobile.app.ui.preview.MOBILE_PREVIEW_MODAL_BUSY_TEST_TAG +import com.bitfun.mobile.app.ui.preview.MOBILE_PREVIEW_MODAL_ERROR_ACTION_TEST_TAG +import com.bitfun.mobile.app.ui.preview.MOBILE_PREVIEW_MODAL_ERROR_TEST_TAG +import com.bitfun.mobile.app.ui.preview.MOBILE_PREVIEW_MODAL_ERROR_TEXT_TEST_TAG +import com.bitfun.mobile.app.ui.preview.MobilePreviewCircleStates +import com.bitfun.mobile.app.ui.preview.MobilePreviewComposerAttachments +import com.bitfun.mobile.app.ui.preview.MobilePreviewComposerFocused +import com.bitfun.mobile.app.ui.preview.MobilePreviewModalBusy +import com.bitfun.mobile.app.ui.preview.MobilePreviewModalError +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test + +class MobilePreviewStatesVisualTest { + @get:Rule + val composeRule = createComposeRule() + + private val targetContext = InstrumentationRegistry.getInstrumentation().targetContext + + @Test + fun focusedComposerExpandsToExposeModelControl() { + composeRule.setContent { MobilePreviewComposerFocused() } + composeRule.onNodeWithTag(MOBILE_PREVIEW_COMPOSER_FOCUSED_TEST_TAG).assertIsDisplayed() + composeRule.onNodeWithTag(COMPOSER_INPUT_TEST_TAG) + .assertIsDisplayed() + .performTextInput("hello") + composeRule.onNodeWithTag(MODEL_CONTROL_TEST_TAG).assertIsDisplayed() + } + + @Test + fun attachmentComposerShowsFallbackAttachmentSurfaceAndSend() { + composeRule.setContent { MobilePreviewComposerAttachments() } + composeRule.onNodeWithTag(MOBILE_PREVIEW_COMPOSER_ATTACHMENTS_TEST_TAG).assertIsDisplayed() + composeRule.onNodeWithText(targetContext.getString(R.string.chat_image)).assertIsDisplayed() + composeRule.onNodeWithTag(COMPOSER_SEND_TEST_TAG).assertIsDisplayed() + } + + @Test + fun busyModalShowsItsWorkingCopy() { + composeRule.setContent { MobilePreviewModalBusy() } + composeRule.onNodeWithTag(MOBILE_PREVIEW_MODAL_BUSY_TEST_TAG).assertIsDisplayed() + composeRule.onNodeWithText("Working…").assertIsDisplayed() + composeRule.onNodeWithText("Please wait while the request finishes.").assertIsDisplayed() + } + + @Test + fun errorModalExposesRecoverySemantics() { + composeRule.setContent { MobilePreviewModalError() } + composeRule.onNodeWithTag(MOBILE_PREVIEW_MODAL_ERROR_TEST_TAG).assertIsDisplayed() + composeRule.onNodeWithTag(MOBILE_PREVIEW_MODAL_ERROR_TEXT_TEST_TAG).assertIsDisplayed() + composeRule.onNodeWithTag(MOBILE_PREVIEW_MODAL_ERROR_ACTION_TEST_TAG).assertIsDisplayed() + } + + @Test + fun circleStatesHaveStableTouchTargetsAndMoreActionIsSafe() { + composeRule.setContent { MobilePreviewCircleStates() } + composeRule.onNodeWithTag(MOBILE_PREVIEW_CIRCLE_STATES_TEST_TAG).assertIsDisplayed() + listOf( + MOBILE_PREVIEW_CIRCLE_SIDEBAR_TEST_TAG, + MOBILE_PREVIEW_CIRCLE_MORE_TEST_TAG, + MOBILE_PREVIEW_CIRCLE_BACK_TEST_TAG, + ).forEach { tag -> + composeRule.onNodeWithTag(tag) + .assertIsDisplayed() + .assertWidthIsEqualTo(44.dp) + .assertHeightIsEqualTo(44.dp) + } + composeRule.onNodeWithContentDescription("More actions").performClick() + composeRule.onNodeWithTag(MOBILE_PREVIEW_CIRCLE_MORE_TEST_TAG).assertIsDisplayed() + } + + @Test + fun darkModalStateProducesNonBlankCapturedPixels() { + composeRule.setContent { MobilePreviewModalError(dark = true) } + val image = composeRule.onNodeWithTag(MOBILE_PREVIEW_MODAL_ERROR_TEST_TAG).captureToImage() + val pixelMap = image.toPixelMap() + val center = pixelMap[image.width / 2, image.height / 2].toArgb() + assertTrue( + "Dark modal capture must have a non-transparent center", + (center ushr 24) != 0, + ) + assertTrue( + "Dark modal capture must be dark-themed", + meanLuminance(sampledArgb(image)) < 128.0, + ) + val maxLuminance = (0 until image.height).maxOf { y -> + (0 until image.width).maxOf { x -> argbLuminance(pixelMap[x, y].toArgb()) } + } + assertTrue( + "Dark modal capture must contain drawn light-on-dark content", + maxLuminance > 128.0, + ) + } + + private fun sampledArgb(image: ImageBitmap): List { + val map = image.toPixelMap() + return (0 until 8).flatMap { row -> + (0 until 8).map { column -> + val x = column * (image.width - 1) / 7 + val y = row * (image.height - 1) / 7 + map[x, y].toArgb() + } + } + } + + private fun argbLuminance(argb: Int): Double { + val red = (argb shr 16) and 0xff + val green = (argb shr 8) and 0xff + val blue = argb and 0xff + return 0.2126 * red + 0.7152 * green + 0.0722 * blue + } + + private fun meanLuminance(samples: List): Double = samples.map(::argbLuminance).average() +} diff --git a/src/apps/mobile/android/app/src/androidTest/kotlin/com/bitfun/mobile/app/MobileScreenTest.kt b/src/apps/mobile/android/app/src/androidTest/kotlin/com/bitfun/mobile/app/MobileScreenTest.kt index 0647c8c1a3..b10cae6b9f 100644 --- a/src/apps/mobile/android/app/src/androidTest/kotlin/com/bitfun/mobile/app/MobileScreenTest.kt +++ b/src/apps/mobile/android/app/src/androidTest/kotlin/com/bitfun/mobile/app/MobileScreenTest.kt @@ -80,7 +80,11 @@ class MobileScreenTest { composeRule.onNodeWithTag(SIDEBAR_CODE_TEST_TAG).performClick() - waitForText("Get a pairing code") + // The drawer routes to the choose-connection page rather than launching + // the scanner, so both entry modes stay visible behind the closing drawer. + waitForText("Choose how to connect") + composeRule.onNodeWithText("Scan to connect").assertIsDisplayed() + composeRule.onNodeWithText("Sign in to BitFun account").assertIsDisplayed() composeRule.onNodeWithTag(SIDEBAR_TEST_TAG).assertIsNotDisplayed() } @@ -453,12 +457,16 @@ class MobileScreenTest { private fun openRemote() { composeRule.onNodeWithTag(MENU_TEST_TAG).performClick() composeRule.onNodeWithTag(SIDEBAR_CODE_TEST_TAG).performClick() - waitForText("Get a pairing code") + waitForText("Choose how to connect") } - /** The compact connect page starts with the scanner; canceling it exposes the typed-link fallback. */ + /** + * The connect page starts on the choose-connection step; tapping "Scan to + * connect" opens the system scanner, and canceling it exposes the typed-link + * fallback Harmony shows after a scan error. + */ private fun openManualPairing() { - composeRule.onNodeWithText("I have a pairing code").performClick() + composeRule.onNodeWithText("Scan to connect").performClick() // Google Code Scanner owns a separate system activity. Espresso's // pressBack requires our activity to be resumed, so inject the platform // key directly and wait for the cancellation callback to reveal the diff --git a/src/apps/mobile/android/app/src/androidTest/kotlin/com/bitfun/mobile/app/PermissionModeCardTest.kt b/src/apps/mobile/android/app/src/androidTest/kotlin/com/bitfun/mobile/app/PermissionModeCardTest.kt new file mode 100644 index 0000000000..b9dba48e6b --- /dev/null +++ b/src/apps/mobile/android/app/src/androidTest/kotlin/com/bitfun/mobile/app/PermissionModeCardTest.kt @@ -0,0 +1,128 @@ +package com.bitfun.mobile.app + +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import com.bitfun.mobile.app.ui.settings.FULL_ACCESS_CONFIRM_TEST_TAG +import com.bitfun.mobile.app.ui.settings.PermissionSection +import com.bitfun.mobile.app.ui.theme.BitFunTheme +import com.bitfun.mobile.core.feature.session.RemoteSessionIntent +import com.bitfun.mobile.core.feature.session.RemoteSessionUiState +import com.bitfun.mobile.core.feature.session.SessionAgentFilter +import com.bitfun.mobile.core.feature.session.SessionPermissionMode +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test + +class PermissionModeCardTest { + @get:Rule + val composeRule = createComposeRule() + + @Test + fun knownModesRenderAndSelectingAutoDispatches() { + val intents = mutableListOf() + setPermissionContent( + permissionMode = SessionPermissionMode.ASK, + onIntent = { intents += it }, + ) + + composeRule.onNodeWithText("Ask first").assertIsDisplayed() + composeRule.onNodeWithText("Approve automatically").assertIsDisplayed() + composeRule.onNodeWithText("Full access").assertIsDisplayed() + composeRule.onNodeWithText("Approve automatically").performClick() + + assertEquals( + listOf(RemoteSessionIntent.SetPermissionMode(SessionPermissionMode.AUTO)), + intents, + ) + } + + @Test + fun fullAccessRequiresConfirmationAndCanBeCancelled() { + val intents = mutableListOf() + setPermissionContent( + permissionMode = SessionPermissionMode.ASK, + onIntent = { intents += it }, + ) + + composeRule.onNodeWithText("Full access").performClick() + assertTrue(intents.isEmpty()) + composeRule.onNodeWithTag(FULL_ACCESS_CONFIRM_TEST_TAG).assertIsDisplayed() + + composeRule.onNodeWithText("Cancel").performClick() + composeRule.onNodeWithTag(FULL_ACCESS_CONFIRM_TEST_TAG).assertDoesNotExist() + assertTrue(intents.isEmpty()) + + composeRule.onNodeWithText("Full access").performClick() + composeRule.onNodeWithText("Turn on full access").performClick() + assertEquals( + listOf(RemoteSessionIntent.SetPermissionMode(SessionPermissionMode.FULL_ACCESS)), + intents, + ) + } + + @Test + fun unknownModeExplainsFailureDisablesModesAndLeavesRefreshEnabled() { + setPermissionContent(permissionMode = SessionPermissionMode.UNKNOWN) + + composeRule + .onNodeWithText("The desktop's permission mode could not be read. Refresh to try again.") + .assertIsDisplayed() + composeRule.onNodeWithText("Ask first").assertIsNotEnabled() + composeRule.onNodeWithText("Approve automatically").assertIsNotEnabled() + composeRule.onNodeWithText("Full access").assertIsNotEnabled() + composeRule.onNodeWithText("Refresh").assertIsEnabled() + } + + @Test + fun disconnectedStateExplainsConnectionAndDisablesModes() { + setPermissionContent( + permissionMode = SessionPermissionMode.ASK, + connected = false, + ) + + composeRule + .onNodeWithText("Connect to the desktop to change the permission mode.") + .assertIsDisplayed() + composeRule.onNodeWithText("Ask first").assertIsNotEnabled() + composeRule.onNodeWithText("Approve automatically").assertIsNotEnabled() + composeRule.onNodeWithText("Full access").assertIsNotEnabled() + } + + private fun setPermissionContent( + permissionMode: SessionPermissionMode?, + connected: Boolean = true, + onIntent: (RemoteSessionIntent) -> Unit = {}, + ) { + composeRule.setContent { + BitFunTheme(dark = false) { + PermissionSection( + state = readyState(permissionMode), + connected = connected, + onIntent = onIntent, + modifier = Modifier, + ) + } + } + } + + private fun readyState(permissionMode: SessionPermissionMode?) = RemoteSessionUiState.Ready( + sessions = emptyList(), + selectedSessionId = null, + timeline = null, + busy = false, + permissionMode = permissionMode, + permissionModeFailure = null, + query = "", + agentFilter = SessionAgentFilter.ALL, + hasMore = false, + hasMoreMessages = false, + modelCatalog = null, + ) +} diff --git a/src/apps/mobile/android/app/src/androidTest/kotlin/com/bitfun/mobile/app/RemoteSettingsSheetTest.kt b/src/apps/mobile/android/app/src/androidTest/kotlin/com/bitfun/mobile/app/RemoteSettingsSheetTest.kt new file mode 100644 index 0000000000..94d0eced57 --- /dev/null +++ b/src/apps/mobile/android/app/src/androidTest/kotlin/com/bitfun/mobile/app/RemoteSettingsSheetTest.kt @@ -0,0 +1,99 @@ +package com.bitfun.mobile.app + +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import com.bitfun.mobile.app.ui.settings.MODEL_CATALOG_FAILURE_TEST_TAG +import com.bitfun.mobile.app.ui.settings.MODEL_CATALOG_RETRY_TEST_TAG +import com.bitfun.mobile.app.ui.settings.RemoteSettingsSheet +import com.bitfun.mobile.app.ui.theme.BitFunTheme +import com.bitfun.mobile.core.feature.session.ModelCatalogFailure +import com.bitfun.mobile.core.feature.session.RemoteSessionIntent +import com.bitfun.mobile.core.feature.session.RemoteSessionUiState +import com.bitfun.mobile.core.feature.session.SessionAgentFilter +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test + +class RemoteSettingsSheetTest { + @get:Rule + val composeRule = createComposeRule() + + @Test + fun transientCatalogFailureShowsAnErrorAndRetryDispatchesTheIntent() { + val intents = mutableListOf() + setSheetContent( + state = readyState(modelCatalogFailure = ModelCatalogFailure.LOAD_FAILED), + onIntent = { intents += it }, + ) + + composeRule + .onNodeWithTag(MODEL_CATALOG_FAILURE_TEST_TAG) + .assertIsDisplayed() + composeRule + .onNodeWithText("Could not load the available models. Try again.") + .assertIsDisplayed() + composeRule + .onNodeWithTag(MODEL_CATALOG_RETRY_TEST_TAG) + .assertIsDisplayed() + .performClick() + + assertEquals( + listOf(RemoteSessionIntent.RefreshModelCatalog), + intents, + ) + } + + @Test + fun unsupportedByPeerIsExplicitAndOffersNoRetry() { + val intents = mutableListOf() + setSheetContent( + state = readyState(modelCatalogFailure = ModelCatalogFailure.UNSUPPORTED_BY_PEER), + onIntent = { intents += it }, + ) + + composeRule + .onNodeWithText( + "The connected desktop does not support choosing models from this app.", + ) + .assertIsDisplayed() + composeRule.onNodeWithTag(MODEL_CATALOG_RETRY_TEST_TAG).assertDoesNotExist() + assertTrue(intents.isEmpty()) + } + + private fun setSheetContent( + state: RemoteSessionUiState.Ready, + onIntent: (RemoteSessionIntent) -> Unit = {}, + ) { + composeRule.setContent { + BitFunTheme(dark = false) { + RemoteSettingsSheet( + state = state, + sessionId = "s-code", + onIntent = onIntent, + modifier = Modifier, + ) + } + } + } + + private fun readyState(modelCatalogFailure: ModelCatalogFailure?) = RemoteSessionUiState.Ready( + sessions = emptyList(), + selectedSessionId = "s-code", + timeline = null, + busy = false, + permissionMode = null, + permissionModeFailure = null, + query = "", + agentFilter = SessionAgentFilter.ALL, + hasMore = false, + hasMoreMessages = false, + modelCatalog = null, + modelCatalogFailure = modelCatalogFailure, + draft = "", + ) +} diff --git a/src/apps/mobile/android/app/src/androidTest/kotlin/com/bitfun/mobile/app/ToolConfirmationPanelTest.kt b/src/apps/mobile/android/app/src/androidTest/kotlin/com/bitfun/mobile/app/ToolConfirmationPanelTest.kt new file mode 100644 index 0000000000..90112de7c8 --- /dev/null +++ b/src/apps/mobile/android/app/src/androidTest/kotlin/com/bitfun/mobile/app/ToolConfirmationPanelTest.kt @@ -0,0 +1,35 @@ +package com.bitfun.mobile.app + +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.hasSetTextAction +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import com.bitfun.mobile.app.ui.chat.tool.ToolConfirmationPanel +import com.bitfun.mobile.app.ui.theme.BitFunTheme +import org.junit.Rule +import org.junit.Test + +class ToolConfirmationPanelTest { + @get:Rule + val composeRule = createComposeRule() + + @Test + fun approvalShowsVerdictButtonsWithoutEditableInput() { + composeRule.setContent { + BitFunTheme(dark = false) { + ToolConfirmationPanel( + canApprove = true, + canReject = true, + enabled = true, + onApprove = {}, + onReject = {}, + ) + } + } + + composeRule.onNodeWithText("Approve").assertIsDisplayed() + composeRule.onNodeWithText("Reject").assertIsDisplayed() + composeRule.onAllNodes(hasSetTextAction()).assertCountEquals(0) + } +} diff --git a/src/apps/mobile/android/app/src/androidTest/kotlin/com/bitfun/mobile/app/ToolStatusRowTest.kt b/src/apps/mobile/android/app/src/androidTest/kotlin/com/bitfun/mobile/app/ToolStatusRowTest.kt index b528040149..94a818cf9a 100644 --- a/src/apps/mobile/android/app/src/androidTest/kotlin/com/bitfun/mobile/app/ToolStatusRowTest.kt +++ b/src/apps/mobile/android/app/src/androidTest/kotlin/com/bitfun/mobile/app/ToolStatusRowTest.kt @@ -5,8 +5,10 @@ import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsEnabled import androidx.compose.ui.test.assertIsNotEnabled import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick +import com.bitfun.mobile.app.ui.chat.tool.TOOL_EXPAND_TEST_TAG import androidx.compose.ui.test.performTextInput import com.bitfun.mobile.app.ui.chat.tool.ToolStatusList import com.bitfun.mobile.app.ui.chat.tool.ToolStatusRow @@ -45,6 +47,7 @@ class ToolStatusRowTest { onReject = { rejection = it }, onCancel = {}, onAnswer = {}, + onAnswerStructured = {}, onOpenFile = { _, _ -> }, modifier = Modifier, ) @@ -74,6 +77,7 @@ class ToolStatusRowTest { onReject = {}, onCancel = {}, onAnswer = { answer = it }, + onAnswerStructured = {}, onOpenFile = { _, _ -> }, modifier = Modifier, ) @@ -101,6 +105,7 @@ class ToolStatusRowTest { onReject = {}, onCancel = {}, onAnswer = {}, + onAnswerStructured = {}, onOpenFile = { _, _ -> }, modifier = Modifier, ) @@ -124,6 +129,7 @@ class ToolStatusRowTest { onReject = {}, onCancel = { cancellation = it }, onAnswer = {}, + onAnswerStructured = {}, onOpenFile = { _, _ -> }, modifier = Modifier, ) @@ -145,6 +151,7 @@ class ToolStatusRowTest { onReject = {}, onCancel = {}, onAnswer = {}, + onAnswerStructured = {}, onOpenFile = { _, _ -> }, modifier = Modifier, ) @@ -153,10 +160,33 @@ class ToolStatusRowTest { // The state is carried by the badge, so the line is free to say the one // thing a chip reading "Done" never did: which file was edited. composeRule.onNodeWithText("Edit file · README.md").assertIsDisplayed() + composeRule.onNodeWithTag(TOOL_EXPAND_TEST_TAG).assertIsDisplayed() composeRule.onNodeWithText("Approve").assertDoesNotExist() composeRule.onNodeWithText("Your answer").assertDoesNotExist() } + @Test + fun aCompletedEmptyPreviewStillExpandsWithChevronFeedback() { + composeRule.setContent { + ToolStatusRow( + tool = readTool("a", "One.kt"), + enabled = true, + onApprove = {}, + onReject = {}, + onCancel = {}, + onAnswer = {}, + onAnswerStructured = {}, + onOpenFile = { _, _ -> }, + modifier = Modifier, + ) + } + + composeRule.onNodeWithTag(TOOL_EXPAND_TEST_TAG).assertIsDisplayed() + composeRule.onNodeWithTag(TOOL_EXPAND_TEST_TAG).performClick() + composeRule.onNodeWithTag(TOOL_EXPAND_TEST_TAG).assertIsDisplayed() + composeRule.onNodeWithText("Read file · One.kt").assertIsDisplayed() + } + @Test fun tappingAFileRowOpensThatFileRatherThanExpandingIt() { var opened: Pair? = null @@ -173,6 +203,7 @@ class ToolStatusRowTest { onReject = {}, onCancel = {}, onAnswer = {}, + onAnswerStructured = {}, onOpenFile = { path, label -> opened = path to label }, modifier = Modifier, ) @@ -193,6 +224,7 @@ class ToolStatusRowTest { onReject = { _, _ -> }, onCancel = { _, _ -> }, onAnswer = { _, _ -> }, + onAnswerStructured = { _, _ -> }, onOpenFile = { _, _ -> }, modifier = Modifier, ) diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/platform/AppLocaleController.kt b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/platform/AppLocaleController.kt index a44885c72c..6dfe7e5064 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/platform/AppLocaleController.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/platform/AppLocaleController.kt @@ -14,13 +14,29 @@ internal enum class AppLocale(val languageTag: String) { SIMPLIFIED_CHINESE("zh-CN"), } +/** + * Resolves Android's language token to one of the app-owned locales. + * + * Every `zh*` Android locale intentionally uses Simplified Chinese because + * this app ships only the default and `values-zh` resource catalogs. + */ +internal fun resolveAppLocale(language: String?): AppLocale = + when (language?.trim()?.lowercase(Locale.ROOT)) { + "zh" -> AppLocale.SIMPLIFIED_CHINESE + null, "" -> AppLocale.ENGLISH + else -> AppLocale.ENGLISH + } + internal object AppLocaleController { private const val PREFERENCES = "bitfun_app_settings" private const val LANGUAGE_KEY = "language" fun current(configuration: Configuration): AppLocale { - val language = configuration.locales[0]?.language.orEmpty() - return if (language == "zh") AppLocale.SIMPLIFIED_CHINESE else AppLocale.ENGLISH + val language = configuration.locales + .takeIf { !it.isEmpty } + ?.get(0) + ?.language + return resolveAppLocale(language) } fun set(context: Context, locale: AppLocale) { diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/platform/WindowMetrics.kt b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/platform/WindowMetrics.kt index b0aa686014..e668c5358f 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/platform/WindowMetrics.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/platform/WindowMetrics.kt @@ -38,11 +38,51 @@ internal data class WindowMetrics( val horizontalCreases: List, ) +internal enum class FoldState { + FLAT, + HALF_OPENED, + UNKNOWN, +} + +internal data class FoldFeatureFacts( + val state: FoldState, + val isHorizontal: Boolean, +) + +internal data class FoldFacts( + val hasFoldingFeature: Boolean, + val halfOpened: Boolean, + val flatOpened: Boolean, + val isFolded: Boolean, + val isExpandedFoldable: Boolean, + val hoverCandidate: Boolean, +) + +internal fun reduceFoldFacts( + hasHingeSensor: Boolean, + features: List, +): FoldFacts { + val hasFoldingFeature = features.isNotEmpty() + val halfOpened = features.any { it.state == FoldState.HALF_OPENED } + val flatOpened = hasFoldingFeature && features.all { it.state == FoldState.FLAT } + return FoldFacts( + hasFoldingFeature = hasFoldingFeature, + halfOpened = halfOpened, + flatOpened = flatOpened, + isFolded = hasHingeSensor && !hasFoldingFeature, + isExpandedFoldable = flatOpened, + hoverCandidate = + features.all { it.state != FoldState.UNKNOWN } && + features.any { + it.isHorizontal && it.state == FoldState.HALF_OPENED + }, + ) +} + private data class AndroidFoldInfo( val creases: List, val horizontalCreases: List, - val hasFoldingFeature: Boolean, - val hoverCandidate: Boolean, + val foldFacts: FoldFacts, ) /** @@ -69,12 +109,25 @@ internal fun rememberWindowMetrics(): WindowMetrics { // somewhere that has no fold to report anyway. val foldInfoFlow = remember(activity, density) { if (activity == null) { - flowOf(AndroidFoldInfo(emptyList(), emptyList(), false, false)) + flowOf(AndroidFoldInfo(emptyList(), emptyList(), reduceFoldFacts(false, emptyList()))) } else { WindowInfoTracker.getOrCreate(activity) .windowLayoutInfo(activity) .map { info -> val features = info.displayFeatures.filterIsInstance() + val foldFacts = reduceFoldFacts( + hasHingeSensor = hasHingeSensor, + features = features.map { feature -> + FoldFeatureFacts( + state = when (feature.state) { + FoldingFeature.State.HALF_OPENED -> FoldState.HALF_OPENED + FoldingFeature.State.FLAT -> FoldState.FLAT + else -> FoldState.UNKNOWN + }, + isHorizontal = feature.orientation == FoldingFeature.Orientation.HORIZONTAL, + ) + }, + ) AndroidFoldInfo( creases = features .filter { it.orientation == FoldingFeature.Orientation.VERTICAL } @@ -96,29 +149,23 @@ internal fun rememberWindowMetrics(): WindowMetrics { ) } }, - hasFoldingFeature = features.isNotEmpty(), - hoverCandidate = features.any { feature -> - feature.orientation == FoldingFeature.Orientation.HORIZONTAL && - feature.state == FoldingFeature.State.HALF_OPENED - }, + foldFacts = foldFacts, ) } } } val foldInfo by foldInfoFlow.collectAsStateWithLifecycle( - AndroidFoldInfo(emptyList(), emptyList(), false, false), + AndroidFoldInfo(emptyList(), emptyList(), reduceFoldFacts(false, emptyList())), ) return WindowMetrics( widthDp = widthDp, heightDp = heightDp, wideViewportMatched = widthDp >= ConversationLayoutPolicy.MD_MIN_WIDTH, - // Android exposes FLAT and HALF_OPENED while the app is visible; a - // fully closed device runs on a narrow cover display instead. - isFolded = false, - isExpandedFoldable = foldInfo.hasFoldingFeature || hasHingeSensor, + isFolded = foldInfo.foldFacts.isFolded, + isExpandedFoldable = foldInfo.foldFacts.isExpandedFoldable, isHoverLayout = ConversationLayoutPolicy.useHoverOperate( - foldInfo.hoverCandidate, + foldInfo.foldFacts.hoverCandidate, widthDp, heightDp, ), diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/state/AppShellState.kt b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/state/AppShellState.kt index cb6bc342a7..8b24de5947 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/state/AppShellState.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/state/AppShellState.kt @@ -108,6 +108,20 @@ internal class AppShellState( remoteSessionId = null } + /** + * Opens the remote surface's connect page without launching the scanner. + * + * The sidebar's "Connect a computer" row is a door to the choose-connection + * page, not a camera trigger: scanning stays a named action the user taps. + * [openRemoteScanner] remains for entry points whose whole job is to scan. + */ + internal fun openRemoteConnect() { + surface = MobileSurface.REMOTE + remoteCreating = false + remoteSessionId = null + remoteScanRequested = false + } + internal fun openRemoteScanner() { surface = MobileSurface.REMOTE remoteCreating = false diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/account/AccountScreen.kt b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/account/AccountScreen.kt index 4cc2c249ec..6a6370e67d 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/account/AccountScreen.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/account/AccountScreen.kt @@ -240,13 +240,26 @@ private fun AccountProfilePage( } else { state.devices.forEach { device -> val selected = device.id == state.selectedDeviceId - Row(Modifier.fillMaxWidth().height(54.dp).clickable(enabled = device.online, onClick = { onSelect(device.id) }), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { + val reconnectable = selected && !device.online + Row(Modifier.fillMaxWidth().height(54.dp).clickable(enabled = device.online || reconnectable, onClick = { onSelect(device.id) }), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { Icon(painterResource(R.drawable.ic_symbol_desktop), contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(24.dp)) Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { Text(device.name.ifBlank { device.id }, fontSize = 15.sp, fontWeight = FontWeight.Medium, maxLines = 1, overflow = TextOverflow.Ellipsis) - Text(if (selected) stringResource(R.string.account_device_current_control) else stringResource(if (device.online) R.string.account_online else R.string.account_offline), fontSize = 13.sp, color = if (device.online) com.bitfun.mobile.app.ui.theme.bitFunColors.success else MaterialTheme.colorScheme.onSurfaceVariant) + Text( + stringResource( + when { + selected && device.online -> R.string.account_device_current_control + device.online -> R.string.account_online + else -> R.string.account_offline + }, + ), + fontSize = 13.sp, + color = if (device.online) com.bitfun.mobile.app.ui.theme.bitFunColors.success else MaterialTheme.colorScheme.onSurfaceVariant, + ) } - if (device.online && !selected) Surface(color = MaterialTheme.colorScheme.surfaceVariant, shape = RoundedCornerShape(14.dp)) { + if (reconnectable) Surface(color = MaterialTheme.colorScheme.surfaceVariant, shape = RoundedCornerShape(14.dp)) { + Text(stringResource(R.string.remote_settings_reconnect), fontSize = 14.sp, modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp)) + } else if (device.online && !selected) Surface(color = MaterialTheme.colorScheme.surfaceVariant, shape = RoundedCornerShape(14.dp)) { Text(stringResource(R.string.account_connect), fontSize = 14.sp, modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp)) } } diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/chat/ChatMessageBubble.kt b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/chat/ChatMessageBubble.kt index 4282784216..7ca15b936d 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/chat/ChatMessageBubble.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/chat/ChatMessageBubble.kt @@ -121,12 +121,17 @@ private fun AssistantContent(row: ConversationRow, callbacks: MessageBlockCallba return } - row.thinking?.let { ThinkingBlock(it, row.streaming) } + row.thinking?.let { ThinkingBlock(it, row.streaming, row.id) } if (row.text.isNotEmpty()) { // No bubble on this side, matching `ChatMessageChrome.ets`: an agent // turn carries headings, lists and code cards, and a rounded tint // around all of that reads as one quoted lump. - MarkdownContent(text = row.text, onOpenLink = callbacks.onOpenLink, modifier = Modifier) + MarkdownContent( + text = row.text, + onOpenLink = callbacks.onOpenLink, + modifier = Modifier, + streaming = row.streaming, + ) FileReferenceCards( text = row.text, previewingRemotePath = callbacks.previewingRemotePath, diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/chat/ChatStatusBar.kt b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/chat/ChatStatusBar.kt index 9b67a023df..64d552e666 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/chat/ChatStatusBar.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/chat/ChatStatusBar.kt @@ -21,6 +21,10 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -34,6 +38,7 @@ import com.bitfun.mobile.core.feature.connection.ConnectionTone internal const val CHAT_STATUS_BAR_TEST_TAG: String = "chat-status-bar" internal const val CHAT_STATUS_DOT_TEST_TAG: String = "chat-status-dot" +internal const val CHAT_STATUS_STOP_TEST_TAG: String = "chat-status-stop" /** The relay state strip between the conversation header and transcript. */ @Composable @@ -47,6 +52,7 @@ internal fun ChatStatusBar( val copy = phase.chatStatusBarCopy(canStop) val title = stringResource(copy.title) val detail = stringResource(copy.detail) + val stopLabel = stringResource(R.string.message_stop) val statusLabel = if (detail != title) "$title · $detail" else title val statusColor = when (ConnectionStatusPresenter.tone(phase)) { ConnectionTone.OK -> bitFunColors.success @@ -95,11 +101,16 @@ internal fun ChatStatusBar( .width(68.dp) .height(34.dp) .background(MaterialTheme.colorScheme.surfaceVariant, RoundedCornerShape(17.dp)) - .clickable(onClick = onStop), + .clickable(role = Role.Button, onClick = onStop) + .semantics { + contentDescription = stopLabel + role = Role.Button + } + .testTag(CHAT_STATUS_STOP_TEST_TAG), contentAlignment = Alignment.Center, ) { Text( - stringResource(R.string.message_stop), + stopLabel, fontSize = 13.sp, fontWeight = FontWeight.Medium, color = MaterialTheme.colorScheme.onSurface, diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/chat/ComposerBar.kt b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/chat/ComposerBar.kt index 3cc07956ef..26a8376279 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/chat/ComposerBar.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/chat/ComposerBar.kt @@ -59,7 +59,10 @@ import androidx.compose.ui.platform.LocalConfiguration 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.contentDescription +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.selected import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow @@ -69,6 +72,7 @@ import com.bitfun.mobile.app.R import com.bitfun.mobile.app.ui.theme.BitFunEaseOut import com.bitfun.mobile.app.ui.theme.MotionQuickMillis import com.bitfun.mobile.app.ui.theme.MotionStructureMillis +import com.bitfun.mobile.app.ui.theme.generated.MobileDesignBreakpoints import com.bitfun.mobile.app.ui.theme.generated.MobileDesignGeometry import com.bitfun.mobile.core.feature.connection.ConnectionPhase import com.bitfun.mobile.core.feature.session.ChatComposerCapabilities @@ -87,6 +91,9 @@ internal const val MODEL_SELECTOR_OPTION_TEST_TAG_PREFIX: String = "composer-mod /** The relay refuses more than this, and refusing here is a better error. */ internal const val MAX_COMPOSER_IMAGES: Int = 4 +internal fun composerIsWide(screenWidthDp: Int): Boolean = + screenWidthDp >= MobileDesignBreakpoints.Wide + // The measurements come straight from `ComposerBar.ets`, which sizes the bar in // vp — the same unit as dp. Naming them keeps the two files diffable. private val ActionSize = MobileDesignGeometry.ComposerActionSize @@ -122,6 +129,13 @@ internal fun ComposerBar( phase: ConnectionPhase, model: ModelOption?, modelOptions: List = emptyList(), + /** + * Whether the model catalog command failed and left no selectable models. + * When true the bar still offers the model control so the settings sheet can + * explain the failure and offer Retry, instead of silently dropping the + * control and hiding the only route to that explanation. + */ + modelCatalogFailed: Boolean = false, capabilities: ChatComposerCapabilities, /** * What the empty field says. Every surface asks for something different — @@ -301,7 +315,7 @@ internal fun ComposerBar( } // The model belongs to the session, but it is chosen // here: it is the one setting a user changes mid-turn. - if (model != null) { + if (model != null || modelOptions.isNotEmpty() || modelCatalogFailed) { ModelControl( model = model, enabled = !busy, @@ -393,7 +407,7 @@ private fun AddButton(enabled: Boolean, onClick: () -> Unit) { modifier = Modifier .size(ActionSize) .clip(CircleShape) - .clickable(enabled = enabled, onClick = onClick), + .clickable(role = Role.Button, enabled = enabled, onClick = onClick), ) { Icon( painterResource(R.drawable.ic_symbol_plus), @@ -413,7 +427,7 @@ private fun AddButton(enabled: Boolean, onClick: () -> Unit) { @OptIn(ExperimentalMaterial3Api::class) @Composable private fun ModelControl( - model: ModelOption, + model: ModelOption?, enabled: Boolean, modelOptions: List, onClick: () -> Unit, @@ -422,7 +436,9 @@ private fun ModelControl( onSelectorDismiss: () -> Unit, ) { val label = stringResource(R.string.models_title) - val wide = LocalConfiguration.current.screenWidthDp >= 600 + val displayModel = model ?: modelOptions.firstOrNull { it.selected } + val displayLabel = displayModel?.primaryLabel ?: label + val wide = composerIsWide(LocalConfiguration.current.screenWidthDp) Box { Row( verticalAlignment = Alignment.CenterVertically, @@ -434,10 +450,13 @@ private fun ModelControl( .clickable(enabled = enabled, onClick = onClick) .padding(horizontal = 4.dp) .testTag(MODEL_CONTROL_TEST_TAG) - .semantics { contentDescription = "$label · ${model.primaryLabel}" }, + .semantics { + contentDescription = "$label · $displayLabel" + role = Role.Button + }, ) { Text( - model.primaryLabel, + displayLabel, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurface, maxLines = 1, @@ -538,7 +557,7 @@ private fun ModelSelectorContent( modifier = Modifier .size(MobileDesignGeometry.SelectionCloseSize) .clip(CircleShape) - .clickable(onClick = onDismiss), + .clickable(role = Role.Button, onClick = onDismiss), ) { Icon( painterResource(R.drawable.ic_symbol_xmark), @@ -586,6 +605,12 @@ private fun ModelSelectorContent( else Color.Transparent, ) .clickable { onSelect(option.id) } + .semantics { + contentDescription = + "${option.primaryLabel} · ${option.secondaryLabel}" + role = Role.Button + selected = option.selected + } .padding(horizontal = 10.dp) .testTag(MODEL_SELECTOR_OPTION_TEST_TAG_PREFIX + option.id), ) { @@ -656,6 +681,7 @@ private fun PrimaryActionButton( R.string.message_voice_input else -> R.string.message_send } + val actionDescription = stringResource(description) Box( contentAlignment = Alignment.Center, @@ -671,6 +697,10 @@ private fun PrimaryActionButton( else -> Unit } } + .semantics { + contentDescription = actionDescription + role = Role.Button + } .testTag(COMPOSER_SEND_TEST_TAG), ) { when (action) { @@ -685,7 +715,7 @@ private fun PrimaryActionButton( ComposerPrimaryAction.VOICE, ComposerPrimaryAction.VOICE_BLOCKED -> Icon( painterResource(R.drawable.ic_symbol_mic), - contentDescription = stringResource(description), + contentDescription = null, tint = colors.onSurface, modifier = Modifier.size(22.dp).alpha( if (action == ComposerPrimaryAction.VOICE) 1f else DimmedAlpha, @@ -694,7 +724,7 @@ private fun PrimaryActionButton( else -> Icon( painterResource(R.drawable.ic_symbol_arrow_up), - contentDescription = stringResource(description), + contentDescription = null, tint = colors.onSurface, modifier = Modifier.size(23.dp).alpha( if (action == ComposerPrimaryAction.SEND) 1f else DimmedAlpha, @@ -756,7 +786,7 @@ private fun AttachmentStrip( .size(32.dp) .clip(CircleShape) .background(BadgeScrim) - .clickable(enabled = enabled) { onRemove(image.id) } + .clickable(role = Role.Button, enabled = enabled) { onRemove(image.id) } .semantics { contentDescription = removeLabel }, ) { Text( diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/chat/ConversationHeader.kt b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/chat/ConversationHeader.kt index c8e2e41dce..456846eff2 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/chat/ConversationHeader.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/chat/ConversationHeader.kt @@ -117,14 +117,21 @@ internal fun ConversationHeader( title.ifBlank { stringResource(R.string.conversation_title_default) }, style = if (hasSubtitle) MobileDesignTypography.ConversationHeaderTitle else MobileDesignTypography.TitleMedium, + color = MaterialTheme.colorScheme.onSurface, textAlign = TextAlign.Center, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier .testTag(CONVERSATION_TITLE_TEST_TAG) .clickable(enabled = enabled) { - draft = title - editing = !editing + // The title and actions are mutually exclusive surfaces. + menuOpen = false + // Opening the editor re-seeds the draft from the + // committed title; re-tapping the title while the + // editor is already open must not throw the user's + // in-progress text away. + if (!editing) draft = title + editing = true }, ) if (hasSubtitle) { @@ -151,7 +158,7 @@ internal fun ConversationHeader( // The editor and the menu are two answers to the same // tap target area; opening one closes the other. editing = false - menuOpen = true + menuOpen = !menuOpen }, modifier = Modifier.testTag(CONVERSATION_MENU_TEST_TAG), ) diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/chat/ConversationTimelineView.kt b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/chat/ConversationTimelineView.kt index a261f076a6..e43c06f1f4 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/chat/ConversationTimelineView.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/chat/ConversationTimelineView.kt @@ -1,6 +1,5 @@ package com.bitfun.mobile.app.ui.chat -import androidx.compose.foundation.gestures.scrollBy import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues @@ -37,6 +36,30 @@ import com.bitfun.mobile.core.feature.session.ConversationRow import com.bitfun.mobile.core.feature.session.QuestionAnswer import com.bitfun.mobile.core.feature.workspace.RemoteFileDownloadUiState +/** Pure decisions for keeping a forward timeline at its visual tail. */ +internal object ConversationScrollPolicy { + fun shouldStickToBottom( + currentlySticking: Boolean, + isAtBottom: Boolean, + isScrollInProgress: Boolean, + ): Boolean = when { + isAtBottom -> true + isScrollInProgress -> false + else -> currentlySticking + } + + fun shouldScrollToBottom(stickToBottom: Boolean, hasRows: Boolean): Boolean = + stickToBottom && hasRows + + /** + * The LazyColumn puts the "load older messages" header at index zero when + * [hasMoreMessages] is true, so the real tail is one past [rowCount] instead + * of `rowCount - 1`. + */ + fun lastItemIndex(rowCount: Int, hasMoreMessages: Boolean): Int = + if (hasMoreMessages) rowCount else (rowCount - 1).coerceAtLeast(0) +} + /** Timeline renderer over feature-owned presentation rows; session routing stays above it. */ @Composable internal fun ConversationTimelineView( @@ -65,19 +88,21 @@ internal fun ConversationTimelineView( LaunchedEffect(listState) { snapshotFlow { listState.isScrollInProgress to listState.canScrollForward } .collect { (scrolling, canScrollForward) -> - when { - !canScrollForward -> stickToBottom = true - scrolling -> stickToBottom = false - } + stickToBottom = ConversationScrollPolicy.shouldStickToBottom( + currentlySticking = stickToBottom, + isAtBottom = !canScrollForward, + isScrollInProgress = scrolling, + ) } } - LaunchedEffect(rows, stickToBottom) { - if (stickToBottom && rows.isNotEmpty()) { - listState.scrollToItem(rows.lastIndex) - while (listState.canScrollForward) { - val viewport = listState.layoutInfo.viewportSize.height.coerceAtLeast(1) - if (listState.scrollBy(viewport * 8f) <= 0f) break - } + LaunchedEffect(rows, stickToBottom, hasMoreMessages) { + if (ConversationScrollPolicy.shouldScrollToBottom(stickToBottom, rows.isNotEmpty())) { + // A large offset positions the item's bottom at the viewport tail directly; + // unlike scrollToItem(index), it does not briefly expose the item's top. + listState.scrollToItem( + ConversationScrollPolicy.lastItemIndex(rows.size, hasMoreMessages), + scrollOffset = Int.MAX_VALUE, + ) } } diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/chat/ConversationView.kt b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/chat/ConversationView.kt index 1f6b3638d1..50ef4d88fc 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/chat/ConversationView.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/chat/ConversationView.kt @@ -8,11 +8,14 @@ import android.widget.Toast import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -44,6 +47,7 @@ import java.util.UUID internal const val CONVERSATION_TEST_TAG: String = "conversation" internal const val CONVERSATION_BACK_TEST_TAG: String = "conversation-back" +internal const val CONVERSATION_LOADING_TEST_TAG: String = "conversation-loading" /** * The transcript itself, tagged so a test can scroll it to a row. @@ -56,6 +60,16 @@ internal const val CONVERSATION_LIST_TEST_TAG: String = "conversation-list" /** The relay refuses anything larger, and refusing here is a better error. */ private const val MAX_IMAGE_BYTES = 8 * 1024 * 1024 +/** + * Joins a dictated fragment onto whatever the composer already holds. + * + * Extracted so the voice path and its merge policy are unit-testable. It keeps + * the single-space join the previous in-composition draft used: a blank side is + * dropped rather than leaving a doubled or leading space. + */ +internal fun mergeComposerDraft(existing: String, spoken: String): String = + listOf(existing.trim(), spoken.trim()).filter(String::isNotEmpty).joinToString(" ") + /** * One open session: the transcript and the composer, ported from * `pages/components/ConversationSurface.ets`. @@ -95,9 +109,10 @@ internal fun ConversationView( onDownloadFile: (String, String) -> Unit, modifier: Modifier, ) { - val timeline = state.timeline ?: return + val timeline = state.timeline + val activeTurn = timeline?.activeTurn val sessionId = state.selectedSessionId.orEmpty() - val rows = remember(timeline) { timeline.conversationRows() } + val rows = remember(timeline) { timeline?.conversationRows().orEmpty() } val visibleRows = remember(rows) { rows.filter { it.kind != ConversationRowKind.EMPTY } } val uploadedFileCount = rows.sumOf { it.images.size } // Resolved here rather than inside the click: a Toast is raised from a @@ -108,7 +123,10 @@ internal fun ConversationView( } else { stringResource(R.string.session_uploaded_files_empty) } - var draft by rememberSaveable(sessionId) { mutableStateOf("") } + // The remote composer's single source of truth is the store's draft. Typing, + // voice, and send all round-trip through `state.draft` so a half-written + // message survives session switches and process restarts via DraftStore. + val draft = state.draft var images by remember(sessionId) { mutableStateOf>(emptyList()) } var showSettings by rememberSaveable(sessionId) { mutableStateOf(false) } val context = LocalContext.current @@ -130,7 +148,7 @@ internal fun ConversationView( if (result.resultCode == Activity.RESULT_OK) { val text = result.data?.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS)?.firstOrNull().orEmpty() if (text.isNotBlank()) { - draft = listOf(draft.trim(), text.trim()).filter(String::isNotEmpty).joinToString(" ") + onIntent(RemoteSessionIntent.UpdateDraft(mergeComposerDraft(state.draft, text))) } } } @@ -139,7 +157,7 @@ internal fun ConversationView( ConversationHeader( title = state.sessions.firstOrNull { it.id == sessionId }?.title.orEmpty(), contextTitle = contextTitle, - canStop = timeline.activeTurn != null, + canStop = activeTurn != null, enabled = !state.busy && sessionId.isNotEmpty(), onBack = onBack, onOpenSidebar = onOpenSidebar, @@ -150,7 +168,7 @@ internal fun ConversationView( Toast.makeText(context, uploadedFilesMessage, Toast.LENGTH_SHORT).show() }, onStop = { - onIntent(RemoteSessionIntent.CancelTurn(sessionId, timeline.activeTurn?.turnId)) + onIntent(RemoteSessionIntent.CancelTurn(sessionId, activeTurn?.turnId)) }, modifier = Modifier, ) @@ -158,57 +176,64 @@ internal fun ConversationView( if (phase != ConnectionPhase.CONNECTED) { ChatStatusBar( phase = phase, - canStop = timeline.activeTurn != null, + canStop = activeTurn != null, onStop = { - onIntent(RemoteSessionIntent.CancelTurn(sessionId, timeline.activeTurn?.turnId)) + onIntent(RemoteSessionIntent.CancelTurn(sessionId, activeTurn?.turnId)) }, ) } - ConversationTimelineView( - rows = visibleRows, - hasMoreMessages = state.hasMoreMessages, - onLoadOlder = { onIntent(RemoteSessionIntent.LoadOlderMessages) }, - enabled = !state.busy, - onApproveTool = { toolId -> - onIntent(RemoteSessionIntent.ApproveTool(sessionId, toolId)) - }, - onRejectTool = { toolId, reason -> - onIntent(RemoteSessionIntent.RejectTool(sessionId, toolId, reason)) - }, - onCancelTool = { toolId, reason -> - onIntent(RemoteSessionIntent.CancelTool(sessionId, toolId, reason)) - }, - onAnswerTool = { toolId, answer -> - onIntent(RemoteSessionIntent.AnswerQuestion(sessionId, toolId, answer)) - }, - onAnswerToolStructured = { toolId, answers -> - onIntent(AnswerStructuredQuestion(sessionId, toolId, answers)) - }, - onRetry = { text -> - onIntent(RemoteSessionIntent.SendMessage(sessionId, text, null)) - }, - onOpenFile = onOpenFile, - previewingRemotePath = previewingRemotePath, - previewLoading = previewLoading, - download = download, - onDownloadFile = onDownloadFile, - downloadEnabled = !state.busy && phase == ConnectionPhase.CONNECTED, - modifier = Modifier.weight(1f).fillMaxWidth(), - ) + if (timeline == null) { + ConversationLoadingState(modifier = Modifier.weight(1f).fillMaxWidth()) + } else if (visibleRows.isEmpty()) { + ConversationEmptyState(modifier = Modifier.weight(1f).fillMaxWidth()) + } else { + ConversationTimelineView( + rows = visibleRows, + hasMoreMessages = state.hasMoreMessages, + onLoadOlder = { onIntent(RemoteSessionIntent.LoadOlderMessages) }, + enabled = !state.busy, + onApproveTool = { toolId -> + onIntent(RemoteSessionIntent.ApproveTool(sessionId, toolId)) + }, + onRejectTool = { toolId, reason -> + onIntent(RemoteSessionIntent.RejectTool(sessionId, toolId, reason)) + }, + onCancelTool = { toolId, reason -> + onIntent(RemoteSessionIntent.CancelTool(sessionId, toolId, reason)) + }, + onAnswerTool = { toolId, answer -> + onIntent(RemoteSessionIntent.AnswerQuestion(sessionId, toolId, answer)) + }, + onAnswerToolStructured = { toolId, answers -> + onIntent(AnswerStructuredQuestion(sessionId, toolId, answers)) + }, + onRetry = { text -> + onIntent(RemoteSessionIntent.SendMessage(sessionId, text, null)) + }, + onOpenFile = onOpenFile, + previewingRemotePath = previewingRemotePath, + previewLoading = previewLoading, + download = download, + onDownloadFile = onDownloadFile, + downloadEnabled = !state.busy && phase == ConnectionPhase.CONNECTED, + modifier = Modifier.weight(1f).fillMaxWidth(), + ) + } ComposerBar( draft = draft, images = images, // An empty session id would send nowhere, so it reads as busy. busy = state.busy || sessionId.isEmpty(), - streaming = timeline.activeTurn != null, + streaming = activeTurn != null, phase = phase, - model = timeline.selectedModelOption(stringResource(R.string.models_unnamed)), - modelOptions = timeline.modelOptions(stringResource(R.string.models_unnamed)), + model = timeline?.selectedModelOption(stringResource(R.string.models_unnamed)), + modelOptions = timeline?.modelOptions(stringResource(R.string.models_unnamed)) ?: emptyList(), + modelCatalogFailed = state.modelCatalogFailure != null, capabilities = ChatComposerCapabilities.RemoteChat, placeholder = stringResource(R.string.message_input_label), - onDraftChange = { draft = it }, + onDraftChange = { onIntent(RemoteSessionIntent.UpdateDraft(it)) }, onRemoveImage = { id -> images = images.filterNot { it.id == id } }, onOpenModels = { showSettings = true }, onSelectModel = { modelId -> @@ -238,11 +263,10 @@ internal fun ConversationView( images.takeIf { it.isNotEmpty() }, ), ) - draft = "" images = emptyList() }, onStop = { - onIntent(RemoteSessionIntent.CancelTurn(sessionId, timeline.activeTurn?.turnId)) + onIntent(RemoteSessionIntent.CancelTurn(sessionId, activeTurn?.turnId)) }, ) } @@ -262,3 +286,15 @@ internal fun ConversationView( } } } + +@Composable +private fun ConversationLoadingState(modifier: Modifier) { + Column( + modifier = modifier.testTag(CONVERSATION_LOADING_TEST_TAG), + horizontalAlignment = androidx.compose.ui.Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + CircularProgressIndicator() + Text(text = stringResource(R.string.chat_empty_loading)) + } +} diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/chat/FileReferenceCards.kt b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/chat/FileReferenceCards.kt index 70b826963e..d66add4326 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/chat/FileReferenceCards.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/chat/FileReferenceCards.kt @@ -26,8 +26,10 @@ import androidx.compose.ui.draw.alpha import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import com.bitfun.mobile.app.R import com.bitfun.mobile.core.feature.session.MessageFileReference import com.bitfun.mobile.core.feature.session.MessageFileReferenceProjector @@ -147,7 +149,8 @@ private fun FileReferenceCard( ) { Text( reference.label, - style = MaterialTheme.typography.bodyMedium, + fontSize = 13.sp, + fontWeight = FontWeight.Medium, maxLines = 1, overflow = TextOverflow.Ellipsis, ) diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/chat/InlineImage.kt b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/chat/InlineImage.kt index a5f30aa223..3ce74b64d6 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/chat/InlineImage.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/chat/InlineImage.kt @@ -12,7 +12,10 @@ import android.util.Base64 * so a URL of any other shape decodes to null and the caller shows a caption. */ internal fun decodeInlineImage(dataUrl: String): Bitmap? { - val payload = dataUrl.substringAfter("base64,", "") + if (!dataUrl.startsWith("data:", ignoreCase = true)) return null + val metadata = dataUrl.substringBefore(',') + if (!metadata.split(';').any { it.equals("base64", ignoreCase = true) }) return null + val payload = dataUrl.substringAfter(',', "") if (payload.isEmpty()) return null return runCatching { val bytes = Base64.decode(payload, Base64.DEFAULT) diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/chat/MarkdownContent.kt b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/chat/MarkdownContent.kt index 6ce6c41744..be20478807 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/chat/MarkdownContent.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/chat/MarkdownContent.kt @@ -56,6 +56,7 @@ import kotlinx.coroutines.launch internal const val MARKDOWN_TEST_TAG: String = "markdown" + /** * An agent turn as marked-up text, ported from `pages/components/MarkdownContent.ets`. * @@ -76,21 +77,36 @@ internal fun MarkdownContent( text: String, onOpenLink: (String, String) -> Unit, modifier: Modifier, + streaming: Boolean = false, ) { val blocks = remember(text) { MarkdownParser.parse(text) } Column( modifier = modifier.fillMaxWidth().testTag(MARKDOWN_TEST_TAG), verticalArrangement = Arrangement.spacedBy(5.dp), ) { - blocks.forEach { block -> MarkdownBlockView(block = block, onOpenLink = onOpenLink) } + blocks.forEach { block -> + MarkdownBlockView( + block = block, + onOpenLink = onOpenLink, + copyFullText = if (streaming) text else null, + ) + } } } @Composable -private fun MarkdownBlockView(block: MarkdownBlock, onOpenLink: (String, String) -> Unit) { +private fun MarkdownBlockView( + block: MarkdownBlock, + onOpenLink: (String, String) -> Unit, + copyFullText: String?, +) { val colors = MaterialTheme.colorScheme when (block.type) { - "code" -> CodeBlock(language = block.language, body = block.text) + "code" -> CodeBlock( + language = block.language, + body = block.text, + copyFullText = copyFullText, + ) "heading" -> InlineText( inlines = block.inlines, @@ -240,7 +256,8 @@ private fun MarkdownList(items: List, onOpenLink: (String, Str * wrote is meant to be run, and retyping it is how a typo gets into a shell. */ @Composable -private fun CodeBlock(language: String, body: String) { +private fun CodeBlock(language: String, body: String, copyFullText: String?) { + val copyBody = copyFullText ?: body val clipboard = LocalClipboard.current val scope = rememberCoroutineScope() val copyLabel = stringResource(R.string.chat_copy) @@ -261,7 +278,7 @@ private fun CodeBlock(language: String, body: String) { modifier = Modifier.clickableText { scope.launch { clipboard.setClipEntry( - ClipEntry(ClipData.newPlainText(copyLabel, body)), + ClipEntry(ClipData.newPlainText(copyLabel, copyBody)), ) } }, diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/chat/message/MessageBlockList.kt b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/chat/message/MessageBlockList.kt index b7440f8b7c..d7f215a92f 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/chat/message/MessageBlockList.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/chat/message/MessageBlockList.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.selection.toggleable import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -20,6 +21,8 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.geometry.Offset import androidx.compose.ui.platform.testTag @@ -80,7 +83,12 @@ internal fun MessageBlockList( private fun MessageBlockView(block: MessageBlock, callbacks: MessageBlockCallbacks) { when (block) { is MessageBlock.Text -> { - MarkdownContent(text = block.text, onOpenLink = callbacks.onOpenLink, modifier = Modifier) + MarkdownContent( + text = block.text, + onOpenLink = callbacks.onOpenLink, + modifier = Modifier, + streaming = block.streaming, + ) FileReferenceCards( text = block.text, previewingRemotePath = callbacks.previewingRemotePath, @@ -93,7 +101,7 @@ private fun MessageBlockView(block: MessageBlock, callbacks: MessageBlockCallbac ) } - is MessageBlock.Thinking -> ThinkingBlock(block.text, block.streaming) + is MessageBlock.Thinking -> ThinkingBlock(block.text, block.streaming, block.id) is MessageBlock.Tools -> ToolStatusList( tools = block.tools, @@ -121,6 +129,17 @@ private fun MessageBlockView(block: MessageBlock, callbacks: MessageBlockCallbac */ @Composable private fun SubagentGroup(block: MessageBlock.Subagent, callbacks: MessageBlockCallbacks) { + var expanded by remember(block.id) { mutableStateOf(block.running) } + var userToggled by remember(block.id) { mutableStateOf(false) } + LaunchedEffect(block.running) { + expanded = if (block.running) { + if (userToggled) expanded else true + } else { + userToggled = false + false + } + } + val outline = MaterialTheme.colorScheme.outlineVariant Column( modifier = Modifier @@ -131,14 +150,26 @@ private fun SubagentGroup(block: MessageBlock.Subagent, callbacks: MessageBlockC verticalArrangement = Arrangement.spacedBy(6.dp), ) { Row( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .semantics(mergeDescendants = true) {} + .toggleable( + value = expanded, + role = Role.Button, + onValueChange = { + userToggled = block.running + expanded = !expanded + }, + ), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, ) { - Text( - "↳", - fontSize = 13.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant, + androidx.compose.material3.Icon( + painter = androidx.compose.ui.res.painterResource( + if (expanded) R.drawable.ic_symbol_chevron_down else R.drawable.ic_symbol_chevron_right, + ), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.width(18.dp), ) Text( @@ -148,11 +179,8 @@ private fun SubagentGroup(block: MessageBlock.Subagent, callbacks: MessageBlockC color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.weight(1f, fill = false), ) - // Its own dots, not the turn's: what is being waited on here is the - // subagent, and the agent that started it may be done. - if (block.running) ChatTypingDots(Modifier) } - if (block.children.isNotEmpty()) { + if (expanded) { Column( modifier = Modifier .fillMaxWidth() @@ -167,6 +195,15 @@ private fun SubagentGroup(block: MessageBlock.Subagent, callbacks: MessageBlockC .padding(start = 12.dp), verticalArrangement = Arrangement.spacedBy(6.dp), ) { + if (block.running) ChatTypingDots(Modifier) + if (block.text.isNotBlank()) { + Text( + block.text, + fontSize = 14.sp, + lineHeight = 21.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } block.children.forEach { child -> MessageBlockView(child, callbacks) } } } @@ -181,9 +218,9 @@ private fun SubagentGroup(block: MessageBlock.Subagent, callbacks: MessageBlockC * is the only part of the turn there is to read. */ @Composable -internal fun ThinkingBlock(thinking: String, streaming: Boolean) { - var expanded by remember(thinking) { mutableStateOf(streaming) } - var userToggled by remember(thinking) { mutableStateOf(false) } +internal fun ThinkingBlock(thinking: String, streaming: Boolean, stateKey: String) { + var expanded by remember(stateKey) { mutableStateOf(streaming) } + var userToggled by remember(stateKey) { mutableStateOf(false) } LaunchedEffect(streaming) { expanded = if (streaming) { if (userToggled) expanded else true diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/chat/tool/ToolInteractionPanels.kt b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/chat/tool/ToolInteractionPanels.kt index 9aa4016e12..37059025ca 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/chat/tool/ToolInteractionPanels.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/chat/tool/ToolInteractionPanels.kt @@ -29,12 +29,16 @@ import com.bitfun.mobile.app.R import com.bitfun.mobile.core.feature.session.QuestionAnswer import com.bitfun.mobile.core.feature.session.QuestionAnswerValue import com.bitfun.mobile.core.feature.session.QuestionOption +import com.bitfun.mobile.core.feature.session.ToolApprovalEditContract +import com.bitfun.mobile.core.feature.session.ToolApprovalEditSupport import com.bitfun.mobile.core.feature.session.ToolQuestion /** * Approve and reject as equal halves of one row, ported from * `ToolConfirmationPanel` in `pages/components/ToolInteractionPanels.ets`. * + * Editable approval is gated by [ToolApprovalEditContract.support] and is + * intentionally not rendered while support is [ToolApprovalEditSupport.UNSUPPORTED]. * The HarmonyOS source offers a JSON editor over `tool_input` before approving. * Android does not expose it yet because the shared intent still carries only a * tool id; the HarmonyOS command factory currently drops `updatedInput` at the diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/chat/tool/ToolStatusList.kt b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/chat/tool/ToolStatusList.kt index ad3ec3c34c..e28cb54b24 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/chat/tool/ToolStatusList.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/chat/tool/ToolStatusList.kt @@ -44,6 +44,7 @@ private const val CANCEL_REASON = "Cancelled from the Android client" internal const val TOOL_ROW_TEST_TAG: String = "tool-row" internal const val TOOL_GROUP_TEST_TAG: String = "tool-group" +internal const val TOOL_EXPAND_TEST_TAG: String = "tool-expand" /** The indent that lines a row's detail up under its label rather than its icon. */ private val DETAIL_INDENT = 28.dp @@ -181,7 +182,7 @@ internal fun ToolStatusRow( var expanded by remember(tool.id) { mutableStateOf(false) } val blocking = tool.actions.isNotEmpty() val emphasized = expanded || blocking || tool.phase == ToolPhase.FAILED - val canExpand = tool.input.isNotEmpty() || tool.output.isNotEmpty() + val canExpand = tool.expandable val openable = tool.filePath.isNotEmpty() Column( @@ -237,6 +238,7 @@ internal fun ToolStatusRow( Box( modifier = Modifier .size(width = 32.dp, height = 28.dp) + .testTag(TOOL_EXPAND_TEST_TAG) .clickable { expanded = !expanded }, contentAlignment = Alignment.Center, ) { diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/common/AdaptiveModalSurface.kt b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/common/AdaptiveModalSurface.kt index 6e686723e9..9da8e461a9 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/common/AdaptiveModalSurface.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/common/AdaptiveModalSurface.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.safeDrawingPadding import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape @@ -59,7 +60,8 @@ internal fun AdaptiveModalSurface( .fillMaxSize() .background(bitFunColors.modalScrim) .clickable(onClick = onDismissRequest) - .safeDrawingPadding(), + .safeDrawingPadding() + .imePadding(), contentAlignment = Alignment.CenterEnd, ) { Surface( diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/common/CircleControl.kt b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/common/CircleControl.kt index 30cdc9980c..8f0dfbcd36 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/common/CircleControl.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/common/CircleControl.kt @@ -56,6 +56,7 @@ internal fun CircleControl( Icon( painterResource(icon), contentDescription = contentDescription, + tint = MaterialTheme.colorScheme.onSurface, modifier = Modifier.size(width = glyphWidth.dp, height = glyphHeight.dp), ) } diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/common/SignedOutConnectionActions.kt b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/common/SignedOutConnectionActions.kt index 0acfa050b6..eb1879ec7e 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/common/SignedOutConnectionActions.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/common/SignedOutConnectionActions.kt @@ -14,6 +14,9 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.draw.clip import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.Dp @@ -46,7 +49,10 @@ internal fun SignedOutConnectionActions( .height(buttonHeight) .clip(shape) .border(1.dp, MaterialTheme.colorScheme.outlineVariant, shape) - .clickable(enabled = enabled, onClick = onScan), + .clickable(enabled = enabled, role = Role.Button, onClick = onScan) + .semantics(mergeDescendants = true) { + contentDescription = scanLabel + }, contentAlignment = Alignment.Center, ) { Text( @@ -63,7 +69,10 @@ internal fun SignedOutConnectionActions( .height(buttonHeight) .clip(shape) .background(MaterialTheme.colorScheme.primary) - .clickable(enabled = enabled, onClick = onOpenAccount), + .clickable(enabled = enabled, role = Role.Button, onClick = onOpenAccount) + .semantics(mergeDescendants = true) { + contentDescription = accountLabel + }, contentAlignment = Alignment.Center, ) { Text( diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/preview/MobileDesignGallery.kt b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/preview/MobileDesignGallery.kt index 30bfc8bb47..63c6f02bc9 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/preview/MobileDesignGallery.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/preview/MobileDesignGallery.kt @@ -18,6 +18,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -32,6 +33,14 @@ import com.bitfun.mobile.core.feature.connection.ConnectionPhase import com.bitfun.mobile.core.feature.session.ChatComposerCapabilities import com.bitfun.mobile.core.feature.session.ModelOption +/** + * Stable preview semantics: the gallery root, platform label, and message timeline + * are tagged so visual-parity instrumentation can locate each region. + */ +internal const val MOBILE_DESIGN_GALLERY_TEST_TAG: String = "mobile-design-gallery" +internal const val MOBILE_DESIGN_GALLERY_PLATFORM_TEST_TAG: String = "mobile-design-gallery-platform" +internal const val MOBILE_DESIGN_GALLERY_TIMELINE_TEST_TAG: String = "mobile-design-gallery-timeline" + @Composable internal fun MobileDesignGallery(scenario: MobilePreviewScenario, dark: Boolean) { BitFunTheme(dark = dark) { @@ -39,7 +48,8 @@ internal fun MobileDesignGallery(scenario: MobilePreviewScenario, dark: Boolean) modifier = Modifier .fillMaxSize() .statusBarsPadding() - .background(MaterialTheme.colorScheme.background), + .background(MaterialTheme.colorScheme.background) + .testTag(MOBILE_DESIGN_GALLERY_TEST_TAG), ) { PlatformLabel(scenario) ConversationHeader( @@ -58,6 +68,7 @@ internal fun MobileDesignGallery(scenario: MobilePreviewScenario, dark: Boolean) modifier = Modifier .weight(1f) .fillMaxWidth() + .testTag(MOBILE_DESIGN_GALLERY_TIMELINE_TEST_TAG) .padding( horizontal = MobileDesignGeometry.ContentGutter, vertical = MobileDesignGeometry.TimelineTopPadding, @@ -107,9 +118,10 @@ private fun PlatformLabel(scenario: MobilePreviewScenario) { .fillMaxWidth() .height(MobileDesignGeometry.ConnectionStripHeight) .border(1.dp, MaterialTheme.colorScheme.outlineVariant) - .padding(horizontal = MobileDesignGeometry.ContentGutter), + .padding(horizontal = MobileDesignGeometry.ContentGutter) + .testTag(MOBILE_DESIGN_GALLERY_PLATFORM_TEST_TAG), ) { - Text("Android", style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.Medium) + Text("Android", style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.Medium, color = MaterialTheme.colorScheme.onSurface) Text( "NATIVE", style = MaterialTheme.typography.labelSmall, @@ -134,6 +146,7 @@ private fun PreviewMessageBubble(message: MobilePreviewMessage) { Text( text = message.text, style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, modifier = Modifier .widthIn(max = MobileDesignGeometry.MessageBubbleMaxWidth) .background( @@ -166,3 +179,9 @@ private fun MobileDesignCompactPreview() { private fun MobileDesignDarkPreview() { MobileDesignGallery(MobilePreviewScenarios.StreamingDark, dark = true) } + +@Preview(name = "BitFun Mobile · Reconnecting Wide", widthDp = 1024, heightDp = 768, showBackground = true) +@Composable +private fun MobileDesignReconnectingWidePreview() { + MobileDesignGallery(MobilePreviewScenarios.ReconnectingWide, dark = false) +} diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/preview/MobilePreviewStates.kt b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/preview/MobilePreviewStates.kt new file mode 100644 index 0000000000..4e9e895324 --- /dev/null +++ b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/preview/MobilePreviewStates.kt @@ -0,0 +1,189 @@ +package com.bitfun.mobile.app.ui.preview + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.dp +import androidx.compose.ui.tooling.preview.Preview +import com.bitfun.mobile.app.R +import com.bitfun.mobile.app.ui.chat.ComposerBar +import com.bitfun.mobile.app.ui.common.AdaptiveModalSurface +import com.bitfun.mobile.app.ui.common.CircleControl +import com.bitfun.mobile.app.ui.theme.BitFunTheme +import com.bitfun.mobile.core.feature.connection.ConnectionPhase +import com.bitfun.mobile.core.feature.layout.SettingsPlacementPolicy +import com.bitfun.mobile.core.feature.layout.SettingsSheetKind +import com.bitfun.mobile.core.feature.session.ChatComposerCapabilities +import com.bitfun.mobile.core.feature.session.ComposerImage +import com.bitfun.mobile.core.feature.session.ModelOption + +internal const val MOBILE_PREVIEW_COMPOSER_FOCUSED_TEST_TAG: String = "mobile-preview-composer-focused" +internal const val MOBILE_PREVIEW_COMPOSER_ATTACHMENTS_TEST_TAG: String = "mobile-preview-composer-attachments" +internal const val MOBILE_PREVIEW_MODAL_BUSY_TEST_TAG: String = "mobile-preview-modal-busy" +internal const val MOBILE_PREVIEW_MODAL_ERROR_TEST_TAG: String = "mobile-preview-modal-error" +internal const val MOBILE_PREVIEW_MODAL_ERROR_TEXT_TEST_TAG: String = "mobile-preview-modal-error-text" +internal const val MOBILE_PREVIEW_MODAL_ERROR_ACTION_TEST_TAG: String = "mobile-preview-modal-error-action" +internal const val MOBILE_PREVIEW_CIRCLE_STATES_TEST_TAG: String = "mobile-preview-circle-states" +internal const val MOBILE_PREVIEW_CIRCLE_SIDEBAR_TEST_TAG: String = "mobile-preview-circle-sidebar" +internal const val MOBILE_PREVIEW_CIRCLE_MORE_TEST_TAG: String = "mobile-preview-circle-more" +internal const val MOBILE_PREVIEW_CIRCLE_BACK_TEST_TAG: String = "mobile-preview-circle-back" + +private val PreviewModel = ModelOption("preview-model", "Preview model", "Native model", true) + +@Composable +private fun previewComposer( + draft: String, + images: List, + modifier: Modifier, +) { + ComposerBar( + draft = draft, + images = images, + busy = false, + streaming = false, + phase = ConnectionPhase.CONNECTED, + model = PreviewModel, + capabilities = ChatComposerCapabilities.GeneralChat, + placeholder = "Type a message", + onDraftChange = {}, + onRemoveImage = {}, + onAttach = {}, + onVoice = {}, + onSend = {}, + onStop = {}, + onOpenModels = {}, + modifier = modifier, + ) +} + +@Preview(name = "BitFun Mobile · Composer Focused", showBackground = true) +@Composable +internal fun MobilePreviewComposerFocused(dark: Boolean = false) { + BitFunTheme(dark = dark) { + Column( + modifier = Modifier.fillMaxSize().testTag(MOBILE_PREVIEW_COMPOSER_FOCUSED_TEST_TAG), + ) { + previewComposer( + draft = "Review the current message", + images = emptyList(), + modifier = Modifier.fillMaxWidth(), + ) + } + } +} + +@Preview(name = "BitFun Mobile · Composer Attachments", showBackground = true) +@Composable +internal fun MobilePreviewComposerAttachments(dark: Boolean = false) { + BitFunTheme(dark = dark) { + Column( + modifier = Modifier.fillMaxSize().testTag(MOBILE_PREVIEW_COMPOSER_ATTACHMENTS_TEST_TAG), + ) { + previewComposer( + draft = "", + images = listOf(ComposerImage(id = "preview-1", dataUrl = "", mimeType = "")), + modifier = Modifier.fillMaxWidth(), + ) + } + } +} + +@Preview(name = "BitFun Mobile · Modal Busy", showBackground = true) +@Composable +internal fun MobilePreviewModalBusy(dark: Boolean = false) { + BitFunTheme(dark = dark) { + AdaptiveModalSurface( + visible = true, + placement = SettingsPlacementPolicy.compactBottom(SettingsSheetKind.SETTINGS), + onDismissRequest = {}, + ) { surfaceModifier -> + Column( + modifier = surfaceModifier + .padding(24.dp) + .testTag(MOBILE_PREVIEW_MODAL_BUSY_TEST_TAG), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text("Working…", style = MaterialTheme.typography.titleMedium) + Text("Please wait while the request finishes.") + } + } + } +} + +@Preview(name = "BitFun Mobile · Modal Error", showBackground = true) +@Composable +internal fun MobilePreviewModalError(dark: Boolean = false) { + BitFunTheme(dark = dark) { + AdaptiveModalSurface( + visible = true, + placement = SettingsPlacementPolicy.compactBottom(SettingsSheetKind.SETTINGS), + onDismissRequest = {}, + ) { surfaceModifier -> + Column( + modifier = surfaceModifier + .padding(24.dp) + .testTag(MOBILE_PREVIEW_MODAL_ERROR_TEST_TAG), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + "Connection failed", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.testTag(MOBILE_PREVIEW_MODAL_ERROR_TEXT_TEST_TAG), + ) + Text("Try again when the connection is available.") + TextButton( + onClick = {}, + modifier = Modifier.testTag(MOBILE_PREVIEW_MODAL_ERROR_ACTION_TEST_TAG), + ) { + Text("Try again") + } + } + } + } +} + +@Preview(name = "BitFun Mobile · Circle States", showBackground = true) +@Composable +internal fun MobilePreviewCircleStates(dark: Boolean = false) { + BitFunTheme(dark = dark) { + Row( + modifier = Modifier + .fillMaxSize() + .padding(24.dp) + .testTag(MOBILE_PREVIEW_CIRCLE_STATES_TEST_TAG), + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + CircleControl( + icon = R.drawable.ic_symbol_menu_lines, + glyphSize = 22, + contentDescription = "Open sidebar", + onClick = {}, + modifier = Modifier.testTag(MOBILE_PREVIEW_CIRCLE_SIDEBAR_TEST_TAG), + ) + CircleControl( + icon = R.drawable.ic_symbol_ellipsis, + glyphSize = 22, + contentDescription = "More actions", + onClick = {}, + modifier = Modifier.testTag(MOBILE_PREVIEW_CIRCLE_MORE_TEST_TAG), + ) + CircleControl( + icon = R.drawable.ic_symbol_chevron_left, + glyphSize = 22, + contentDescription = "Back", + onClick = {}, + modifier = Modifier.testTag(MOBILE_PREVIEW_CIRCLE_BACK_TEST_TAG), + ) + } + } +} diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/remote/ConnectAccountDeviceScreen.kt b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/remote/ConnectAccountDeviceScreen.kt index bec77241f5..b1793135a0 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/remote/ConnectAccountDeviceScreen.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/remote/ConnectAccountDeviceScreen.kt @@ -225,12 +225,13 @@ private fun DeviceListCard( } else { state.devices.forEach { device -> val selected = device.id == state.selectedDeviceId + val reconnectable = selected && !device.online Row( modifier = Modifier .fillMaxWidth() .height(60.dp) - .clickable(enabled = device.online, onClick = { onSelect(device.id) }) - .alpha(if (device.online) 1f else 0.64f) + .clickable(enabled = device.online || reconnectable, onClick = { onSelect(device.id) }) + .alpha(if (device.online || selected) 1f else 0.64f) .testTag(CONNECT_ACCOUNT_DEVICE_ROW_TEST_TAG_PREFIX + device.id), horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically, @@ -239,7 +240,7 @@ private fun DeviceListCard( painterResource(R.drawable.ic_symbol_desktop), contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(26.dp).alpha(if (device.online) 0.68f else 0.38f), + modifier = Modifier.size(26.dp).alpha(if (device.online || selected) 0.68f else 0.38f), ) Column(verticalArrangement = Arrangement.spacedBy(3.dp), modifier = Modifier.weight(1f)) { Text( @@ -250,7 +251,13 @@ private fun DeviceListCard( overflow = TextOverflow.Ellipsis, ) Text( - stringResource(if (device.online) R.string.account_online else R.string.account_offline), + stringResource( + when { + selected && device.online -> R.string.account_device_current_control + device.online -> R.string.account_online + else -> R.string.account_offline + }, + ), fontSize = 13.sp, color = if (device.online) { bitFunColors.success @@ -259,7 +266,15 @@ private fun DeviceListCard( }, ) } - if (device.online && !selected) { + if (reconnectable) { + Surface(color = MaterialTheme.colorScheme.surfaceVariant, shape = RoundedCornerShape(14.dp)) { + Text( + stringResource(R.string.remote_settings_reconnect), + fontSize = 14.sp, + modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), + ) + } + } else if (device.online && !selected) { Surface(color = MaterialTheme.colorScheme.surfaceVariant, shape = RoundedCornerShape(14.dp)) { Text( stringResource(R.string.account_connect), @@ -267,12 +282,6 @@ private fun DeviceListCard( modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), ) } - } else if (selected) { - Text( - stringResource(R.string.account_device_current_control), - fontSize = 13.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) } } } diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/remote/FilePreviewSurface.kt b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/remote/FilePreviewSurface.kt index d3ce9733a3..26eed3a28a 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/remote/FilePreviewSurface.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/remote/FilePreviewSurface.kt @@ -19,6 +19,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CircularProgressIndicator @@ -207,20 +208,23 @@ internal fun FilePreviewSurface( // The pane sits on the soft surface the whole way down, as // HarmonyOS' TextPreview does — source is read against a // tint, rendered Markdown against the page. - Text( - source, - fontSize = 12.sp, - fontFamily = FontFamily.Monospace, - color = MaterialTheme.colorScheme.onSurface, - softWrap = false, - modifier = Modifier - .weight(1f) - .fillMaxWidth() - .background(MaterialTheme.colorScheme.surfaceVariant) - .verticalScroll(rememberScrollState()) - .horizontalScroll(rememberScrollState()) - .padding(start = 14.dp, end = 20.dp, top = 14.dp, bottom = 24.dp), - ) + SelectionContainer { + Text( + source, + fontSize = 12.sp, + lineHeight = 19.sp, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurface, + softWrap = false, + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surfaceVariant) + .verticalScroll(rememberScrollState()) + .horizontalScroll(rememberScrollState()) + .padding(start = 14.dp, end = 20.dp, top = 14.dp, bottom = 24.dp), + ) + } } } diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/remote/RemoteSessionListView.kt b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/remote/RemoteSessionListView.kt index 676209a134..a6c5e5cea2 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/remote/RemoteSessionListView.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/remote/RemoteSessionListView.kt @@ -383,11 +383,6 @@ internal fun RemoteSessionListContent( status = session.status, capabilities = capabilities, onViewDetails = openDetails, - // Archive and export are local-storage operations, so - // the policy never offers them for a REMOTE scope and - // these cannot be reached from this list. - onArchive = {}, - onExport = {}, onDelete = delete, onDismiss = dismissActions, ) else SessionActionPopup( @@ -396,8 +391,6 @@ internal fun RemoteSessionListContent( status = session.status, capabilities = capabilities, onViewDetails = openDetails, - onArchive = {}, - onExport = {}, onDelete = delete, onDismiss = dismissActions, ) diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/remote/SessionActionSheet.kt b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/remote/SessionActionSheet.kt index 5abc5b4a96..b32a94efbb 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/remote/SessionActionSheet.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/remote/SessionActionSheet.kt @@ -90,8 +90,8 @@ internal fun SessionActionSheet( status: String, capabilities: SessionActionCapabilities, onViewDetails: () -> Unit, - onArchive: () -> Unit, - onExport: () -> Unit, + onArchive: (() -> Unit)? = null, + onExport: (() -> Unit)? = null, onDelete: () -> Unit, onDismiss: () -> Unit, ) { @@ -99,6 +99,11 @@ internal fun SessionActionSheet( // sheet that is itself gone after a process death, and restoring "about to // delete" without the tap that got there is not a state worth rebuilding. var confirmingDelete by remember { mutableStateOf(false) } + // Archive and export are local-storage operations a remote session can never + // offer, so those callbacks are optional: a surface without them renders no + // row instead of rendering one that does nothing. + val showArchive = capabilities.canArchive && onArchive != null + val showExport = capabilities.canExport && onExport != null ModalBottomSheet( onDismissRequest = onDismiss, @@ -213,7 +218,7 @@ internal fun SessionActionSheet( onDismiss() } } - if (capabilities.canArchive) { + if (showArchive) { val archived = status.equals("archived", ignoreCase = true) val label = stringResource( if (archived) R.string.session_unarchive else R.string.session_archive, @@ -223,16 +228,20 @@ internal fun SessionActionSheet( onDismiss() } } - if (capabilities.canExport) { - // The source draws a cloud here; the label says copy, and on - // Android the action puts Markdown on the clipboard. - ActionRow(R.drawable.ic_symbol_cloud, stringResource(R.string.general_chat_export)) { + if (showExport) { + // The label says "Copy Markdown" for Harmony parity, but Android + // opens an Intent.ACTION_SEND text/plain share sheet; this icon + // must not promise a file or cloud export. + ActionRow( + R.drawable.ic_symbol_doc_text_badge_arrow_up, + stringResource(R.string.general_chat_export), + ) { onExport() onDismiss() } } if (capabilities.canDelete) { - if (capabilities.canArchive || capabilities.canExport) { + if (showArchive || showExport) { HorizontalDivider(modifier = Modifier.padding(vertical = 6.dp)) } ActionRow( @@ -254,12 +263,14 @@ internal fun SessionActionPopup( status: String, capabilities: SessionActionCapabilities, onViewDetails: () -> Unit, - onArchive: () -> Unit, - onExport: () -> Unit, + onArchive: (() -> Unit)? = null, + onExport: (() -> Unit)? = null, onDelete: () -> Unit, onDismiss: () -> Unit, ) { var confirmingDelete by remember { mutableStateOf(false) } + val showArchive = capabilities.canArchive && onArchive != null + val showExport = capabilities.canExport && onExport != null val targetBounds = anchorBounds val positionProvider = remember(targetBounds) { object : PopupPositionProvider { @@ -371,7 +382,7 @@ internal fun SessionActionPopup( onViewDetails(); onDismiss() } } - if (capabilities.canArchive) { + if (showArchive) { ActionRow( R.drawable.ic_symbol_archivebox, stringResource( @@ -380,13 +391,16 @@ internal fun SessionActionPopup( ), ) { onArchive(); onDismiss() } } - if (capabilities.canExport) { - ActionRow(R.drawable.ic_symbol_cloud, stringResource(R.string.general_chat_export)) { + if (showExport) { + ActionRow( + R.drawable.ic_symbol_doc_text_badge_arrow_up, + stringResource(R.string.general_chat_export), + ) { onExport(); onDismiss() } } if (capabilities.canDelete) { - if (capabilities.canArchive || capabilities.canExport) { + if (showArchive || showExport) { HorizontalDivider(modifier = Modifier.padding(vertical = 6.dp)) } ActionRow( diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/settings/ModelServiceScreen.kt b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/settings/ModelServiceScreen.kt index 36b2c43e34..09d6034782 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/settings/ModelServiceScreen.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/settings/ModelServiceScreen.kt @@ -296,6 +296,11 @@ private fun ModelOverview( ) { ModelSourceRow( icon = R.drawable.ic_symbol_cloud, + iconTint = if (accountModels.isEmpty()) { + MaterialTheme.colorScheme.outline + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, title = stringResource(R.string.model_service_account_summary), subtitle = if (accountModels.isEmpty()) { stringResource(R.string.model_service_account_empty) @@ -309,6 +314,7 @@ private fun ModelOverview( HorizontalDivider(modifier = Modifier.padding(start = 56.dp)) ModelSourceRow( icon = R.drawable.ic_symbol_wrench_and_screwdriver, + iconTint = MaterialTheme.colorScheme.onSurfaceVariant, title = if (complete) config.model else notConfigured, subtitle = if (complete) localSource else "", onBodyClick = if (complete) onSelectLocal else onEditLocal, @@ -323,6 +329,7 @@ private fun ModelOverview( @Composable private fun ModelSourceRow( @DrawableRes icon: Int, + iconTint: Color, title: String, subtitle: String, onBodyClick: () -> Unit, @@ -342,7 +349,7 @@ private fun ModelSourceRow( Icon( painterResource(icon), contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, + tint = iconTint, modifier = Modifier.size(22.dp), ) Column(verticalArrangement = Arrangement.spacedBy(3.dp)) { diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/settings/PermissionModeCard.kt b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/settings/PermissionModeCard.kt index 31aaa5cd33..5182516ff9 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/settings/PermissionModeCard.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/settings/PermissionModeCard.kt @@ -66,7 +66,8 @@ internal fun PermissionSection( // Cleared whenever the mode changes underneath it: a confirmation left // standing after a successful change would confirm the wrong thing. var confirmFullAccess by rememberSaveable(state.permissionMode) { mutableStateOf(false) } - val enabled = !state.busy && connected + val refreshEnabled = !state.busy && connected + val selectEnabled = refreshEnabled && state.permissionMode != SessionPermissionMode.UNKNOWN Column( modifier = modifier.fillMaxWidth().testTag(PERMISSION_SECTION_TEST_TAG), @@ -87,7 +88,7 @@ internal fun PermissionSection( if (connected) { TextButton( onClick = { onIntent(RemoteSessionIntent.RefreshPermissionMode) }, - enabled = enabled, + enabled = refreshEnabled, ) { Text(stringResource(R.string.sessions_refresh)) } } } @@ -108,7 +109,7 @@ internal fun PermissionSection( label = stringResource(entry.label), description = stringResource(entry.description), selected = state.permissionMode == entry.mode, - enabled = enabled, + enabled = selectEnabled, onSelect = { // Full access removes every confirmation the desktop // would otherwise ask for, so this one is confirmed. @@ -128,7 +129,7 @@ internal fun PermissionSection( // rather than as the second half of choosing Full access. if (confirmFullAccess) { FullAccessConfirmation( - enabled = enabled, + enabled = selectEnabled, onCancel = { confirmFullAccess = false }, onConfirm = { onIntent( @@ -211,13 +212,14 @@ private fun ColumnScope.PermissionStatus(state: RemoteSessionUiState.Ready, conn !connected -> stringResource(R.string.permission_needs_connection) failure == PermissionModeFailure.LOAD -> stringResource(R.string.permission_load_failed) failure == PermissionModeFailure.SAVE -> stringResource(R.string.permission_save_failed) + state.permissionMode == SessionPermissionMode.UNKNOWN -> stringResource(R.string.permission_unknown) state.permissionMode == null -> stringResource(R.string.permission_loading) else -> return } Text( text, style = MaterialTheme.typography.bodySmall, - color = if (failure == null) { + color = if (failure == null && state.permissionMode != SessionPermissionMode.UNKNOWN) { MaterialTheme.colorScheme.onSurfaceVariant } else { MaterialTheme.colorScheme.error diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/settings/RemoteSettingsSheet.kt b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/settings/RemoteSettingsSheet.kt index 5f7cb5d80f..ceba325771 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/settings/RemoteSettingsSheet.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/settings/RemoteSettingsSheet.kt @@ -13,6 +13,7 @@ import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.RadioButton import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -20,12 +21,15 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.bitfun.mobile.app.R +import com.bitfun.mobile.core.feature.session.ModelCatalogFailure import com.bitfun.mobile.core.feature.session.ModelOption import com.bitfun.mobile.core.feature.session.RemoteSessionIntent import com.bitfun.mobile.core.feature.session.RemoteSessionUiState import com.bitfun.mobile.core.feature.session.modelOptions internal const val REMOTE_SETTINGS_TEST_TAG: String = "remote-settings" +internal const val MODEL_CATALOG_FAILURE_TEST_TAG: String = "model-catalog-failure" +internal const val MODEL_CATALOG_RETRY_TEST_TAG: String = "model-catalog-retry" /** * The settings that belong to one open session. @@ -61,23 +65,112 @@ private fun ModelSection( ) { val fallback = stringResource(R.string.models_unnamed) val options: List = state.timeline?.modelOptions(fallback).orEmpty() - if (options.isEmpty()) return + val failure = state.modelCatalogFailure Text(stringResource(R.string.models_title), style = MaterialTheme.typography.titleMedium) - Card(modifier = Modifier.fillMaxWidth()) { - Column(modifier = Modifier.padding(vertical = 4.dp)) { - options.forEachIndexed { index, option -> - if (index > 0) HorizontalDivider() - SelectableRow( - label = option.primaryLabel, - description = option.secondaryLabel, - selected = option.selected, - enabled = !state.busy && sessionId.isNotEmpty(), - onSelect = { - onIntent(RemoteSessionIntent.SelectModel(sessionId, option.id)) + + when { + failure == ModelCatalogFailure.LOAD_FAILED && options.isEmpty() -> { + ModelCatalogNotice( + text = stringResource(R.string.model_catalog_load_failed), + isError = true, + showRetry = true, + retryEnabled = !state.busy, + onRetry = { onIntent(RemoteSessionIntent.RefreshModelCatalog) }, + ) + } + + failure == ModelCatalogFailure.UNSUPPORTED_BY_PEER && options.isEmpty() -> { + ModelCatalogNotice( + text = stringResource(R.string.model_catalog_unsupported), + isError = true, + showRetry = false, + retryEnabled = false, + onRetry = {}, + ) + } + + options.isEmpty() -> { + Text( + stringResource(R.string.model_selector_empty), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + else -> { + if (failure != null) { + ModelCatalogNotice( + text = when (failure) { + ModelCatalogFailure.LOAD_FAILED -> + stringResource(R.string.model_catalog_stale_warning) + + ModelCatalogFailure.UNSUPPORTED_BY_PEER -> + stringResource(R.string.model_catalog_unsupported) }, + isError = false, + showRetry = failure == ModelCatalogFailure.LOAD_FAILED, + retryEnabled = !state.busy, + onRetry = { onIntent(RemoteSessionIntent.RefreshModelCatalog) }, ) } + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(vertical = 4.dp)) { + options.forEachIndexed { index, option -> + if (index > 0) HorizontalDivider() + SelectableRow( + label = option.primaryLabel, + description = option.secondaryLabel, + selected = option.selected, + enabled = !state.busy && sessionId.isNotEmpty(), + onSelect = { + onIntent(RemoteSessionIntent.SelectModel(sessionId, option.id)) + }, + ) + } + } + } + } + } +} + +/** + * The model catalog section's non-happy-path copy: a blocking error when there + * are no options to show, or a non-fatal warning when a previously loaded list + * is retained. [isError] keeps the blocking case red while the warning stays a + * muted note above the list it qualifies. + */ +@Composable +private fun ModelCatalogNotice( + text: String, + isError: Boolean, + showRetry: Boolean, + retryEnabled: Boolean, + onRetry: () -> Unit, +) { + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier + .fillMaxWidth() + .testTag(MODEL_CATALOG_FAILURE_TEST_TAG), + ) { + Text( + text, + style = MaterialTheme.typography.bodySmall, + color = if (isError) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + if (showRetry) { + TextButton( + onClick = onRetry, + enabled = retryEnabled, + modifier = Modifier.testTag(MODEL_CATALOG_RETRY_TEST_TAG), + ) { + Text(stringResource(R.string.model_catalog_retry)) + } } } } diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/shell/BitFunCompactDrawer.kt b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/shell/BitFunCompactDrawer.kt index 83bbe020c7..3392e9781a 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/shell/BitFunCompactDrawer.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/shell/BitFunCompactDrawer.kt @@ -2,7 +2,6 @@ package com.bitfun.mobile.app.ui.shell import androidx.activity.compose.BackHandler import androidx.compose.animation.core.Animatable -import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -19,13 +18,17 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.TransformOrigin import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import com.bitfun.mobile.app.R import com.bitfun.mobile.app.ui.theme.BitFunEaseOut import com.bitfun.mobile.app.ui.theme.MotionDrawerCloseMillis import com.bitfun.mobile.app.ui.theme.MotionDrawerHideMillis @@ -33,6 +36,7 @@ import com.bitfun.mobile.app.ui.theme.MotionDrawerOpenMillis import com.bitfun.mobile.app.ui.theme.MotionDrawerRevealMillis import com.bitfun.mobile.app.ui.theme.MotionDrawerScrimMillis import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch private const val CONTENT_SCALE_X = 0.985f @@ -40,6 +44,53 @@ private const val CONTENT_SCALE_Y = 0.992f private const val CONTENT_RADIUS_DP = 28 private const val CONTENT_ELEVATION_DP = 18 +/** + * Whether the full-screen content is drawn as a flat card or as the receded + * drawer companion (rounded corner + shadow). This is a boundary switch, not a + * per-frame property: the rounded clip and shadow are static modifiers toggled + * only when the content leaves or returns to its resting position, so per-frame + * work stays limited to translation/scale/alpha. + */ +internal enum class ContentCardPhase { Flat, Receded } + +/** + * Pure, Compose-observable coordinator for [ContentCardPhase]. + * + * The timing rules live here, separate from the animation loop, so they can be + * tested without standing up a Compose tree: + * - Opening starts flat and only recedes after the content has left its resting + * position, which [onContentProgress] enforces by guarding on progress > 0; + * [onRevealStarted] records the intent but must not recede the card on the + * first, still-full-screen frame. + * - Closing settles back to flat only after the content animation has finished + * ([onContentSettled]); there is no earlier deadline that pops the shadow and + * clip while the content is still moving. + */ +internal class ContentCardCoordinator(initialOpen: Boolean) { + var phase by mutableStateOf(if (initialOpen) ContentCardPhase.Receded else ContentCardPhase.Flat) + private set + + fun onRevealStarted() { + // No phase change: the first open frame must stay a full-screen flat + // card. onContentProgress is what recedes it, once the content has left + // its resting position. + } + + fun onContentProgress(progress: Float) { + // Recede only once the content has actually left its resting position. + // progress must be strictly positive: zero still means the full-screen + // card, and attaching the corner/shadow there is the snap this + // coordinator exists to prevent. + if (progress > 0f) { + phase = ContentCardPhase.Receded + } + } + + fun onContentSettled() { + phase = ContentCardPhase.Flat + } +} + /** Compact app shell motion shared with `AppShell.ets`. */ @Composable internal fun BitFunCompactDrawer( @@ -52,20 +103,19 @@ internal fun BitFunCompactDrawer( ) { BackHandler(enabled = open, onBack = onDismiss) - val contentDuration = if (open) MotionDrawerOpenMillis else MotionDrawerCloseMillis - // Translation, scale and corner radius share one progress value so they stay - // in lockstep and the whole motion runs as layer-property updates instead of - // recomposing the content once per frame. - val contentProgress by animateFloatAsState( - targetValue = if (open) 1f else 0f, - animationSpec = tween(contentDuration, easing = BitFunEaseOut), - label = "drawer-content-progress", - ) + // Translation and scale share one progress value so they stay in lockstep. + // Only these two properties are driven per frame: they are cheap render-thread + // layer properties, so the content never recomposes or re-lays-out per frame. + // The rounded-corner clip and the shadow are deliberately *not* derived from + // contentProgress — changing shape/clip/shadowElevation every frame forces the + // full-screen content to re-rasterize its clip and shadow outline each frame + // (and allocates a new RoundedCornerShape), which is the open-drawer jank. + val contentProgress = remember { Animatable(if (open) 1f else 0f) } val density = LocalDensity.current val drawerWidthPx = with(density) { drawerWidth.toPx() } - val contentRadiusPx = with(density) { CONTENT_RADIUS_DP.dp.toPx() } - val contentElevationPx = with(density) { CONTENT_ELEVATION_DP.dp.toPx() } + val contentShape = remember { RoundedCornerShape(CONTENT_RADIUS_DP.dp) } val interactionSource = remember { MutableInteractionSource() } + val dismissSidebarLabel = stringResource(R.string.common_close) // The drawer and scrim run on `Animatable` so their reveal/hide values are // read only inside `graphicsLayer` blocks (layer-only updates, no relayout or @@ -83,20 +133,32 @@ internal fun BitFunCompactDrawer( var drawerComposed by remember { mutableStateOf(open) } var drawerRevealed by remember { mutableStateOf(open) } var scrimComposed by remember { mutableStateOf(open) } + // The content's card treatment (rounded clip + shadow) is a static modifier + // toggled at open/close boundaries, not a per-frame graphicsLayer property. + // The coordinator encodes exactly when those boundaries fire: not on the + // first full-screen frame when opening, and not before the content has + // settled when closing. + val contentCard = remember { ContentCardCoordinator(open) } LaunchedEffect(open, compact) { if (!compact) { // Wide layout: the permanent sidebar owns this role. Drop any resident // copy so the window does not end up with a second, hidden Sidebar. + contentProgress.snapTo(0f) drawerProgress.snapTo(0f) scrimProgress.snapTo(0f) drawerComposed = false drawerRevealed = false scrimComposed = false + contentCard.onContentSettled() } else if (open) { drawerComposed = true drawerRevealed = true scrimComposed = true + // Record the reveal without receding the card yet: the first frame + // after opening is still the full-screen content at rest, and a 28dp + // corner must not snap onto it before the content has moved. + contentCard.onRevealStarted() coroutineScope { launch { drawerProgress.animateTo( @@ -110,6 +172,20 @@ internal fun BitFunCompactDrawer( animationSpec = tween(MotionDrawerScrimMillis, easing = BitFunEaseOut), ) } + launch { + contentProgress.animateTo( + targetValue = 1f, + animationSpec = tween(MotionDrawerOpenMillis, easing = BitFunEaseOut), + ) + } + // Value guard, not a frame count: recede only once the content + // animation has actually advanced past rest (progress > 0). + // snapshotFlow observes the Animatable value and first{} yields + // the first positive frame, so the corner never snaps onto a + // still-full-screen frame. No fixed threshold or delay. + contentCard.onContentProgress( + snapshotFlow { contentProgress.value }.first { it > 0f }, + ) } } else if (drawerComposed) { coroutineScope { @@ -118,16 +194,26 @@ internal fun BitFunCompactDrawer( targetValue = 0f, animationSpec = tween(MotionDrawerHideMillis, easing = BitFunEaseOut), ) + drawerRevealed = false } launch { scrimProgress.animateTo( targetValue = 0f, animationSpec = tween(MotionDrawerScrimMillis, easing = BitFunEaseOut), ) + scrimComposed = false + } + launch { + // The shadow and clip stay until the content itself has + // settled back to full screen — no earlier deadline pops + // them while the content is still moving. + contentProgress.animateTo( + targetValue = 0f, + animationSpec = tween(MotionDrawerCloseMillis, easing = BitFunEaseOut), + ) + contentCard.onContentSettled() } } - drawerRevealed = false - scrimComposed = false } } @@ -160,14 +246,22 @@ internal fun BitFunCompactDrawer( modifier = Modifier .fillMaxSize() .graphicsLayer { - translationX = drawerWidthPx * contentProgress - scaleX = 1f - (1f - CONTENT_SCALE_X) * contentProgress - scaleY = 1f - (1f - CONTENT_SCALE_Y) * contentProgress + translationX = drawerWidthPx * contentProgress.value + scaleX = 1f - (1f - CONTENT_SCALE_X) * contentProgress.value + scaleY = 1f - (1f - CONTENT_SCALE_Y) * contentProgress.value transformOrigin = TransformOrigin(0f, 0.5f) - shape = RoundedCornerShape(contentRadiusPx * contentProgress) - clip = true - shadowElevation = if (open || contentProgress > 0f) contentElevationPx else 0f - }, + } + .then( + if (contentCard.phase == ContentCardPhase.Receded) { + Modifier.shadow( + elevation = CONTENT_ELEVATION_DP.dp, + shape = contentShape, + clip = true, + ) + } else { + Modifier + }, + ), ) { content() if (scrimComposed) { @@ -180,6 +274,7 @@ internal fun BitFunCompactDrawer( enabled = open, interactionSource = interactionSource, indication = null, + onClickLabel = dismissSidebarLabel, onClick = onDismiss, ), ) diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/shell/MobileScreen.kt b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/shell/MobileScreen.kt index 2aaa7bef74..9e007e23a7 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/shell/MobileScreen.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/shell/MobileScreen.kt @@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.ime +import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawingPadding import androidx.compose.foundation.layout.union @@ -358,7 +359,11 @@ internal fun MobileScreen() { onToggleSearch = shell::toggleSearch, onScanDesktop = { pairingViewModel.dispatch(PairingIntent.Disconnect) - shell.openRemoteScanner() + // The sidebar row opens the choose-connection page, not the + // camera: ML Kit's scanner is a full-screen system activity, so + // launching it from the drawer would leave the user no way to + // pick "scan" vs "sign in" and would cover the app on every tap. + shell.openRemoteConnect() closeDrawer() }, onRetryRemoteDevice = { @@ -420,6 +425,7 @@ internal fun MobileScreen() { onDeleteSession = { id -> generalChatViewModel.dispatch(GeneralChatIntent.DeleteSession(id)) }, + onDeleteRemoteSession = { id -> dispatchActiveSession(RemoteSessionIntent.DeleteSession(id)) }, onOpenSettings = { // HarmonyOS' `onSidebar.settings` always opens root settings. // Remote-control settings has a separate remote-home action; @@ -609,7 +615,7 @@ internal fun MobileScreen() { drawerContent = { Surface( color = MaterialTheme.colorScheme.background, - modifier = Modifier.fillMaxSize().safeDrawingPadding(), + modifier = Modifier.fillMaxSize().safeDrawingPadding().imePadding(), ) { sidebar() } }, ) { diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/shell/sidebar/AppSidebar.kt b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/shell/sidebar/AppSidebar.kt index 263d3e3a48..5c08b4cb75 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/shell/sidebar/AppSidebar.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/shell/sidebar/AppSidebar.kt @@ -43,6 +43,7 @@ import com.bitfun.mobile.core.feature.session.SessionActionPolicy import com.bitfun.mobile.core.feature.session.SessionActionScope import com.bitfun.mobile.core.feature.session.RemoteSessionUiState import com.bitfun.mobile.core.feature.layout.SettingsPlacement +import com.bitfun.mobile.core.feature.shell.RemoteSidebarSessionRow import com.bitfun.mobile.core.feature.shell.SidebarPresentation import com.bitfun.mobile.core.feature.shell.SidebarSessionRow import com.bitfun.mobile.core.feature.workspace.RemoteWorkspaceUiState @@ -95,6 +96,7 @@ internal fun AppSidebar( onArchiveSession: (String, Boolean) -> Unit, onExportSession: (SidebarSessionRow) -> Unit, onDeleteSession: (String) -> Unit, + onDeleteRemoteSession: (String) -> Unit, onOpenSettings: () -> Unit, onOpenAccount: () -> Unit, modifier: Modifier, @@ -107,6 +109,9 @@ internal fun AppSidebar( var actionSessionId by rememberSaveable { mutableStateOf(null) } var actionAnchor by remember { mutableStateOf(IntRect.Zero) } var detailsSessionId by rememberSaveable { mutableStateOf(null) } + var remoteActionSession by remember { mutableStateOf(null) } + var remoteActionAnchor by remember { mutableStateOf(IntRect.Zero) } + var remoteDetailsSessionId by rememberSaveable { mutableStateOf(null) } var archivedExpanded by rememberSaveable { mutableStateOf(false) } Box(modifier = modifier.fillMaxSize().testTag(SIDEBAR_TEST_TAG)) { @@ -148,6 +153,10 @@ internal fun AppSidebar( onRetryActive = onRetryRemoteDevice, onSelectDevice = onSelectRemoteDevice, onOpenSession = onOpenRemoteSession, + onOpenActions = { row, anchor -> + remoteActionAnchor = anchor + remoteActionSession = row + }, onCreateInWorkspace = onCreateRemoteInWorkspace, onOpenWorkspace = onOpenRemoteWorkspace, ) @@ -228,6 +237,56 @@ internal fun AppSidebar( actionSurface() } + remoteActionSession?.let { session -> + val busy = (remoteState as? RemoteSessionUiState.Ready)?.busy == true + val capabilities = SessionActionPolicy.resolve( + SessionActionScope.REMOTE, + session.agentType, + busy, + ) + if (permanent) { + SessionActionPopup( + anchorBounds = remoteActionAnchor, + title = session.title, + status = "", + capabilities = capabilities, + onViewDetails = { remoteDetailsSessionId = session.id }, + onDelete = { onDeleteRemoteSession(session.id) }, + onDismiss = { remoteActionSession = null }, + ) + } else { + SessionActionSheet( + title = session.title, + status = "", + capabilities = capabilities, + onViewDetails = { remoteDetailsSessionId = session.id }, + onDelete = { onDeleteRemoteSession(session.id) }, + onDismiss = { remoteActionSession = null }, + ) + } + } + + remoteDetailsSessionId?.let { id -> + val session = (remoteState as? RemoteSessionUiState.Ready)?.sessions + ?.firstOrNull { it.id == id } + if (session == null) { + remoteDetailsSessionId = null + return@let + } + SessionDetailsSheet( + title = session.title, + agentType = session.agentType, + status = session.status, + workspaceName = session.workspaceName, + workspacePath = session.workspacePath, + createdAt = session.createdAt, + updatedAt = session.updatedAt, + messageCount = session.messageCount, + placement = sessionDetailsPlacement, + onDismiss = { remoteDetailsSessionId = null }, + ) + } + detailsSessionId?.let { id -> val session = sessions.firstOrNull { it.id == id } if (session == null) { diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/shell/sidebar/RemoteWorkspacePathPolicy.kt b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/shell/sidebar/RemoteWorkspacePathPolicy.kt new file mode 100644 index 0000000000..10648fb47a --- /dev/null +++ b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/shell/sidebar/RemoteWorkspacePathPolicy.kt @@ -0,0 +1,14 @@ +package com.bitfun.mobile.app.ui.shell.sidebar + +/** Path comparisons for remote workspaces, whose wire format is POSIX-like on every client. */ +internal object RemoteWorkspacePathPolicy { + fun equal(left: String, right: String): Boolean = normalize(left) == normalize(right) + + fun normalize(path: String): String { + var value = path.trim() + while (value.length > 1 && (value.endsWith('/') || value.endsWith('\\'))) { + value = value.dropLast(1) + } + return value + } +} diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/shell/sidebar/SidebarChrome.kt b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/shell/sidebar/SidebarChrome.kt index e1781844da..2d3c7899ce 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/shell/sidebar/SidebarChrome.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/shell/sidebar/SidebarChrome.kt @@ -16,6 +16,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource +import androidx.compose.ui.semantics.Role import androidx.compose.ui.unit.dp import com.bitfun.mobile.app.ui.theme.bitFunColors import com.bitfun.mobile.core.feature.connection.ConnectionPhase @@ -45,7 +46,7 @@ internal fun SidebarCircleButton( .clip(CircleShape) .background(MaterialTheme.colorScheme.surface) .border(1.dp, MaterialTheme.colorScheme.outlineVariant, CircleShape) - .clickable(onClick = onClick), + .clickable(role = Role.Button, onClick = onClick), contentAlignment = Alignment.Center, ) { Icon( diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/shell/sidebar/SidebarFooter.kt b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/shell/sidebar/SidebarFooter.kt index 591291777e..d80e3a1a47 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/shell/sidebar/SidebarFooter.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/shell/sidebar/SidebarFooter.kt @@ -22,6 +22,9 @@ import androidx.compose.ui.draw.shadow import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -40,6 +43,7 @@ internal const val SIDEBAR_SETTINGS_TEST_TAG: String = "app-sidebar-settings" */ @Composable internal fun SidebarAuthenticatedFooter(onNewChat: () -> Unit, onOpenSettings: () -> Unit) { + val newChatLabel = stringResource(R.string.sidebar_new_chat) Row( modifier = Modifier.fillMaxWidth().height(56.dp), verticalAlignment = Alignment.CenterVertically, @@ -52,7 +56,10 @@ internal fun SidebarAuthenticatedFooter(onNewChat: () -> Unit, onOpenSettings: ( .clip(RoundedCornerShape(23.dp)) .background(MaterialTheme.colorScheme.surface) .border(1.dp, MaterialTheme.colorScheme.outlineVariant, RoundedCornerShape(23.dp)) - .clickable(onClick = onNewChat) + .clickable(role = Role.Button, onClick = onNewChat) + .semantics(mergeDescendants = true) { + contentDescription = newChatLabel + } .testTag(SIDEBAR_NEW_CHAT_TEST_TAG), horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.CenterHorizontally), verticalAlignment = Alignment.CenterVertically, @@ -64,7 +71,7 @@ internal fun SidebarAuthenticatedFooter(onNewChat: () -> Unit, onOpenSettings: ( modifier = Modifier.size(18.dp), ) Text( - stringResource(R.string.sidebar_new_chat), + newChatLabel, fontSize = 15.sp, fontWeight = FontWeight.Medium, color = MaterialTheme.colorScheme.onSurface, diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/shell/sidebar/SidebarHeader.kt b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/shell/sidebar/SidebarHeader.kt index 0e805b4374..f6667747e5 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/shell/sidebar/SidebarHeader.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/shell/sidebar/SidebarHeader.kt @@ -22,6 +22,9 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -118,12 +121,16 @@ private fun SidebarSearchField(query: String, onQueryChange: (String) -> Unit) { */ @Composable internal fun SidebarSignedOutHeader(onNewChat: () -> Unit) { + val newChatLabel = stringResource(R.string.sidebar_signed_out_new_chat) Row( modifier = Modifier .fillMaxWidth() .height(50.dp) .clip(RoundedCornerShape(12.dp)) - .clickable(onClick = onNewChat) + .clickable(role = Role.Button, onClick = onNewChat) + .semantics(mergeDescendants = true) { + contentDescription = newChatLabel + } .padding(horizontal = 12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically, @@ -135,7 +142,7 @@ internal fun SidebarSignedOutHeader(onNewChat: () -> Unit) { modifier = Modifier.size(22.dp), ) Text( - stringResource(R.string.sidebar_signed_out_new_chat), + newChatLabel, fontSize = 16.sp, fontWeight = FontWeight.Medium, color = MaterialTheme.colorScheme.onSurface, diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/shell/sidebar/SidebarRemoteWorkspaceSection.kt b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/shell/sidebar/SidebarRemoteWorkspaceSection.kt index caa81809de..ea65f2138d 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/shell/sidebar/SidebarRemoteWorkspaceSection.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/shell/sidebar/SidebarRemoteWorkspaceSection.kt @@ -16,6 +16,7 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -28,12 +29,19 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.IntRect import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.bitfun.mobile.app.R @@ -41,8 +49,8 @@ import com.bitfun.mobile.core.feature.account.AccountDeviceUi import com.bitfun.mobile.core.feature.connection.ConnectionPhase import com.bitfun.mobile.core.feature.connection.ConnectionStatusPresenter import com.bitfun.mobile.core.feature.connection.RemoteControlSource -import com.bitfun.mobile.core.feature.shell.RemoteSidebarPresentation import com.bitfun.mobile.core.feature.shell.RemoteSidebarSessionRow +import com.bitfun.mobile.core.feature.shell.RemoteSidebarWorkspaceRow import com.bitfun.mobile.core.feature.session.RemoteSessionUiState import com.bitfun.mobile.core.feature.workspace.RemoteWorkspaceUiState @@ -67,10 +75,12 @@ internal fun SidebarRemoteWorkspaceSection( onRetryActive: () -> Unit, onSelectDevice: (String) -> Unit, onOpenSession: (String) -> Unit, + onOpenActions: (RemoteSidebarSessionRow, IntRect) -> Unit, onCreateInWorkspace: (String) -> Unit, onOpenWorkspace: (String) -> Unit, ) { val connected = ConnectionStatusPresenter.canReachSessions(connectionPhase) + val addConnectionLabel = stringResource(R.string.sidebar_add_connection) val transientDeviceKey = remember(deviceName) { "qr:$deviceName" } val projectedDevices = remember(devices, controlSource, deviceName) { if ( @@ -132,12 +142,18 @@ internal fun SidebarRemoteWorkspaceSection( ) } Box( - modifier = Modifier.size(32.dp).clip(CircleShape).clickable(onClick = onConnect), + modifier = Modifier + .size(32.dp) + .clip(CircleShape) + .clickable(role = Role.Button, onClick = onConnect) + .semantics(mergeDescendants = true) { + contentDescription = addConnectionLabel + }, contentAlignment = Alignment.Center, ) { Icon( painterResource(R.drawable.ic_symbol_plus), - contentDescription = stringResource(R.string.sidebar_add_connection), + contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(17.dp), ) @@ -158,6 +174,8 @@ internal fun SidebarRemoteWorkspaceSection( onConnect = onConnect, onRetry = onRetryActive, onOpenSession = onOpenSession, + onOpenActions = onOpenActions, + canActOnSessions = true, onCreateInWorkspace = onCreateInWorkspace, onOpenWorkspace = onOpenWorkspace, ) @@ -227,6 +245,8 @@ internal fun SidebarRemoteWorkspaceSection( onConnect = onConnect, onRetry = { onSelectDevice(device.id) }, onOpenSession = onOpenSession, + onOpenActions = onOpenActions, + canActOnSessions = active, onCreateInWorkspace = onCreateInWorkspace, onOpenWorkspace = onOpenWorkspace, ) @@ -257,13 +277,51 @@ private fun SidebarActiveDeviceBody( onConnect: () -> Unit, onRetry: () -> Unit, onOpenSession: (String) -> Unit, + onOpenActions: (RemoteSidebarSessionRow, IntRect) -> Unit, + canActOnSessions: Boolean, onCreateInWorkspace: (String) -> Unit, onOpenWorkspace: (String) -> Unit, ) { val readyWorkspace = workspaceState val readySessions = remoteState + val busy = remoteState?.busy == true val entries = remember(readyWorkspace, readySessions) { - RemoteSidebarPresentation.workspaces(readyWorkspace, readySessions) + // Keep this projection local: the shared function is off-limits here, and + // its raw path equality loses sessions when a desktop adds a separator. + if (readyWorkspace == null) { + emptyList() + } else { + val selected = readyWorkspace.selected + val workspaceRows = buildList { + if (selected != null && selected.path.isNotBlank()) { + add(selected.path to selected.name) + } + readyWorkspace.workspaces.forEach { workspace -> + if (workspace.path.isNotBlank() && none { + RemoteWorkspacePathPolicy.equal(it.first, workspace.path) + }) { + add(workspace.path to workspace.name) + } + } + } + workspaceRows.map { (path, name) -> + RemoteSidebarWorkspaceRow( + path = path, + name = name, + selected = RemoteWorkspacePathPolicy.equal(path, selected?.path.orEmpty()), + sessions = readySessions?.sessions.orEmpty() + .filter { session -> + RemoteWorkspacePathPolicy.equal( + session.workspacePath ?: selected?.path.orEmpty(), + path, + ) + } + .map { session -> + RemoteSidebarSessionRow(session.id, session.title, session.agentType) + }, + ) + } + } } var collapsedPaths by rememberSaveable(deviceKey) { mutableStateOf(emptyList()) } var expandedSessionPaths by rememberSaveable(deviceKey) { mutableStateOf(emptyList()) } @@ -296,6 +354,7 @@ private fun SidebarActiveDeviceBody( .height(46.dp) .clip(RoundedCornerShape(10.dp)) .combinedClickable( + role = Role.Button, onClick = { collapsedPaths = if (collapsed) { collapsedPaths - path @@ -305,6 +364,9 @@ private fun SidebarActiveDeviceBody( }, onLongClick = { onOpenWorkspace(path) }, ) + .semantics(mergeDescendants = true) { + contentDescription = entry.name.ifBlank { path.substringAfterLast('/') } + } .padding(start = 10.dp, end = 6.dp), horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically, @@ -336,7 +398,7 @@ private fun SidebarActiveDeviceBody( modifier = Modifier .size(width = 30.dp, height = 40.dp) .clip(RoundedCornerShape(8.dp)) - .clickable { onCreateInWorkspace(path) }, + .clickable(role = Role.Button) { onCreateInWorkspace(path) }, contentAlignment = Alignment.Center, ) { Icon( @@ -364,7 +426,14 @@ private fun SidebarActiveDeviceBody( SESSIONS_PER_WORKSPACE } workspaceSessions.take(limit).forEach { session -> - RemoteSessionRow(session, session.id == selectedSessionId, onOpenSession) + RemoteSessionRow( + session = session, + selected = session.id == selectedSessionId, + busy = busy, + canActOnSessions = canActOnSessions, + onOpenSession = onOpenSession, + onOpenActions = onOpenActions, + ) } if (limit < workspaceSessions.size) { MoreRow( @@ -396,12 +465,16 @@ private fun SidebarDeviceHeader( loading: Boolean, onToggle: () -> Unit, ) { + val deviceLabel = deviceName Row( modifier = Modifier .fillMaxWidth() .height(46.dp) .clip(RoundedCornerShape(10.dp)) - .clickable(onClick = onToggle) + .clickable(role = Role.Button, onClick = onToggle) + .semantics(mergeDescendants = true) { + contentDescription = deviceLabel + } .padding(start = 10.dp, end = 6.dp), horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically, @@ -471,8 +544,12 @@ private fun DeviceLoadingRow() { @Composable private fun DeviceFailedRow(onRetry: () -> Unit) { + val retryLabel = stringResource(R.string.sidebar_device_retry) Row( - modifier = Modifier.fillMaxWidth().height(40.dp).padding(start = 10.dp, end = 10.dp), + modifier = Modifier + .fillMaxWidth() + .height(40.dp) + .padding(start = 10.dp, end = 10.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, ) { @@ -483,22 +560,28 @@ private fun DeviceFailedRow(onRetry: () -> Unit) { modifier = Modifier.weight(1f), ) Text( - stringResource(R.string.sidebar_device_retry), + retryLabel, fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.clickable(onClick = onRetry), + modifier = Modifier + .clickable(role = Role.Button, onClick = onRetry) + .semantics { contentDescription = retryLabel }, ) } } @Composable private fun ConnectDesktopRow(onConnect: () -> Unit) { + val connectLabel = stringResource(R.string.sidebar_connect_desktop) Row( modifier = Modifier .fillMaxWidth() .height(46.dp) .clip(RoundedCornerShape(10.dp)) - .clickable(onClick = onConnect) + .clickable(role = Role.Button, onClick = onConnect) + .semantics(mergeDescendants = true) { + contentDescription = connectLabel + } .padding(start = 10.dp, end = 8.dp) .testTag(SIDEBAR_CODE_TEST_TAG), horizontalArrangement = Arrangement.spacedBy(10.dp), @@ -511,7 +594,7 @@ private fun ConnectDesktopRow(onConnect: () -> Unit) { modifier = Modifier.size(22.dp), ) Text( - stringResource(R.string.sidebar_connect_desktop), + connectLabel, fontSize = 15.sp, color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.weight(1f), @@ -529,8 +612,13 @@ private fun ConnectDesktopRow(onConnect: () -> Unit) { private fun RemoteSessionRow( session: RemoteSidebarSessionRow, selected: Boolean, + busy: Boolean, + canActOnSessions: Boolean, onOpenSession: (String) -> Unit, + onOpenActions: (RemoteSidebarSessionRow, IntRect) -> Unit, ) { + var anchorBounds by remember { mutableStateOf(IntRect.Zero) } + val sessionTitle = session.title.ifBlank { stringResource(R.string.sidebar_untitled) } val icon = when (session.agentType.lowercase()) { "code" -> R.drawable.ic_symbol_code_square "claw", "assistant", "chat" -> R.drawable.ic_symbol_message @@ -542,8 +630,23 @@ private fun RemoteSessionRow( .height(44.dp) .clip(RoundedCornerShape(10.dp)) .background(if (selected) MaterialTheme.colorScheme.surfaceVariant else Color.Transparent) - .clickable { onOpenSession(session.id) } - .padding(start = 26.dp, end = 10.dp) + .onGloballyPositioned { coordinates -> + anchorBounds = coordinates.boundsInWindow().toIntRect() + } + .combinedClickable( + enabled = !busy, + role = Role.Button, + onClick = { onOpenSession(session.id) }, + onLongClick = if (canActOnSessions) { + { onOpenActions(session, anchorBounds) } + } else { + null + }, + ) + .semantics(mergeDescendants = true) { + contentDescription = sessionTitle + } + .padding(start = 26.dp, end = 4.dp) .testTag(SIDEBAR_REMOTE_SESSION_TEST_TAG), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, @@ -555,7 +658,7 @@ private fun RemoteSessionRow( modifier = Modifier.size(19.dp), ) Text( - session.title.ifBlank { stringResource(R.string.sidebar_untitled) }, + sessionTitle, fontSize = 15.sp, fontWeight = if (selected) FontWeight.Medium else FontWeight.Normal, color = MaterialTheme.colorScheme.onSurface, @@ -563,9 +666,29 @@ private fun RemoteSessionRow( overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f), ) + if (canActOnSessions) { + IconButton( + enabled = !busy, + onClick = { onOpenActions(session, anchorBounds) }, + ) { + Icon( + painterResource(R.drawable.ic_symbol_ellipsis), + contentDescription = stringResource(R.string.session_actions), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(18.dp), + ) + } + } } } +private fun Rect.toIntRect(): IntRect = IntRect( + left = left.toInt(), + top = top.toInt(), + right = right.toInt(), + bottom = bottom.toInt(), +) + @Composable private fun MoreRow( hidden: Int, @@ -574,25 +697,28 @@ private fun MoreRow( workspaces: Boolean = false, devices: Boolean = false, ) { + val moreLabel = stringResource( + when { + devices -> R.string.sidebar_more_devices + workspaces -> R.string.sidebar_more_workspaces + else -> R.string.sessions_show_more + }, + hidden, + ) Row( modifier = Modifier .fillMaxWidth() .height(40.dp) - .clickable(onClick = onClick) + .clickable(role = Role.Button, onClick = onClick) + .semantics(mergeDescendants = true) { + contentDescription = moreLabel + } .padding(start = startPadding.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, ) { - Text("...", fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurfaceVariant) Text( - stringResource( - when { - devices -> R.string.sidebar_more_devices - workspaces -> R.string.sidebar_more_workspaces - else -> R.string.sessions_show_more - }, - hidden, - ), + moreLabel, fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, ) diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/shell/sidebar/SidebarSessionList.kt b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/shell/sidebar/SidebarSessionList.kt index 73dc3fa71f..64d4af54e9 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/shell/sidebar/SidebarSessionList.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/shell/sidebar/SidebarSessionList.kt @@ -33,6 +33,9 @@ import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -188,9 +191,11 @@ private fun SessionRow( anchorBounds = coordinates.boundsInWindow().toIntRect() } .combinedClickable( + role = Role.Button, onClick = { onOpen(session) }, onLongClick = { onOpenActions(session, anchorBounds) }, ) + .semantics { contentDescription = title } .padding(start = 12.dp, end = 4.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, @@ -216,7 +221,7 @@ private fun SessionRow( .width(34.dp) .height(40.dp) .clip(RoundedCornerShape(8.dp)) - .clickable { onOpenActions(session, anchorBounds) } + .clickable(role = Role.Button) { onOpenActions(session, anchorBounds) } .testTag(SIDEBAR_MORE_TEST_TAG), contentAlignment = Alignment.Center, ) { @@ -240,6 +245,7 @@ private fun Rect.toIntRect(): IntRect = IntRect( /** The archive, shown as how much is in it rather than as what is in it. */ @Composable private fun ArchivedDisclosureRow(count: Int, expanded: Boolean, onToggle: () -> Unit) { + val archivedLabel = stringResource(R.string.sidebar_archived) Row( modifier = Modifier .fillMaxWidth() @@ -247,7 +253,10 @@ private fun ArchivedDisclosureRow(count: Int, expanded: Boolean, onToggle: () -> .height(46.dp) .clip(RoundedCornerShape(10.dp)) .background(if (expanded) MaterialTheme.colorScheme.surfaceVariant else Color.Transparent) - .clickable(onClick = onToggle) + .clickable(role = Role.Button, onClick = onToggle) + .semantics(mergeDescendants = true) { + contentDescription = archivedLabel + } .padding(start = 12.dp, end = 12.dp) .testTag(SIDEBAR_ARCHIVED_TEST_TAG), horizontalArrangement = Arrangement.spacedBy(10.dp), @@ -260,7 +269,7 @@ private fun ArchivedDisclosureRow(count: Int, expanded: Boolean, onToggle: () -> modifier = Modifier.size(20.dp), ) Text( - stringResource(R.string.sidebar_archived), + archivedLabel, fontSize = 14.sp, fontWeight = FontWeight.Medium, color = MaterialTheme.colorScheme.onSurface, diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/theme/BitFunMotion.kt b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/theme/BitFunMotion.kt index 514b601871..1073b5ab7d 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/theme/BitFunMotion.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/bitfun/mobile/app/ui/theme/BitFunMotion.kt @@ -3,14 +3,29 @@ package com.bitfun.mobile.app.ui.theme import androidx.compose.animation.core.CubicBezierEasing import com.bitfun.mobile.app.ui.theme.generated.MobileDesignMotion -/** Motion values shared with the HarmonyOS presentation components. */ +/** + * Motion contract for the mobile surfaces. + * + * [MotionQuickMillis] and [MotionStructureMillis] consume the generated + * design-system tokens from [MobileDesignMotion]. Compose's composable animation + * APIs and [androidx.compose.animation.core.Animatable.animateTo] honor + * [androidx.compose.animation.core.MotionDurationScale] from the coroutine + * context, and the Android window recomposer derives that scale from the system + * `Settings.Global.ANIMATOR_DURATION_SCALE` ("Remove animations"), so reduced + * motion needs no call-site scaling. The drawer keeps its real durations under + * that scale rather than switching to a different, shorter animation. + * + * The remaining drawer durations mirror the HarmonyOS `AppShell.ets` timings and + * intentionally exceed the minimal Quick/Structure scale; [MotionDrawerHideMillis] + * reuses the structure token because its value is already the same. + */ internal const val MotionQuickMillis: Int = MobileDesignMotion.Quick internal const val MotionStructureMillis: Int = MobileDesignMotion.Structure internal const val MotionDrawerScrimMillis: Int = 210 internal const val MotionDrawerOpenMillis: Int = 320 internal const val MotionDrawerCloseMillis: Int = 250 internal const val MotionDrawerRevealMillis: Int = 300 -internal const val MotionDrawerHideMillis: Int = 220 +internal const val MotionDrawerHideMillis: Int = MotionStructureMillis internal val BitFunEaseOut = CubicBezierEasing(0f, 0f, 0.58f, 1f) internal val BitFunEaseInOut = CubicBezierEasing(0.42f, 0f, 0.58f, 1f) 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 806927dfb5..1027e36b2d 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 @@ -242,6 +242,7 @@ 允许所有工具操作,不再请求确认。 正在读取桌面权限设置… 权限设置加载失败,请重试。 + 无法读取桌面端的权限模式,请刷新后重试。 权限模式保存失败,桌面端仍是上方显示的模式。 连接桌面后可修改权限模式。 确认开启完全访问? @@ -253,6 +254,10 @@ 本机自定义 账号模型 暂无可用模型。 + 可用模型加载失败,请重试。 + 当前连接的桌面端不支持从本应用选择模型。 + 重试 + 模型列表刷新失败,正在显示之前加载的列表。 工作区 工作区信息加载失败。 助手 @@ -355,6 +360,7 @@ 远程会话 还没有消息 可以就这个工作区向智能体提问。 + 正在加载对话… 发送中… 发送失败,请检查连接后重试 回复被中断。 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 f436a3bcbd..c8bd7bb4b0 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 @@ -248,6 +248,7 @@ Every tool action runs, and nothing is confirmed again. Reading the desktop\'s permission setting… Could not read the permission setting. Try again. + The desktop\'s permission mode could not be read. Refresh to try again. Could not save the permission mode. The desktop is still on the mode shown above. Connect to the desktop to change the permission mode. Turn on full access? @@ -259,6 +260,10 @@ On this device Account model No models are ready to use. + Could not load the available models. Try again. + The connected desktop does not support choosing models from this app. + Retry + The model list could not be refreshed. Showing the previously loaded list. Workspace Could not load workspace information. Assistants @@ -369,6 +374,7 @@ Remote session No messages yet Ask the agent anything about this workspace. + Loading conversation… Sending… Send failed. Check the connection and retry. diff --git a/src/apps/mobile/android/app/src/test/kotlin/com/bitfun/mobile/app/platform/AppLocaleResolverTest.kt b/src/apps/mobile/android/app/src/test/kotlin/com/bitfun/mobile/app/platform/AppLocaleResolverTest.kt new file mode 100644 index 0000000000..7aacd53715 --- /dev/null +++ b/src/apps/mobile/android/app/src/test/kotlin/com/bitfun/mobile/app/platform/AppLocaleResolverTest.kt @@ -0,0 +1,21 @@ +package com.bitfun.mobile.app.platform + +import org.junit.Assert.assertEquals +import org.junit.Test + +class AppLocaleResolverTest { + @Test + fun resolvesMissingOrUnknownLanguagesToEnglish() { + listOf(null, "", " ", "en", "fr").forEach { language -> + assertEquals(AppLocale.ENGLISH, resolveAppLocale(language)) + } + } + + @Test + fun resolvesChineseLocaleTagsThroughTheirLanguageToken() { + // Android exposes zh-CN and zh-Hans locale tags as the language token zh. + listOf("zh-CN" to "zh", "zh-Hans" to "zh").forEach { (_, language) -> + assertEquals(AppLocale.SIMPLIFIED_CHINESE, resolveAppLocale(language)) + } + } +} diff --git a/src/apps/mobile/android/app/src/test/kotlin/com/bitfun/mobile/app/platform/FoldFactsTest.kt b/src/apps/mobile/android/app/src/test/kotlin/com/bitfun/mobile/app/platform/FoldFactsTest.kt new file mode 100644 index 0000000000..fd76e3813d --- /dev/null +++ b/src/apps/mobile/android/app/src/test/kotlin/com/bitfun/mobile/app/platform/FoldFactsTest.kt @@ -0,0 +1,115 @@ +package com.bitfun.mobile.app.platform + +import org.junit.Assert.assertEquals +import org.junit.Test + +class FoldFactsTest { + @Test + fun ordinaryDeviceHasNoFoldFacts() { + assertEquals( + FoldFacts( + hasFoldingFeature = false, + halfOpened = false, + flatOpened = false, + isFolded = false, + isExpandedFoldable = false, + hoverCandidate = false, + ), + reduceFoldFacts(hasHingeSensor = false, features = emptyList()), + ) + } + + @Test + fun flatOpenFeatureIsExpandedButNotFolded() { + assertEquals( + FoldFacts(true, false, true, false, true, false), + reduceFoldFacts( + hasHingeSensor = true, + features = listOf(FoldFeatureFacts(FoldState.FLAT, isHorizontal = false)), + ), + ) + } + + @Test + fun halfOpenHorizontalFeatureIsHoverCandidate() { + assertEquals( + FoldFacts(true, true, false, false, false, true), + reduceFoldFacts( + hasHingeSensor = false, + features = listOf(FoldFeatureFacts(FoldState.HALF_OPENED, isHorizontal = true)), + ), + ) + } + + @Test + fun halfOpenVerticalFeatureIsNotHoverCandidate() { + assertEquals( + FoldFacts(true, true, false, false, false, false), + reduceFoldFacts( + hasHingeSensor = false, + features = listOf(FoldFeatureFacts(FoldState.HALF_OPENED, isHorizontal = false)), + ), + ) + } + + @Test + fun hingeSensorWithoutWindowFeatureMeansCoverFolded() { + assertEquals( + FoldFacts(false, false, false, true, false, false), + reduceFoldFacts(hasHingeSensor = true, features = emptyList()), + ) + } + + @Test + fun unknownFeatureIsNotTreatedAsExpandedOrHovering() { + assertEquals( + FoldFacts(true, false, false, false, false, false), + reduceFoldFacts( + hasHingeSensor = false, + features = listOf(FoldFeatureFacts(FoldState.UNKNOWN, isHorizontal = true)), + ), + ) + } + + @Test + fun mixedFlatAndUnknownFeaturesAreNotFlatOpen() { + assertEquals( + FoldFacts(true, false, false, false, false, false), + reduceFoldFacts( + hasHingeSensor = false, + features = listOf( + FoldFeatureFacts(FoldState.FLAT, isHorizontal = false), + FoldFeatureFacts(FoldState.UNKNOWN, isHorizontal = false), + ), + ), + ) + } + + @Test + fun unknownFeatureSuppressesHoverForMixedHalfOpenFeatures() { + assertEquals( + FoldFacts(true, true, false, false, false, false), + reduceFoldFacts( + hasHingeSensor = false, + features = listOf( + FoldFeatureFacts(FoldState.HALF_OPENED, isHorizontal = true), + FoldFeatureFacts(FoldState.UNKNOWN, isHorizontal = false), + ), + ), + ) + } + + @Test + fun multipleFeaturesRemainHalfOpenAndUseHorizontalHalfOpenForHover() { + assertEquals( + FoldFacts(true, true, false, false, false, true), + reduceFoldFacts( + hasHingeSensor = true, + features = listOf( + FoldFeatureFacts(FoldState.FLAT, isHorizontal = false), + FoldFeatureFacts(FoldState.HALF_OPENED, isHorizontal = true), + ), + ), + ) + } +} diff --git a/src/apps/mobile/android/app/src/test/kotlin/com/bitfun/mobile/app/ui/chat/ComposerBreakpointTest.kt b/src/apps/mobile/android/app/src/test/kotlin/com/bitfun/mobile/app/ui/chat/ComposerBreakpointTest.kt new file mode 100644 index 0000000000..c847e2e227 --- /dev/null +++ b/src/apps/mobile/android/app/src/test/kotlin/com/bitfun/mobile/app/ui/chat/ComposerBreakpointTest.kt @@ -0,0 +1,30 @@ +package com.bitfun.mobile.app.ui.chat + +import com.bitfun.mobile.app.ui.theme.generated.MobileDesignBreakpoints +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ComposerBreakpointTest { + @Test + fun generatedBreakpointTokensRemainStable() { + assertEquals(600, MobileDesignBreakpoints.Wide) + assertEquals(840, MobileDesignBreakpoints.ExtraWide) + assertEquals(1440, MobileDesignBreakpoints.Xl) + } + + @Test + fun composerWideBreakpointHasNoOffByOne() { + assertFalse(composerIsWide(599)) + assertTrue(composerIsWide(600)) + } + + @Test + fun generatedBreakpointBoundariesRemainOrdered() { + assertTrue(839 < MobileDesignBreakpoints.ExtraWide) + assertEquals(MobileDesignBreakpoints.ExtraWide, 840) + assertTrue(1439 < MobileDesignBreakpoints.Xl) + assertEquals(MobileDesignBreakpoints.Xl, 1440) + } +} diff --git a/src/apps/mobile/android/app/src/test/kotlin/com/bitfun/mobile/app/ui/chat/ConversationComposerDraftTest.kt b/src/apps/mobile/android/app/src/test/kotlin/com/bitfun/mobile/app/ui/chat/ConversationComposerDraftTest.kt new file mode 100644 index 0000000000..3e1214c741 --- /dev/null +++ b/src/apps/mobile/android/app/src/test/kotlin/com/bitfun/mobile/app/ui/chat/ConversationComposerDraftTest.kt @@ -0,0 +1,34 @@ +package com.bitfun.mobile.app.ui.chat + +import org.junit.Assert.assertEquals +import org.junit.Test + +class ConversationComposerDraftTest { + @Test + fun mergesDictationOntoExistingTextWithASingleSpace() { + assertEquals("existing draft spoken text", mergeComposerDraft("existing draft", "spoken text")) + } + + @Test + fun trimsBothSidesBeforeJoining() { + assertEquals("existing spoken", mergeComposerDraft(" existing ", " spoken ")) + } + + @Test + fun dropsABlankExistingDraft() { + assertEquals("spoken", mergeComposerDraft("", " spoken ")) + assertEquals("spoken", mergeComposerDraft(" ", "spoken")) + } + + @Test + fun dropsABlankSpokenFragment() { + assertEquals("existing", mergeComposerDraft(" existing ", "")) + assertEquals("existing", mergeComposerDraft("existing", " ")) + } + + @Test + fun bothBlankProducesEmptyDraft() { + assertEquals("", mergeComposerDraft("", "")) + assertEquals("", mergeComposerDraft(" ", " ")) + } +} diff --git a/src/apps/mobile/android/app/src/test/kotlin/com/bitfun/mobile/app/ui/chat/ConversationScrollPolicyTest.kt b/src/apps/mobile/android/app/src/test/kotlin/com/bitfun/mobile/app/ui/chat/ConversationScrollPolicyTest.kt new file mode 100644 index 0000000000..338adb43ad --- /dev/null +++ b/src/apps/mobile/android/app/src/test/kotlin/com/bitfun/mobile/app/ui/chat/ConversationScrollPolicyTest.kt @@ -0,0 +1,59 @@ +package com.bitfun.mobile.app.ui.chat + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ConversationScrollPolicyTest { + @Test + fun sticksWhenReaderIsAtTail() { + assertTrue( + ConversationScrollPolicy.shouldStickToBottom( + currentlySticking = false, + isAtBottom = true, + isScrollInProgress = false, + ), + ) + assertTrue(ConversationScrollPolicy.shouldScrollToBottom(stickToBottom = true, hasRows = true)) + } + + @Test + fun stopsStickingWhenReaderLeavesTail() { + assertFalse( + ConversationScrollPolicy.shouldStickToBottom( + currentlySticking = true, + isAtBottom = false, + isScrollInProgress = true, + ), + ) + } + + @Test + fun resumesAfterExplicitScrollToBottom() { + assertTrue( + ConversationScrollPolicy.shouldStickToBottom( + currentlySticking = false, + isAtBottom = true, + isScrollInProgress = false, + ), + ) + } + + @Test + fun doesNotScrollWhenDeltaArrivesWhileReaderIsScrolledUp() { + assertFalse( + ConversationScrollPolicy.shouldScrollToBottom( + stickToBottom = false, + hasRows = true, + ), + ) + } + + @Test + fun lastItemIndexAccountsForTheLoadOlderHeader() { + org.junit.Assert.assertEquals(2, ConversationScrollPolicy.lastItemIndex(rowCount = 3, hasMoreMessages = false)) + org.junit.Assert.assertEquals(3, ConversationScrollPolicy.lastItemIndex(rowCount = 3, hasMoreMessages = true)) + org.junit.Assert.assertEquals(0, ConversationScrollPolicy.lastItemIndex(rowCount = 0, hasMoreMessages = false)) + org.junit.Assert.assertEquals(0, ConversationScrollPolicy.lastItemIndex(rowCount = 0, hasMoreMessages = true)) + } +} diff --git a/src/apps/mobile/android/app/src/test/kotlin/com/bitfun/mobile/app/ui/chat/tool/ToolInteractionPanelsTest.kt b/src/apps/mobile/android/app/src/test/kotlin/com/bitfun/mobile/app/ui/chat/tool/ToolInteractionPanelsTest.kt index 23e63c694c..0348d80e76 100644 --- a/src/apps/mobile/android/app/src/test/kotlin/com/bitfun/mobile/app/ui/chat/tool/ToolInteractionPanelsTest.kt +++ b/src/apps/mobile/android/app/src/test/kotlin/com/bitfun/mobile/app/ui/chat/tool/ToolInteractionPanelsTest.kt @@ -2,11 +2,18 @@ package com.bitfun.mobile.app.ui.chat.tool import com.bitfun.mobile.core.feature.session.QuestionAnswerValue import com.bitfun.mobile.core.feature.session.QuestionOption +import com.bitfun.mobile.core.feature.session.ToolApprovalEditContract +import com.bitfun.mobile.core.feature.session.ToolApprovalEditSupport import com.bitfun.mobile.core.feature.session.ToolQuestion import org.junit.Assert.assertEquals import org.junit.Test class ToolInteractionPanelsTest { + @Test + fun approvalEditSupportIsTypedAsUnsupported() { + assertEquals(ToolApprovalEditSupport.UNSUPPORTED, ToolApprovalEditContract.support) + } + private val questions = listOf( ToolQuestion(0, "", "Pick one", listOf(QuestionOption("A", null), QuestionOption("B", null)), false), ToolQuestion(1, "", "Pick many", listOf(QuestionOption("X", null)), true), diff --git a/src/apps/mobile/android/app/src/test/kotlin/com/bitfun/mobile/app/ui/shell/ContentCardCoordinatorTest.kt b/src/apps/mobile/android/app/src/test/kotlin/com/bitfun/mobile/app/ui/shell/ContentCardCoordinatorTest.kt new file mode 100644 index 0000000000..10f66b6b84 --- /dev/null +++ b/src/apps/mobile/android/app/src/test/kotlin/com/bitfun/mobile/app/ui/shell/ContentCardCoordinatorTest.kt @@ -0,0 +1,100 @@ +package com.bitfun.mobile.app.ui.shell + +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * Locks the content-card boundary rules for the compact drawer without standing + * up a Compose tree. [ContentCardCoordinator] is the single observable for the + * rounded clip + shadow: [ContentCardPhase.Receded] means the static shadow/clip + * modifier is attached, [ContentCardPhase.Flat] means the full-screen content is + * drawn with neither. + */ +class ContentCardCoordinatorTest { + @Test + fun initialStateReflectsWhetherTheDrawerStartsOpen() { + assertEquals(ContentCardPhase.Flat, ContentCardCoordinator(initialOpen = false).phase) + assertEquals(ContentCardPhase.Receded, ContentCardCoordinator(initialOpen = true).phase) + } + + @Test + fun revealDoesNotRecedeTheFirstFullScreenFrame() { + val coordinator = ContentCardCoordinator(initialOpen = false) + + coordinator.onRevealStarted() + + // The first frame after opening is still the content at rest, full + // screen. The 28dp corner and shadow must not snap onto it. + assertEquals(ContentCardPhase.Flat, coordinator.phase) + } + + @Test + fun zeroProgressNeverRecedes() { + val coordinator = ContentCardCoordinator(initialOpen = false) + + coordinator.onRevealStarted() + coordinator.onContentProgress(0f) + + // progress == 0 means the content is still at rest, full screen; the + // corner/shadow must stay off. This pins the "only progress > 0 may + // recede" invariant directly. + assertEquals(ContentCardPhase.Flat, coordinator.phase) + } + + @Test + fun positiveProgressRecedesTheCard() { + val coordinator = ContentCardCoordinator(initialOpen = false) + + coordinator.onRevealStarted() + coordinator.onContentProgress(0.001f) + + assertEquals(ContentCardPhase.Receded, coordinator.phase) + } + + @Test + fun openThenCloseSettlesBackToFlat() { + val coordinator = ContentCardCoordinator(initialOpen = false) + + coordinator.onRevealStarted() + coordinator.onContentProgress(0.5f) + assertEquals(ContentCardPhase.Receded, coordinator.phase) + + // Close only removes the shadow/clip once the content has settled back + // to full screen — never on an earlier drawer-hide deadline. + coordinator.onContentSettled() + + assertEquals(ContentCardPhase.Flat, coordinator.phase) + } + + @Test + fun rapidOpenCloseConverges() { + val coordinator = ContentCardCoordinator(initialOpen = false) + + coordinator.onRevealStarted() + coordinator.onContentProgress(0.4f) + coordinator.onContentSettled() + coordinator.onRevealStarted() + coordinator.onContentProgress(0.4f) + coordinator.onContentSettled() + + assertEquals(ContentCardPhase.Flat, coordinator.phase) + } + + @Test + fun reducedMotionCollapsesTimingButKeepsTheFinalState() { + val coordinator = ContentCardCoordinator(initialOpen = false) + + // Under "Remove animations" the content animation completes instantly, + // so the first observed progress is already 1f. The boundary events + // still fire in the same order and the final card state must be flat + // with no residual clip/shadow; a repeated settle is a no-op. + coordinator.onRevealStarted() + coordinator.onContentProgress(1f) + assertEquals(ContentCardPhase.Receded, coordinator.phase) + + coordinator.onContentSettled() + coordinator.onContentSettled() + + assertEquals(ContentCardPhase.Flat, coordinator.phase) + } +} diff --git a/src/apps/mobile/android/app/src/test/kotlin/com/bitfun/mobile/app/ui/shell/sidebar/RemoteWorkspacePathPolicyTest.kt b/src/apps/mobile/android/app/src/test/kotlin/com/bitfun/mobile/app/ui/shell/sidebar/RemoteWorkspacePathPolicyTest.kt new file mode 100644 index 0000000000..b7f5209da5 --- /dev/null +++ b/src/apps/mobile/android/app/src/test/kotlin/com/bitfun/mobile/app/ui/shell/sidebar/RemoteWorkspacePathPolicyTest.kt @@ -0,0 +1,42 @@ +package com.bitfun.mobile.app.ui.shell.sidebar + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class RemoteWorkspacePathPolicyTest { + @Test + fun trailingSeparatorsDoNotChangeEquality() { + assertTrue(RemoteWorkspacePathPolicy.equal("/workspace", "/workspace/")) + assertTrue(RemoteWorkspacePathPolicy.equal("/workspace///", "/workspace")) + assertTrue(RemoteWorkspacePathPolicy.equal("/workspace\\", "/workspace")) + } + + @Test + fun surroundingWhitespaceIsIgnored() { + assertTrue(RemoteWorkspacePathPolicy.equal(" /workspace ", "/workspace")) + } + + @Test + fun rootEmptyBlankAndSingleCharacterEdgesArePreserved() { + assertEquals("/", RemoteWorkspacePathPolicy.normalize("///")) + assertEquals("\\", RemoteWorkspacePathPolicy.normalize("\\\\")) + assertEquals("", RemoteWorkspacePathPolicy.normalize(" ")) + assertEquals("/", RemoteWorkspacePathPolicy.normalize("/")) + assertTrue(RemoteWorkspacePathPolicy.equal("", " ")) + assertFalse(RemoteWorkspacePathPolicy.equal("/", "")) + } + + @Test + fun realWorkspacePathWithTrailingSlashEqualsCanonicalPath() { + val path = "/home/user/project/src" + assertTrue(RemoteWorkspacePathPolicy.equal(path, "$path/")) + } + + @Test + fun distinctPathsRemainUnequal() { + assertFalse(RemoteWorkspacePathPolicy.equal("/home/user/project", "/home/user/other")) + assertFalse(RemoteWorkspacePathPolicy.equal("/workspace/a", "/workspace/ab")) + } +} diff --git a/src/apps/mobile/design-system/preview/preview-scenarios.smoke.test.mjs b/src/apps/mobile/design-system/preview/preview-scenarios.smoke.test.mjs new file mode 100644 index 0000000000..ad614c59cc --- /dev/null +++ b/src/apps/mobile/design-system/preview/preview-scenarios.smoke.test.mjs @@ -0,0 +1,63 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { mobilePreviewScenarios } from './generated/mobile-design-data.js'; + +const expectedScenarioIds = [ + 'connected-conversation', + 'streaming-dark', + 'reconnecting-wide', +]; +const requiredScenarioFields = [ + 'id', + 'title', + 'appearance', + 'header.title', + 'header.subtitle', + 'composer.phase', + 'composer.streaming', +]; +const sensitiveContentPatterns = [ + /akid/i, + /sk-/i, + /-----begin/i, + /password/i, + /secret/i, + /token/i, + /[A-Za-z0-9+/=]{40,}/, +]; + +const hasOwn = (value, key) => Object.prototype.hasOwnProperty.call(value, key); + + +test('mobile preview scenarios satisfy the deterministic contract', () => { + const scenarios = mobilePreviewScenarios.scenarios; + + assert.equal(scenarios.length, 3); + assert.deepEqual( + scenarios.map((scenario) => scenario.id), + expectedScenarioIds, + ); + + for (const scenario of scenarios) { + for (const field of requiredScenarioFields) { + const [root, nested] = field.split('.'); + const value = nested === undefined ? scenario : scenario[root]?.[nested]; + assert.ok( + nested === undefined ? hasOwn(scenario, root) : hasOwn(scenario[root], nested), + `scenario ${scenario.id} is missing ${field}`, + ); + assert.notEqual(value, undefined, `scenario ${scenario.id} has undefined ${field}`); + } + + assert.ok(hasOwn(scenario.viewport, 'width')); + assert.ok(hasOwn(scenario.viewport, 'height')); + assert.ok(Array.isArray(scenario.messages)); + assert.ok(scenario.messages.length > 0); + } + + const serializedScenarios = JSON.stringify(mobilePreviewScenarios); + for (const pattern of sensitiveContentPatterns) { + assert.doesNotMatch(serializedScenarios, pattern); + } +}); diff --git a/src/apps/mobile/design-system/preview/snapshots/README.md b/src/apps/mobile/design-system/preview/snapshots/README.md index d8368a4140..cb4b282392 100644 --- a/src/apps/mobile/design-system/preview/snapshots/README.md +++ b/src/apps/mobile/design-system/preview/snapshots/README.md @@ -18,3 +18,7 @@ Use the matching generated native preview gallery and the same scenario id. Captures can also be selected directly from each column in the browser. Local captures are visual evidence and should not be committed unless they are being reviewed as deliberate regression fixtures. + +Android capture evidence is produced by on-device instrumentation tests, not +committed PNGs. HarmonyOS captures remain unproven until a device and toolchain +are available. diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/entryability/EntryAbility.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/entryability/EntryAbility.ets index 729c9f70b0..558a1305a7 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/entryability/EntryAbility.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/entryability/EntryAbility.ets @@ -8,13 +8,25 @@ const TAG = 'BitFunRemote'; export default class EntryAbility extends UIAbility { private mainWindow?: window.Window; private initialPage: string = 'pages/AppRoot'; + private previewStorage?: LocalStorage; onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void { - if (want.parameters?.['bitfunDesignPreview'] === 'connected-conversation') { + const scenarioId = want.parameters?.['bitfunDesignPreview']; + if (scenarioId === 'connected-conversation' || + scenarioId === 'streaming-dark' || + scenarioId === 'reconnecting-wide') { this.initialPage = 'pages/preview/MobileDesignGallery'; + this.previewStorage = new LocalStorage(); + this.previewStorage.setOrCreate('scenarioId', scenarioId); + AppStorage.setOrCreate('scenarioId', scenarioId); } try { - this.context.getApplicationContext().setColorMode(ConfigurationConstant.ColorMode.COLOR_MODE_NOT_SET); + const colorMode = scenarioId === 'streaming-dark' + ? ConfigurationConstant.ColorMode.COLOR_MODE_DARK + : scenarioId === 'connected-conversation' || scenarioId === 'reconnecting-wide' + ? ConfigurationConstant.ColorMode.COLOR_MODE_LIGHT + : ConfigurationConstant.ColorMode.COLOR_MODE_NOT_SET; + this.context.getApplicationContext().setColorMode(colorMode); } catch (err) { hilog.error(DOMAIN, TAG, 'Failed to set colorMode. Cause: %{public}s', JSON.stringify(err)); } @@ -37,13 +49,18 @@ export default class EntryAbility extends UIAbility { hilog.error(DOMAIN, TAG, 'Failed to configure window bars. Cause: %{public}s', JSON.stringify(err)); } - windowStage.loadContent(this.initialPage, (err) => { + const onContentLoaded = (err: BusinessError): void => { if (err.code) { hilog.error(DOMAIN, TAG, 'Failed to load the content. Cause: %{public}s', JSON.stringify(err)); return; } hilog.info(DOMAIN, TAG, 'Succeeded in loading the content.'); - }); + }; + if (this.previewStorage) { + windowStage.loadContent(this.initialPage, this.previewStorage, onContentLoaded); + } else { + windowStage.loadContent(this.initialPage, onContentLoaded); + } } onWindowStageDestroy(): void { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/MobileDesignGallery.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/MobileDesignGallery.ets index e9dad8f417..2c74811d01 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/MobileDesignGallery.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/MobileDesignGallery.ets @@ -13,17 +13,30 @@ import { CARD, INK, LINE, MUTED, PAGE_BG, SOFT } from '../components/Theme'; struct MobileDesignGallery { @Param scenario: MobilePreviewScenario = MobilePreviewScenarios.connectedConversation; + private resolvedScenario(): MobilePreviewScenario { + switch (AppStorage.get('scenarioId') ?? '') { + case 'streaming-dark': + return MobilePreviewScenarios.streamingDark; + case 'reconnecting-wide': + return MobilePreviewScenarios.reconnectingWide; + case 'connected-conversation': + return MobilePreviewScenarios.connectedConversation; + default: + return this.scenario; + } + } + build() { Column() { this.PlatformLabel() ConversationHeader({ - title: this.scenario.headerTitle, - subtitle: this.scenario.headerSubtitle, + title: this.resolvedScenario().headerTitle, + subtitle: this.resolvedScenario().headerSubtitle, showSidebarButton: true, showActions: true }) Column({ space: MobileDesignGeometry.messageSpacing }) { - ForEach(this.scenario.messages, (message: MobilePreviewMessage) => { + ForEach(this.resolvedScenario().messages, (message: MobilePreviewMessage) => { this.MessageBubble(message) }, (message: MobilePreviewMessage) => `${message.role}:${message.text}`) } @@ -37,10 +50,10 @@ struct MobileDesignGallery { }) ComposerBar({ presentation: ComposerPresentation.Compact, - chatInput: this.scenario.composerDraft, - isBusy: this.scenario.streaming, - canStop: this.scenario.streaming, - connectionState: this.scenario.connectionPhase + chatInput: this.resolvedScenario().composerDraft, + isBusy: this.resolvedScenario().streaming, + canStop: this.resolvedScenario().streaming, + connectionState: this.resolvedScenario().connectionPhase }) } .width('100%') @@ -62,7 +75,7 @@ struct MobileDesignGallery { .backgroundColor(SOFT) .borderRadius(10) Blank() - Text(`${this.scenario.viewportWidth} × ${this.scenario.viewportHeight}`) + Text(`${this.resolvedScenario().viewportWidth} × ${this.resolvedScenario().viewportHeight}`) .fontSize(MobileDesignTypography.labelSmall.size) .fontColor(MUTED) } @@ -112,6 +125,18 @@ struct MobileDesignCompactPreview { } } +@Preview({ + title: 'BitFun Mobile · Dark', + width: 390, + height: 844 +}) +@ComponentV2 +struct MobileDesignStreamingDarkPreview { + build() { + MobileDesignGallery({ scenario: MobilePreviewScenarios.streamingDark }) + } +} + @Preview({ title: 'BitFun Mobile · Wide', width: 1024, diff --git a/src/apps/mobile/harmonyos/entry/src/test/MobilePreviewScenarioUnit.test.ets b/src/apps/mobile/harmonyos/entry/src/test/MobilePreviewScenarioUnit.test.ets new file mode 100644 index 0000000000..dfa4041e2c --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/test/MobilePreviewScenarioUnit.test.ets @@ -0,0 +1,86 @@ +import { describe, it, expect } from '@ohos/hypium'; +import { + MobilePreviewScenario, + MobilePreviewScenarios +} from '../main/ets/generated/MobilePreviewScenarios'; + +const sensitiveContentPattern: RegExp = /akid|aki|sk-|-----begin|password|secret|token/i; +const base64LookingPattern: RegExp = /[A-Za-z0-9+/=]{40,}/; + +function containsSensitiveContent(scenario: MobilePreviewScenario): boolean { + const fields: string[] = [ + scenario.title, + scenario.description, + scenario.headerTitle, + scenario.headerSubtitle, + scenario.composerDraft, + scenario.composerPlaceholder + ]; + scenario.messages.forEach(message => fields.push(message.text)); + const content = fields.join('\n'); + return sensitiveContentPattern.test(content) || base64LookingPattern.test(content); +} + +export default function mobilePreviewScenarioUnitTest() { + describe('Mobile preview scenarios', () => { + it('keeps the three scenario ids stable', 0, () => { + const ids: string[] = [ + MobilePreviewScenarios.connectedConversation.id, + MobilePreviewScenarios.streamingDark.id, + MobilePreviewScenarios.reconnectingWide.id + ]; + expect(ids.length).assertEqual(3); + expect(ids[0]).assertEqual('connected-conversation'); + expect(ids[1]).assertEqual('streaming-dark'); + expect(ids[2]).assertEqual('reconnecting-wide'); + }); + + it('matches the preview contract for appearance, viewport, connection, and streaming', 0, () => { + const connected = MobilePreviewScenarios.connectedConversation; + expect(connected.appearance).assertEqual('light'); + expect(connected.viewportWidth).assertEqual(390); + expect(connected.viewportHeight).assertEqual(844); + expect(connected.connectionPhase).assertEqual('connected'); + expect(connected.streaming).assertFalse(); + + const streaming = MobilePreviewScenarios.streamingDark; + expect(streaming.appearance).assertEqual('dark'); + expect(streaming.viewportWidth).assertEqual(390); + expect(streaming.viewportHeight).assertEqual(844); + expect(streaming.connectionPhase).assertEqual('connected'); + expect(streaming.streaming).assertTrue(); + + const reconnecting = MobilePreviewScenarios.reconnectingWide; + expect(reconnecting.appearance).assertEqual('light'); + expect(reconnecting.viewportWidth).assertEqual(1024); + expect(reconnecting.viewportHeight).assertEqual(768); + expect(reconnecting.connectionPhase).assertEqual('reconnecting'); + expect(reconnecting.streaming).assertFalse(); + }); + + it('keeps required preview copy and messages populated', 0, () => { + const scenarios: MobilePreviewScenario[] = [ + MobilePreviewScenarios.connectedConversation, + MobilePreviewScenarios.streamingDark, + MobilePreviewScenarios.reconnectingWide + ]; + scenarios.forEach(scenario => { + expect(scenario.headerTitle.length > 0).assertTrue(); + expect(scenario.composerPlaceholder.length > 0).assertTrue(); + expect(scenario.messages.length > 0).assertTrue(); + expect(scenario.messages.some(message => message.text.length > 0)).assertTrue(); + }); + }); + + it('contains no sensitive content in scenario fields', 0, () => { + const scenarios: MobilePreviewScenario[] = [ + MobilePreviewScenarios.connectedConversation, + MobilePreviewScenarios.streamingDark, + MobilePreviewScenarios.reconnectingWide + ]; + scenarios.forEach(scenario => { + expect(containsSensitiveContent(scenario)).assertFalse(); + }); + }); + }); +} diff --git a/src/apps/mobile/shared/core-domain/src/commonMain/kotlin/com/bitfun/mobile/core/domain/ChatTimelineProjector.kt b/src/apps/mobile/shared/core-domain/src/commonMain/kotlin/com/bitfun/mobile/core/domain/ChatTimelineProjector.kt index 0f9a8108a2..959ea9839f 100644 --- a/src/apps/mobile/shared/core-domain/src/commonMain/kotlin/com/bitfun/mobile/core/domain/ChatTimelineProjector.kt +++ b/src/apps/mobile/shared/core-domain/src/commonMain/kotlin/com/bitfun/mobile/core/domain/ChatTimelineProjector.kt @@ -1,9 +1,13 @@ package com.bitfun.mobile.core.domain import com.bitfun.mobile.core.protocol.ChatMessageItemResponse -import com.bitfun.mobile.core.protocol.RemoteToolStatusResponse import kotlin.math.absoluteValue +/** + * Harmony-parity revision tracking port, currently not consumed by the Kotlin UI. + * Android Compose uses `items(key = ...)` and iOS uses `ForEach` ids, so this + * tracker intentionally keeps coarse signatures only. + */ public class ChatTimelineRevisionTracker public constructor() { private var signature: String = "" private var revision: Int = 0 @@ -47,9 +51,19 @@ public object ChatTimelineProjector { pendingMessages: List, activeTurn: ChatMessage?, hasMoreMessages: Boolean, + ): List = project(messages, pendingMessages, activeTurn, hasMoreMessages, "") + + public fun project( + messages: List, + pendingMessages: List, + activeTurn: ChatMessage?, + hasMoreMessages: Boolean, + activeTurnAnchorId: String, ): List { val timelineMessages = realMessages(messages) val pendingItems = pendingMessagesNotPersisted(pendingMessages, timelineMessages) + val renderActiveTurn = activeTurn?.takeIf { shouldRenderActiveTurn(timelineMessages, it) } + val anchorIndex = anchorIndex(pendingItems, activeTurnAnchorId) val items = timelineMessages.map { message -> ChatTimelineItem( id = "message-${message.id}", @@ -61,7 +75,8 @@ public object ChatTimelineProjector { ) }.toMutableList() - pendingItems.forEach { message -> + if (renderActiveTurn != null && anchorIndex < 0) items += activeTurnItem(renderActiveTurn) + pendingItems.forEachIndexed { index, message -> items += ChatTimelineItem( id = "pending-${message.id}", type = ChatTimelineItemType.OPTIMISTIC_USER_MESSAGE, @@ -70,17 +85,7 @@ public object ChatTimelineProjector { isFinalizing = false, showRetryAction = false, ) - } - - if (activeTurn != null && shouldRenderActiveTurn(timelineMessages, activeTurn)) { - items += ChatTimelineItem( - id = "active-${activeTurnKey(activeTurn)}-${activeTurnVersionKey(activeTurn)}", - type = ChatTimelineItemType.ASSISTANT_LIVE_TURN, - message = activeTurn, - isStreaming = MessageStatusSemantics.isStreaming(activeTurn.status), - isFinalizing = MessageStatusSemantics.isFinalizing(activeTurn.status), - showRetryAction = false, - ) + if (renderActiveTurn != null && index == anchorIndex) items += activeTurnItem(renderActiveTurn) } if (items.isEmpty() && !hasMoreMessages) { @@ -98,6 +103,18 @@ public object ChatTimelineProjector { return items } + private fun activeTurnItem(activeTurn: ChatMessage): ChatTimelineItem = ChatTimelineItem( + id = "active-${activeTurnKey(activeTurn)}", + type = ChatTimelineItemType.ASSISTANT_LIVE_TURN, + message = activeTurn, + isStreaming = MessageStatusSemantics.isStreaming(activeTurn.status), + isFinalizing = MessageStatusSemantics.isFinalizing(activeTurn.status), + showRetryAction = false, + ) + + private fun anchorIndex(pendingItems: List, id: String): Int = + if (id.isEmpty()) -1 else pendingItems.indexOfFirst { it.id == id } + public fun pendingMessagesNotPersisted( pendingMessages: List, messages: List, @@ -176,43 +193,6 @@ public object ChatTimelineProjector { private fun activeTurnKey(activeTurn: ChatMessage): String = activeTurn.turnId?.takeIf(String::isNotEmpty) ?: activeTurn.id - private fun activeTurnVersionKey(activeTurn: ChatMessage): String { - val signature = listOf( - activeTurn.status, - (activeTurn.renderVersion ?: 0).toString(), - "${activeTurn.text.length}:${stableTextHash(activeTurn.text)}", - "${activeTurn.thinking.orEmpty().length}:${stableTextHash(activeTurn.thinking.orEmpty())}", - itemSignature(activeTurn.items.orEmpty()), - toolSignature(activeTurn.tools.orEmpty()), - ).joinToString("|") - return stableTextHash(signature) - } - - private fun itemSignature(items: List): String = - items.mapIndexed { index, item -> - listOf( - index.toString(), - item.type.orEmpty(), - if (item.isSubagent == true) "1" else "0", - "${item.content.orEmpty().length}:${stableTextHash(item.content.orEmpty())}", - item.tool?.let { toolSignature(listOf(it)) }.orEmpty(), - item.subItems?.let(::itemSignature).orEmpty(), - ).joinToString(":") - }.joinToString(",") - - private fun toolSignature(tools: List): String = - tools.mapIndexed { index, tool -> - val preview = "${tool.inputPreview.orEmpty()}|${tool.resultPreview.orEmpty()}|${tool.errorPreview.orEmpty()}" - listOf( - index.toString(), - tool.id.orEmpty(), - tool.name.orEmpty(), - tool.status.orEmpty(), - (tool.durationMs ?: 0).toString(), - "${preview.length}:${stableTextHash(preview)}", - ).joinToString(":") - }.joinToString(",") - } private fun stableTextHash(text: String): String { diff --git a/src/apps/mobile/shared/core-domain/src/commonMain/kotlin/com/bitfun/mobile/core/domain/ChatTimelineStore.kt b/src/apps/mobile/shared/core-domain/src/commonMain/kotlin/com/bitfun/mobile/core/domain/ChatTimelineStore.kt index 0e0536a93c..150d1172d6 100644 --- a/src/apps/mobile/shared/core-domain/src/commonMain/kotlin/com/bitfun/mobile/core/domain/ChatTimelineStore.kt +++ b/src/apps/mobile/shared/core-domain/src/commonMain/kotlin/com/bitfun/mobile/core/domain/ChatTimelineStore.kt @@ -25,23 +25,27 @@ public data class ChatTimelineState public constructor( public val cursor: ChatSessionCursor, public val modelCatalog: RemoteModelCatalog, public val selectedModelId: String, + public val activeTurnAnchorId: String, ) public class ChatTimelineStore public constructor() { private var state: ChatTimelineState = emptyState("") private var recentlyCoveredTurn: CoveredTurn? = null + private var activeTurnAnchor: String = "" public fun reset(): Unit = reset("") public fun reset(sessionId: String) { state = emptyState(sessionId) recentlyCoveredTurn = null + activeTurnAnchor = "" } public fun snapshot(): ChatTimelineState = state.copy( persistedMessages = state.persistedMessages.toList(), optimisticMessages = state.optimisticMessages.toList(), cursor = state.cursor.copy(), + activeTurnAnchorId = activeTurnAnchor, ) public fun setSyncPhase(syncPhase: ChatSyncPhase) { @@ -134,6 +138,9 @@ public class ChatTimelineStore public constructor() { ) { return } + if (existing == null || !isLocalPendingActiveTurn(existing)) { + activeTurnAnchor = lastOptimisticMessageId() + } state = state.copy( activeTurn = emptyMessage( id = activeId, @@ -154,6 +161,7 @@ public class ChatTimelineStore public constructor() { ) { return "" } + activeTurnAnchor = normalizedLocalId state = state.copy( activeTurn = emptyMessage(id = activeId, turnId = null, status = "active"), syncPhase = ChatSyncPhase.STREAMING, @@ -164,6 +172,7 @@ public class ChatTimelineStore public constructor() { public fun clearPendingActiveTurn(activeId: String) { val activeTurn = state.activeTurn if (activeTurn == null || activeTurn.id != activeId || !isLocalPendingActiveTurn(activeTurn)) return + activeTurnAnchor = "" state = state.copy(activeTurn = null, syncPhase = ChatSyncPhase.IDLE) } @@ -186,6 +195,7 @@ public class ChatTimelineStore public constructor() { public fun clearActiveTurn() { recentlyCoveredTurn = null + activeTurnAnchor = "" state = state.copy(activeTurn = null, syncPhase = ChatSyncPhase.IDLE) } @@ -254,12 +264,15 @@ public class ChatTimelineStore public constructor() { public fun activeTurnOrNull(): ChatMessage? = state.activeTurn + public fun activeTurnAnchorId(): String = activeTurnAnchor + public fun project(hasMoreMessages: Boolean): List = ChatTimelineProjector.project( state.persistedMessages, state.optimisticMessages, state.activeTurn, hasMoreMessages, + activeTurnAnchor, ) private fun acceptsSession(sessionId: String): Boolean = @@ -305,6 +318,8 @@ public class ChatTimelineStore public constructor() { return null } + private fun lastOptimisticMessageId(): String = state.optimisticMessages.lastOrNull()?.id.orEmpty() + public companion object { public fun optimisticMessagesNotPersisted( optimisticMessages: List, @@ -341,6 +356,7 @@ public class ChatTimelineStore public constructor() { cursor = ChatSessionCursor(0, 0, 0), modelCatalog = RemoteModelCatalog(0, emptyList(), RemoteDefaultModels(), null), selectedModelId = "", + activeTurnAnchorId = "", ) private fun emptyMessage(id: String, turnId: String?, status: String): ChatMessage = ChatMessage( @@ -384,7 +400,13 @@ public class ChatTimelineStore public constructor() { return previous.id == incoming.id && previous.id.startsWith("active-") && incoming.id.startsWith("active-") } - private fun mergeActiveTurn(previous: ChatMessage, incoming: ChatMessage): ChatMessage = incoming.copy( + private fun mergeActiveTurn(previous: ChatMessage, incoming: ChatMessage): ChatMessage { + val previousVersion = previous.renderVersion + val incomingVersion = incoming.renderVersion + if (previousVersion != null && incomingVersion != null && incomingVersion < previousVersion) { + return previous + } + return incoming.copy( turnId = incoming.turnId ?: previous.turnId, role = incoming.role.ifEmpty { previous.role }, text = monotonicText(previous.text, incoming.text), @@ -394,39 +416,226 @@ public class ChatTimelineStore public constructor() { tools = incoming.tools ?: previous.tools, items = mergeActiveItems(previous.items.orEmpty(), incoming.items.orEmpty()), images = incoming.images?.takeIf(List::isNotEmpty) ?: previous.images, - ) + ) + } private fun mergeActiveItems( previousItems: List, incomingItems: List, ): List { if (incomingItems.isEmpty()) return previousItems - val merged = previousItems.toMutableList() - val matched = mutableSetOf() - incomingItems.forEachIndexed { incomingIndex, incoming -> - val toolId = incoming.tool?.id.orEmpty() - val matchIndex = if (toolId.isNotEmpty()) { - previousItems.indices.firstOrNull { index -> - index !in matched && previousItems[index].tool?.id == toolId - } ?: -1 - } else if ( - incomingIndex < previousItems.size && incomingIndex !in matched && - sameActiveItem(previousItems[incomingIndex], incoming) + + val working = previousItems.toMutableList() + var searchFrom = 0 + + for (incomingIndex in incomingItems.indices) { + val incoming = incomingItems[incomingIndex] + if (incoming.tool != null) { + val toolIndex = findToolMatch(working, incoming, searchFrom) + if (toolIndex >= 0) { + working[toolIndex] = mergeActiveItem(working[toolIndex], incoming) + searchFrom = toolIndex + 1 + } else { + val anchor = anchorForNewTool(working, incomingItems, incomingIndex, searchFrom) + working.add(anchor, incoming) + searchFrom = anchor + 1 + } + continue + } + + when (val match = findTextMatch(working, incoming, incomingItems, incomingIndex, searchFrom)) { + is TextMatch.Merge -> { + working[match.index] = mergeActiveItem(working[match.index], incoming) + searchFrom = match.index + 1 + } + is TextMatch.Collapse -> { + val merged = mergeActiveItem(working[match.start], incoming) + repeat(match.end - match.start + 1) { working.removeAt(match.start) } + working.add(match.start, merged) + searchFrom = match.start + 1 + } + is TextMatch.Split -> { + val previous = working[match.index] + val prefix = incoming.copy( + type = incoming.type ?: previous.type, + subItems = mergeActiveItems(previous.subItems.orEmpty(), incoming.subItems.orEmpty()), + ) + val remainder = previous.copy( + content = previous.content.orEmpty().substring(incoming.content.orEmpty().length), + subItems = previous.subItems, + ) + working[match.index] = prefix + working.add(match.index + 1, remainder) + searchFrom = match.index + 1 + } + null -> { + working.add(incoming) + searchFrom = working.size + } + } + } + return working.deduplicateAdjacentActiveItems() + } + + private sealed interface TextMatch { + data class Merge(val index: Int) : TextMatch + data class Collapse(val start: Int, val end: Int) : TextMatch + data class Split(val index: Int) : TextMatch + } + + private fun findToolMatch( + working: List, + incoming: ChatMessageItemResponse, + searchFrom: Int, + ): Int { + val incomingToolId = incoming.tool?.id.orEmpty() + return (searchFrom until working.size).firstOrNull { index -> + val toolId = working[index].tool?.id.orEmpty() + toolId.isNotEmpty() && toolId == incomingToolId + } ?: -1 + } + + private fun anchorForNewTool( + working: List, + incomingItems: List, + incomingIndex: Int, + searchFrom: Int, + ): Int { + for (index in incomingIndex + 1 until incomingItems.size) { + val next = incomingItems[index] + if (next.tool != null) continue + val position = positionForText(working, next, searchFrom) + if (position >= 0) return position + } + return working.size + } + + private fun positionForText( + working: List, + incoming: ChatMessageItemResponse, + searchFrom: Int, + ): Int { + val incomingType = incoming.type.orEmpty().lowercase() + val incomingContent = incoming.content.orEmpty() + val exact = (searchFrom until working.size).firstOrNull { index -> + matchesTextType(working[index], incomingType) && + working[index].content.orEmpty() == incomingContent + } + if (exact != null) return exact + + for (start in searchFrom until working.size) { + if (!isTextLikeItem(working[start]) || working[start].type.orEmpty().lowercase() != incomingType) continue + var concatenated = "" + var spaced = "" + var end = start + while (end < working.size && isTextLikeItem(working[end]) && + working[end].type.orEmpty().lowercase() == incomingType ) { - incomingIndex - } else { - -1 + val content = working[end].content.orEmpty() + concatenated += content + spaced = if (spaced.isEmpty()) content else "$spaced $content" + if (end > start && incomingContent.length > working[start].content.orEmpty().length && + (prefixRelated(incomingContent, concatenated) || prefixRelated(incomingContent, spaced)) + ) { + return start + } + end += 1 } - if (matchIndex >= 0) { - merged[matchIndex] = mergeActiveItem(previousItems[matchIndex], incoming) - matched += matchIndex - } else { - merged += incoming + } + + return (searchFrom until working.size).firstOrNull { index -> + matchesTextType(working[index], incomingType) && + prefixRelated(working[index].content.orEmpty(), incomingContent) + } ?: -1 + } + + private fun findTextMatch( + working: List, + incoming: ChatMessageItemResponse, + incomingItems: List, + incomingIndex: Int, + searchFrom: Int, + ): TextMatch? { + val incomingType = incoming.type.orEmpty().lowercase() + val incomingContent = incoming.content.orEmpty() + + val exact = (searchFrom until working.size).firstOrNull { index -> + matchesTextType(working[index], incomingType) && + working[index].content.orEmpty() == incomingContent + } + if (exact != null) return TextMatch.Merge(exact) + + for (start in searchFrom until working.size) { + if (!isTextLikeItem(working[start]) || working[start].type.orEmpty().lowercase() != incomingType) continue + var concatenated = "" + var spaced = "" + var end = start + while (end < working.size && isTextLikeItem(working[end]) && + working[end].type.orEmpty().lowercase() == incomingType + ) { + val content = working[end].content.orEmpty() + concatenated += content + spaced = if (spaced.isEmpty()) content else "$spaced $content" + if (end > start && incomingContent.length > working[start].content.orEmpty().length && + (incomingContent.startsWith(concatenated) || incomingContent.startsWith(spaced)) + ) { + return TextMatch.Collapse(start, end) + } + end += 1 } } - return merged + + val single = (searchFrom until working.size).firstOrNull { index -> + matchesTextType(working[index], incomingType) && + (incomingContent.isEmpty() || prefixRelated(working[index].content.orEmpty(), incomingContent)) + } + if (single == null) return null + + val previousContent = working[single].content.orEmpty() + if (incomingContent.isEmpty() || previousContent.isEmpty()) return TextMatch.Merge(single) + return if (previousContent.length > incomingContent.length && + previousContent.startsWith(incomingContent) && + remainderWillBeConsumed(previousContent.substring(incomingContent.length), incomingItems, incomingIndex) + ) { + TextMatch.Split(single) + } else { + TextMatch.Merge(single) + } } + private fun remainderWillBeConsumed( + remainder: String, + incomingItems: List, + incomingIndex: Int, + ): Boolean { + for (index in incomingIndex + 1 until incomingItems.size) { + val next = incomingItems[index] + if (next.tool != null) continue + if (prefixRelated(remainder, next.content.orEmpty())) return true + } + return false + } + + private fun matchesTextType(item: ChatMessageItemResponse, incomingType: String): Boolean = + isTextLikeItem(item) && item.type.orEmpty().lowercase() == incomingType + + private fun prefixRelated(left: String, right: String): Boolean = + left.isNotEmpty() && right.isNotEmpty() && (left.startsWith(right) || right.startsWith(left)) + + private fun List.deduplicateAdjacentActiveItems(): List = + fold(mutableListOf()) { result, item -> + if (result.lastOrNull()?.let { previous -> + if (previous.tool != null || item.tool != null) { + previous.tool?.id?.isNotEmpty() == true && previous.tool?.id == item.tool?.id + } else { + previous.type.orEmpty().lowercase() == item.type.orEmpty().lowercase() && + previous.content == item.content + } + } != true + ) result += item + result + } + private fun mergeActiveItem( previous: ChatMessageItemResponse, incoming: ChatMessageItemResponse, @@ -445,17 +654,6 @@ public class ChatTimelineStore public constructor() { ) } - private fun sameActiveItem(previous: ChatMessageItemResponse, incoming: ChatMessageItemResponse): Boolean { - if (previous.type.orEmpty().lowercase() != incoming.type.orEmpty().lowercase()) return false - val previousToolId = previous.tool?.id.orEmpty() - val incomingToolId = incoming.tool?.id.orEmpty() - return if (previousToolId.isNotEmpty() || incomingToolId.isNotEmpty()) { - previousToolId == incomingToolId - } else { - true - } - } - private fun isTextLikeItem(item: ChatMessageItemResponse): Boolean = item.type.orEmpty().lowercase() in setOf("text", "message", "thinking") diff --git a/src/apps/mobile/shared/core-domain/src/commonMain/kotlin/com/bitfun/mobile/core/domain/ToolStatusPolicy.kt b/src/apps/mobile/shared/core-domain/src/commonMain/kotlin/com/bitfun/mobile/core/domain/ToolStatusPolicy.kt index 19666d9375..7dcbf76dd6 100644 --- a/src/apps/mobile/shared/core-domain/src/commonMain/kotlin/com/bitfun/mobile/core/domain/ToolStatusPolicy.kt +++ b/src/apps/mobile/shared/core-domain/src/commonMain/kotlin/com/bitfun/mobile/core/domain/ToolStatusPolicy.kt @@ -13,7 +13,7 @@ import kotlinx.serialization.json.jsonPrimitive * * Ported from the predicates at the bottom of `pages/components/ToolStatusList.ets`. * The relay has never narrowed this field to an enum — different agents spell the - * same state `completed` / `done` / `sent`, and confirmation arrives as either + * same state `completed` / `done` / `sent` / `success` (with legacy `finished`), and confirmation arrives as either * `pending_confirmation` or `needs_confirmation` — so the vocabulary is matched * here once instead of at every call site. */ @@ -25,9 +25,23 @@ public object ToolStatusPolicy { public fun isRunning(tool: RemoteToolStatusResponse): Boolean = tool.status.normalized() in RUNNING + /** Whether the tool has completed, including `success` and legacy `finished`. */ public fun isCompleted(tool: RemoteToolStatusResponse): Boolean = tool.status.normalized() in COMPLETED + /** Whether the tool has a status-driven expandable card, regardless of preview content. */ + public fun isExpandable(tool: RemoteToolStatusResponse): Boolean = + isFailed(tool) || isPendingConfirmation(tool) || isQuestion(tool) || isRunning(tool) || + isCompleted(tool) || isCancelled(tool) + + /** + * Whether the tool has content to reveal. This is separate from [isExpandable]: + * expandability is a status-driven typed domain fact, not a preview-driven one, + * for a later Android UI consumer (P3). + */ + public fun hasPreview(tool: RemoteToolStatusResponse): Boolean = + inputText(tool).isNotEmpty() || outputText(tool).isNotEmpty() + public fun isCancelled(tool: RemoteToolStatusResponse): Boolean = tool.status.normalized() in CANCELLED @@ -113,7 +127,7 @@ public object ToolStatusPolicy { private const val OUTPUT_CAP = 480 private val PENDING = setOf("pending_confirmation", "needs_confirmation") private val RUNNING = setOf("running", "active") - private val COMPLETED = setOf("completed", "done", "sent") + private val COMPLETED = setOf("completed", "done", "sent", "success", "finished") private val CANCELLED = setOf("cancelled", "canceled", "rejected") private val FAILED = setOf("failed", "error") private val QUESTION_TERMINAL = setOf("completed", "done") + CANCELLED + FAILED diff --git a/src/apps/mobile/shared/core-domain/src/commonTest/kotlin/com/bitfun/mobile/core/domain/ChatTimelineProjectorTest.kt b/src/apps/mobile/shared/core-domain/src/commonTest/kotlin/com/bitfun/mobile/core/domain/ChatTimelineProjectorTest.kt index d3187d1448..d62e84198f 100644 --- a/src/apps/mobile/shared/core-domain/src/commonTest/kotlin/com/bitfun/mobile/core/domain/ChatTimelineProjectorTest.kt +++ b/src/apps/mobile/shared/core-domain/src/commonTest/kotlin/com/bitfun/mobile/core/domain/ChatTimelineProjectorTest.kt @@ -4,7 +4,6 @@ import com.bitfun.mobile.core.protocol.ImageAttachment import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse -import kotlin.test.assertNotEquals import kotlin.test.assertTrue class ChatTimelineProjectorTest { @@ -109,7 +108,7 @@ class ChatTimelineProjectorTest { } @Test - fun updatesActiveTurnItemIdWhenVisibleContentChanges() { + fun keepsActiveTurnItemIdStableWhenVisibleContentChanges() { val firstItems = ChatTimelineProjector.project( emptyList(), emptyList(), @@ -125,9 +124,8 @@ class ChatTimelineProjectorTest { assertEquals(1, firstItems.size) assertEquals(1, secondItems.size) - assertTrue(firstItems[0].id.startsWith("active-turn-stable-1-")) - assertTrue(secondItems[0].id.startsWith("active-turn-stable-1-")) - assertNotEquals(firstItems[0].id, secondItems[0].id) + assertEquals("active-turn-stable-1", firstItems[0].id) + assertEquals(firstItems[0].id, secondItems[0].id) } @Test @@ -208,6 +206,68 @@ class ChatTimelineProjectorTest { assertFalse(ToolStatusSemantics.shouldKeepPrevious("running", "finished")) } + @Test + fun placesActiveTurnAfterAnchoredOptimisticMessage() { + val items = ChatTimelineProjector.project( + messages = listOf(message("persisted-user", "user", "First")), + pendingMessages = listOf(message("pending-local-1", "user", "Second")), + activeTurn = activeMessage("turn-1", "Reply", "active", 1), + hasMoreMessages = false, + activeTurnAnchorId = "pending-local-1", + ) + + assertEquals( + listOf( + ChatTimelineItemType.USER_MESSAGE, + ChatTimelineItemType.OPTIMISTIC_USER_MESSAGE, + ChatTimelineItemType.ASSISTANT_LIVE_TURN, + ), + items.map { it.type }, + ) + } + + @Test + fun placesActiveTurnBeforePendingWhenAnchorIsMissingOrStale() { + val items = ChatTimelineProjector.project( + messages = listOf(message("persisted-user", "user", "First")), + pendingMessages = listOf(message("pending-local-2", "user", "Second")), + activeTurn = activeMessage("turn-1", "Reply", "active", 1), + hasMoreMessages = false, + activeTurnAnchorId = "stale-local-id", + ) + + assertEquals( + listOf("message-persisted-user", "active-turn-1", "pending-pending-local-2"), + items.map { it.id }, + ) + } + + @Test + fun staleAnchorFallsBackWithoutCrashing() { + val items = ChatTimelineProjector.project( + messages = emptyList(), + pendingMessages = listOf(message("pending-local-3", "user", "Second")), + activeTurn = activeMessage("turn-1", "Reply", "active", 1), + hasMoreMessages = false, + activeTurnAnchorId = "not-present", + ) + + assertEquals(listOf("active-turn-1", "pending-pending-local-3"), items.map { it.id }) + } + + @Test + fun activeRowIdRemainsStableWhenInterleavedAfterAnchor() { + val items = ChatTimelineProjector.project( + messages = listOf(message("persisted-user", "user", "First")), + pendingMessages = listOf(message("pending-local-1", "user", "Second")), + activeTurn = activeMessage("turn-1", "Reply", "active", 2), + hasMoreMessages = false, + activeTurnAnchorId = "pending-local-1", + ) + + assertEquals("active-turn-1", items.single { it.type == ChatTimelineItemType.ASSISTANT_LIVE_TURN }.id) + } + @Test fun revisionChangesOnlyWhenProjectedContentChanges() { val tracker = ChatTimelineRevisionTracker() diff --git a/src/apps/mobile/shared/core-domain/src/commonTest/kotlin/com/bitfun/mobile/core/domain/ChatTimelineStoreTest.kt b/src/apps/mobile/shared/core-domain/src/commonTest/kotlin/com/bitfun/mobile/core/domain/ChatTimelineStoreTest.kt index b3817d6548..2342ac848b 100644 --- a/src/apps/mobile/shared/core-domain/src/commonTest/kotlin/com/bitfun/mobile/core/domain/ChatTimelineStoreTest.kt +++ b/src/apps/mobile/shared/core-domain/src/commonTest/kotlin/com/bitfun/mobile/core/domain/ChatTimelineStoreTest.kt @@ -23,10 +23,16 @@ class ChatTimelineStoreTest { assertEquals("active-turn-1", state.activeTurn?.id) assertEquals(ChatSyncPhase.FINALIZING, state.syncPhase) - store.mergePersistedMessages(listOf(message("assistant-final-1", "assistant", "Final text"))) + val final = message("assistant-final-1", "assistant", "Final text") + store.mergePersistedMessages(listOf(final)) state = store.snapshot() assertEquals(2, state.persistedMessages.size) assertEquals(null, state.activeTurn) + assertEquals(listOf("message-remote-user-1", "message-assistant-final-1"), store.project(false).map { it.id }) + + store.mergePersistedMessages(listOf(final)) + assertEquals(2, store.snapshot().persistedMessages.size) + assertEquals(listOf("message-remote-user-1", "message-assistant-final-1"), store.project(false).map { it.id }) } @Test @@ -133,6 +139,370 @@ class ChatTimelineStoreTest { assertEquals("abcdef", store.snapshot().activeTurn?.items?.firstOrNull()?.content) } + @Test + fun exactContentMatchRetainsEarlierPreviousItemWithoutDuplicate() { + val store = ChatTimelineStore() + store.reset("session-merge-r1a") + store.setActiveTurn(activeMessage("turn-r1a", "").copy(items = listOf( + ChatMessageItemResponse(type = "text", content = "Hello"), + ChatMessageItemResponse(type = "text", content = "Hello world"), + ))) + store.setActiveTurn(activeMessage("turn-r1a", "", "active", 1).copy(items = listOf( + ChatMessageItemResponse(type = "text", content = "Hello world"), + ))) + + assertEquals(listOf("Hello", "Hello world"), store.snapshot().activeTurn?.items.orEmpty().map { it.content }) + } + + @Test + fun concatenatedAdjacentTextItemsAreCollapsedByIncomingPrefix() { + val store = ChatTimelineStore() + store.reset("session-merge-r1b") + store.setActiveTurn(activeMessage("turn-r1b", "").copy(items = listOf( + ChatMessageItemResponse(type = "text", content = "Hello"), + ChatMessageItemResponse(type = "text", content = "World"), + ))) + store.setActiveTurn(activeMessage("turn-r1b", "", "active", 1).copy(items = listOf( + ChatMessageItemResponse(type = "text", content = "Hello World"), + ))) + + assertEquals(listOf("Hello World"), store.snapshot().activeTurn?.items.orEmpty().map { it.content }) + } + + @Test + fun partialPrefixTextSnapshotKeepsTrailingTextBlock() { + val store = ChatTimelineStore() + store.reset("session-merge-r1f") + store.setActiveTurn(activeMessage("turn-r1f", "").copy(items = listOf( + ChatMessageItemResponse(type = "text", content = "Hello"), + ChatMessageItemResponse(type = "text", content = "World"), + ))) + store.setActiveTurn(activeMessage("turn-r1f", "", "active", 1).copy(items = listOf( + ChatMessageItemResponse(type = "text", content = "Hello W"), + ))) + + assertEquals(listOf("Hello W", "World"), store.snapshot().activeTurn?.items.orEmpty().map { it.content }) + } + + @Test + fun partialPrefixThinkingSnapshotKeepsTrailingThinkingBlock() { + val store = ChatTimelineStore() + store.reset("session-merge-r1-thinking-partial") + store.setActiveTurn(activeMessage("turn-r1-thinking-partial", "").copy(items = listOf( + ChatMessageItemResponse(type = "thinking", content = "Hello"), + ChatMessageItemResponse(type = "thinking", content = "World"), + ))) + store.setActiveTurn(activeMessage("turn-r1-thinking-partial", "", "active", 1).copy(items = listOf( + ChatMessageItemResponse(type = "thinking", content = "Hello W"), + ))) + + assertEquals(listOf("Hello W", "World"), store.snapshot().activeTurn?.items.orEmpty().map { it.content }) + } + + @Test + fun partialPrefixWithInterleavedToolKeepsToolAndTrailingBlock() { + val store = ChatTimelineStore() + store.reset("session-merge-r1-tool-partial") + store.setActiveTurn(activeMessage("turn-r1-tool-partial", "").copy(items = listOf( + ChatMessageItemResponse(type = "text", content = "Hello"), + ChatMessageItemResponse(type = "text", content = "World"), + ChatMessageItemResponse( + type = "tool", + tool = RemoteToolStatusResponse(id = "tool-r1-partial", name = "read_file", status = "running"), + ), + ))) + store.setActiveTurn(activeMessage("turn-r1-tool-partial", "", "active", 1).copy(items = listOf( + ChatMessageItemResponse(type = "text", content = "Hello W"), + ))) + + assertEquals( + listOf("Hello W", "World", "tool-r1-partial"), + store.snapshot().activeTurn?.items.orEmpty().map { it.content ?: it.tool?.id }, + ) + } + + @Test + fun incomingCoveringFullSpacedJoinCollapsesWithoutDuplicate() { + val store = ChatTimelineStore() + store.reset("session-merge-r1-spaced-full") + store.setActiveTurn(activeMessage("turn-r1-spaced-full", "").copy(items = listOf( + ChatMessageItemResponse(type = "text", content = "Hello"), + ChatMessageItemResponse(type = "text", content = "World"), + ))) + store.setActiveTurn(activeMessage("turn-r1-spaced-full", "", "active", 1).copy(items = listOf( + ChatMessageItemResponse(type = "text", content = "Hello World!"), + ))) + + assertEquals(listOf("Hello World!"), store.snapshot().activeTurn?.items.orEmpty().map { it.content }) + } + + @Test + fun thinkingCoveringFullSpacedJoinCollapsesWithoutDuplicate() { + val store = ChatTimelineStore() + store.reset("session-merge-r1-thinking-full") + store.setActiveTurn(activeMessage("turn-r1-thinking-full", "").copy(items = listOf( + ChatMessageItemResponse(type = "thinking", content = "Hello"), + ChatMessageItemResponse(type = "thinking", content = "World"), + ))) + store.setActiveTurn(activeMessage("turn-r1-thinking-full", "", "active", 1).copy(items = listOf( + ChatMessageItemResponse(type = "thinking", content = "Hello World!"), + ))) + + assertEquals(listOf("Hello World!"), store.snapshot().activeTurn?.items.orEmpty().map { it.content }) + } + + @Test + fun incomingCoveringFullCompactJoinCollapsesWithoutDuplicate() { + val store = ChatTimelineStore() + store.reset("session-merge-r1-compact-full") + store.setActiveTurn(activeMessage("turn-r1-compact-full", "").copy(items = listOf( + ChatMessageItemResponse(type = "text", content = "Hello"), + ChatMessageItemResponse(type = "text", content = "World"), + ))) + store.setActiveTurn(activeMessage("turn-r1-compact-full", "", "active", 1).copy(items = listOf( + ChatMessageItemResponse(type = "text", content = "HelloWorld!"), + ))) + + assertEquals(listOf("HelloWorld!"), store.snapshot().activeTurn?.items.orEmpty().map { it.content }) + } + + @Test + fun singleTextBlockSplitAcrossIncomingItemsDoesNotDuplicate() { + val store = ChatTimelineStore() + store.reset("session-merge-r1c") + store.setActiveTurn(activeMessage("turn-r1c", "").copy(items = listOf( + ChatMessageItemResponse(type = "text", content = "abcdef"), + ))) + store.setActiveTurn(activeMessage("turn-r1c", "", "active", 1).copy(items = listOf( + ChatMessageItemResponse(type = "text", content = "abc"), + ChatMessageItemResponse(type = "text", content = "def"), + ))) + + assertEquals(listOf("abc", "def"), store.snapshot().activeTurn?.items.orEmpty().map { it.content }) + } + + @Test + fun thinkingBlockSplitAcrossIncomingItemsDoesNotDuplicate() { + val store = ChatTimelineStore() + store.reset("session-merge-r1-thinking") + store.setActiveTurn(activeMessage("turn-r1-thinking", "").copy(items = listOf( + ChatMessageItemResponse(type = "thinking", content = "abcdef"), + ))) + store.setActiveTurn(activeMessage("turn-r1-thinking", "", "active", 1).copy(items = listOf( + ChatMessageItemResponse(type = "thinking", content = "abc"), + ChatMessageItemResponse(type = "thinking", content = "def"), + ))) + + assertEquals(listOf("abc", "def"), store.snapshot().activeTurn?.items.orEmpty().map { it.content }) + } + + @Test + fun splitAroundNewToolKeepsTextAndToolOrder() { + val store = ChatTimelineStore() + store.reset("session-merge-r2c") + store.setActiveTurn(activeMessage("turn-r2c", "").copy(items = listOf( + ChatMessageItemResponse(type = "text", content = "abcdef"), + ))) + store.setActiveTurn(activeMessage("turn-r2c", "", "active", 1).copy(items = listOf( + ChatMessageItemResponse(type = "text", content = "abc"), + ChatMessageItemResponse( + type = "tool", + tool = RemoteToolStatusResponse(id = "tool-r2c", name = "read_file", status = "running"), + ), + ChatMessageItemResponse(type = "text", content = "def"), + ))) + + assertEquals( + listOf("abc", "tool-r2c", "def"), + store.snapshot().activeTurn?.items.orEmpty().map { it.content ?: it.tool?.id }, + ) + } + + @Test + fun boundaryShiftBetweenAdjacentBlocksKeepsTotalText() { + val store = ChatTimelineStore() + store.reset("session-merge-r1d") + store.setActiveTurn(activeMessage("turn-r1d", "").copy(items = listOf( + ChatMessageItemResponse(type = "text", content = "ab"), + ChatMessageItemResponse(type = "text", content = "c"), + ))) + store.setActiveTurn(activeMessage("turn-r1d", "", "active", 1).copy(items = listOf( + ChatMessageItemResponse(type = "text", content = "a"), + ChatMessageItemResponse(type = "text", content = "bc"), + ))) + + assertEquals(listOf("a", "bc"), store.snapshot().activeTurn?.items.orEmpty().map { it.content }) + } + + @Test + fun shorterPrefixSnapshotDoesNotSwallowAdjacentBlocks() { + val store = ChatTimelineStore() + store.reset("session-merge-r1e") + store.setActiveTurn(activeMessage("turn-r1e", "").copy(items = listOf( + ChatMessageItemResponse(type = "text", content = "Hello"), + ChatMessageItemResponse(type = "text", content = "World"), + ))) + store.setActiveTurn(activeMessage("turn-r1e", "", "active", 1).copy(items = listOf( + ChatMessageItemResponse(type = "text", content = "Hell"), + ))) + + assertEquals(listOf("Hello", "World"), store.snapshot().activeTurn?.items.orEmpty().map { it.content }) + } + + @Test + fun newToolAnchorsBeforeItsNextMatchedPreviousItem() { + val store = ChatTimelineStore() + store.reset("session-merge-r2") + store.setActiveTurn(activeMessage("turn-r2", "").copy(items = listOf( + ChatMessageItemResponse(type = "text", content = "text A"), + ChatMessageItemResponse(type = "text", content = "text B"), + ))) + store.setActiveTurn(activeMessage("turn-r2", "", "active", 1).copy(items = listOf( + ChatMessageItemResponse( + type = "tool", + tool = RemoteToolStatusResponse(id = "tool-r2", name = "read_file", status = "running"), + ), + ChatMessageItemResponse(type = "text", content = "text B'"), + ))) + + assertEquals( + listOf("text A", "tool-r2", "text B'"), + store.snapshot().activeTurn?.items.orEmpty().map { it.content ?: it.tool?.id }, + ) + } + + @Test + fun continuousDeltasKeepStableProjectedRowAndMonotonicText() { + val store = ChatTimelineStore() + store.reset("session-1") + store.applyEvent(ConversationEvent.AssistantDelta("session-1", "turn-delta-1", "Hello")) + val first = store.project(false) + store.applyEvent(ConversationEvent.AssistantDelta("session-1", "turn-delta-1", " world")) + val second = store.project(false) + + assertEquals("Hello world", store.snapshot().activeTurn?.text) + assertEquals("active-turn-delta-1", first.single().id) + assertEquals(first.single().id, second.single().id) + } + + @Test + fun interleavedToolSnapshotDoesNotDuplicateTextItems() { + val store = ChatTimelineStore() + store.reset("session-1") + val first = activeMessage("turn-interleaved-1", "").copy( + items = listOf( + ChatMessageItemResponse(type = "text", content = "text A"), + ChatMessageItemResponse(type = "text", content = "text B"), + ), + ) + val second = activeMessage("turn-interleaved-1", "", "active", 2).copy( + items = listOf( + ChatMessageItemResponse(type = "text", content = "text A'"), + ChatMessageItemResponse( + type = "tool", + tool = RemoteToolStatusResponse(id = "tool-interleaved-1", name = "read_file", status = "completed"), + ), + ChatMessageItemResponse(type = "text", content = "text B'"), + ), + ) + + store.setActiveTurn(first) + store.setActiveTurn(second) + + assertEquals( + listOf("text A'", "tool-interleaved-1", "text B'"), + store.snapshot().activeTurn?.items.orEmpty().map { it.content ?: it.tool?.id }, + ) + } + + @Test + fun omittedToolKeepsItsOriginalServerOrderSlot() { + val store = ChatTimelineStore() + store.reset("session-1") + store.setActiveTurn(activeMessage("turn-omitted-tool-1", "").copy( + items = listOf( + ChatMessageItemResponse(type = "text", content = "text A"), + ChatMessageItemResponse( + type = "tool", + tool = RemoteToolStatusResponse(id = "tool-omitted-1", name = "read_file", status = "completed"), + ), + ChatMessageItemResponse(type = "text", content = "text B"), + ), + )) + store.setActiveTurn(activeMessage("turn-omitted-tool-1", "", "active", 1).copy( + items = listOf( + ChatMessageItemResponse(type = "text", content = "text A'"), + ChatMessageItemResponse(type = "text", content = "text B'"), + ), + )) + + assertEquals( + listOf("text A'", "tool-omitted-1", "text B'"), + store.snapshot().activeTurn?.items.orEmpty().map { it.content ?: it.tool?.id }, + ) + } + + @Test + fun newToolWithOmittedOldTextKeepsOldBlockAndDoesNotJumpIt() { + val store = ChatTimelineStore() + store.reset("session-merge-r2d") + store.setActiveTurn(activeMessage("turn-r2d", "").copy(items = listOf( + ChatMessageItemResponse(type = "text", content = "text A"), + ChatMessageItemResponse(type = "text", content = "text B"), + ChatMessageItemResponse(type = "text", content = "text C"), + ))) + store.setActiveTurn(activeMessage("turn-r2d", "", "active", 1).copy(items = listOf( + ChatMessageItemResponse(type = "text", content = "text A'"), + ChatMessageItemResponse( + type = "tool", + tool = RemoteToolStatusResponse(id = "tool-r2d", name = "read_file", status = "running"), + ), + ChatMessageItemResponse(type = "text", content = "text C'"), + ))) + + assertEquals( + listOf("text A'", "text B", "tool-r2d", "text C'"), + store.snapshot().activeTurn?.items.orEmpty().map { it.content ?: it.tool?.id }, + ) + } + + @Test + fun adjacentSameTypeBlocksUseContentAffinityForPartialSnapshots() { + val store = ChatTimelineStore() + store.reset("session-1") + store.setActiveTurn(activeMessage("turn-adjacent-1", "").copy( + items = listOf( + ChatMessageItemResponse(type = "text", content = "text A"), + ChatMessageItemResponse(type = "text", content = "text B"), + ), + )) + store.setActiveTurn(activeMessage("turn-adjacent-1", "", "active", 1).copy( + items = listOf( + ChatMessageItemResponse(type = "text", content = "text A'"), + ChatMessageItemResponse(type = "text", content = "text C"), + ), + )) + + assertEquals( + listOf("text A'", "text B", "text C"), + store.snapshot().activeTurn?.items.orEmpty().map { it.content }, + ) + } + + @Test + fun staleStructuredSnapshotCannotRegressNewerText() { + val store = ChatTimelineStore() + store.reset("session-1") + store.setActiveTurn(activeMessage("turn-order-1", "", "active", 2).copy( + items = listOf(ChatMessageItemResponse(type = "text", content = "newer text")), + )) + store.setActiveTurn(activeMessage("turn-order-1", "", "active", 1).copy( + items = listOf(ChatMessageItemResponse(type = "text", content = "stale")), + )) + + assertEquals("newer text", store.snapshot().activeTurn?.items?.single()?.content) + } + @Test fun updatesToolStatusFromLatestStructuredSnapshot() { val store = ChatTimelineStore() @@ -194,6 +564,69 @@ class ChatTimelineStoreTest { assertEquals(null, store.snapshot().activeTurn) } + @Test + fun keepsFirstReplyUnderTheMessageThatStartedIt() { + val store = ChatTimelineStore() + store.reset("session-anchor") + store.appendOptimisticMessage(message("local-1", "user", "Question")) + store.setPendingActiveTurn("local-1") + + assertEquals("local-1", store.activeTurnAnchorId()) + assertEquals( + listOf( + ChatTimelineItemType.OPTIMISTIC_USER_MESSAGE, + ChatTimelineItemType.ASSISTANT_LIVE_TURN, + ), + store.project(false).map { it.type }, + ) + + store.setLocalActiveTurn("turn-1") + assertEquals("local-1", store.activeTurnAnchorId()) + assertEquals( + listOf("pending-local-1", "active-turn-1"), + store.project(false).map { it.id }, + ) + } + + @Test + fun keepsMessageSentMidRunBelowTheReply() { + val store = ChatTimelineStore() + store.reset("session-mid-run") + store.mergePersistedMessages(listOf(message("user-1", "user", "First"))) + store.setLocalActiveTurn("turn-1") + store.appendOptimisticMessage(message("local-2", "user", "Second")) + + assertEquals( + listOf("message-user-1", "active-turn-1", "pending-local-2"), + store.project(false).map { it.id }, + ) + } + + @Test + fun clearsAnchorWhenActiveTurnIsPersisted() { + val store = ChatTimelineStore() + store.reset("session-handoff") + store.appendOptimisticMessage(message("local-1", "user", "Question")) + store.setPendingActiveTurn("local-1") + store.setLocalActiveTurn("turn-1") + + store.applyEvent(ConversationEvent.AssistantMessage(message("turn-1_assistant", "assistant", "Reply"))) + + assertEquals("", store.activeTurnAnchorId()) + assertNull(store.snapshot().activeTurn) + } + + @Test + fun rendersActiveTurnWithMissingAnchor() { + val store = ChatTimelineStore() + store.reset("session-no-anchor") + store.mergePersistedMessages(listOf(message("user-1", "user", "First"))) + store.setLocalActiveTurn("turn-1") + + assertEquals("", store.activeTurnAnchorId()) + assertEquals("active-turn-1", store.project(false).single { it.type == ChatTimelineItemType.ASSISTANT_LIVE_TURN }.id) + } + @Test fun filtersSeedMessagesWhenSettingPersistedHistory() { val store = ChatTimelineStore() diff --git a/src/apps/mobile/shared/core-domain/src/commonTest/kotlin/com/bitfun/mobile/core/domain/ToolStatusPolicyTest.kt b/src/apps/mobile/shared/core-domain/src/commonTest/kotlin/com/bitfun/mobile/core/domain/ToolStatusPolicyTest.kt index 3a0c9ff4ba..da367848ec 100644 --- a/src/apps/mobile/shared/core-domain/src/commonTest/kotlin/com/bitfun/mobile/core/domain/ToolStatusPolicyTest.kt +++ b/src/apps/mobile/shared/core-domain/src/commonTest/kotlin/com/bitfun/mobile/core/domain/ToolStatusPolicyTest.kt @@ -16,10 +16,36 @@ class ToolStatusPolicyTest { } @Test - fun allThreeSpellingsOfFinishedCount() { - listOf("completed", "done", "sent").forEach { status -> + fun completedVocabularyIncludesSuccessAndLegacyFinishedCaseInsensitively() { + listOf("completed", "done", "sent", "success", "finished", "Success", "SUCCESS").forEach { status -> assertTrue(ToolStatusPolicy.isCompleted(tool(status = status)), status) } + listOf("queued", "unknown").forEach { status -> + assertFalse(ToolStatusPolicy.isCompleted(tool(status = status)), status) + } + } + + @Test + fun emptyPreviewToolsAreExpandableOnlyForKnownActionableStates() { + listOf( + "completed", + "cancelled", + "error", + "failed", + "running", + "pending_confirmation", + ).forEach { status -> + assertTrue(ToolStatusPolicy.isExpandable(tool(status = status)), status) + } + assertTrue(ToolStatusPolicy.isExpandable(tool(name = "AskUserQuestion", status = "sent"))) + assertFalse(ToolStatusPolicy.isExpandable(tool(status = "unknown"))) + } + + @Test + fun previewFactIsIndependentFromExpandability() { + assertFalse(ToolStatusPolicy.hasPreview(tool())) + assertTrue(ToolStatusPolicy.hasPreview(tool(inputPreview = "input"))) + assertTrue(ToolStatusPolicy.hasPreview(tool(resultPreview = "output"))) } @Test diff --git a/src/apps/mobile/shared/core-feature/src/androidMain/kotlin/com/bitfun/mobile/core/feature/account/AccountStore.android.kt b/src/apps/mobile/shared/core-feature/src/androidMain/kotlin/com/bitfun/mobile/core/feature/account/AccountStore.android.kt index bda3647ef6..a019c9a239 100644 --- a/src/apps/mobile/shared/core-feature/src/androidMain/kotlin/com/bitfun/mobile/core/feature/account/AccountStore.android.kt +++ b/src/apps/mobile/shared/core-feature/src/androidMain/kotlin/com/bitfun/mobile/core/feature/account/AccountStore.android.kt @@ -2,6 +2,7 @@ package com.bitfun.mobile.core.feature.account import android.content.Context import com.bitfun.mobile.core.feature.CoreLog +import com.bitfun.mobile.core.persistence.androidPersistenceStores import com.bitfun.mobile.core.persistence.androidSecureStore import kotlinx.coroutines.CoroutineScope @@ -16,10 +17,14 @@ public fun AccountStore.Companion.create( deviceName: String, log: CoreLog, legacyMobileDeviceNames: Set, -): AccountStore = AccountStore.create( - scope = scope, - backend = AccountStore.backend(log, legacyMobileDeviceNames), - secureStore = androidSecureStore(context.applicationContext, "cloud_account"), - deviceId = deviceId, - deviceName = deviceName, -) +): AccountStore { + val persistence = androidPersistenceStores(context.applicationContext, "bitfun-mobile.db") + return AccountStore.create( + scope = scope, + backend = AccountStore.backend(log, legacyMobileDeviceNames), + secureStore = androidSecureStore(context.applicationContext, "cloud_account"), + deviceId = deviceId, + deviceName = deviceName, + persistence = persistence, + ) +} diff --git a/src/apps/mobile/shared/core-feature/src/androidMain/kotlin/com/bitfun/mobile/core/feature/pairing/PairingStore.android.kt b/src/apps/mobile/shared/core-feature/src/androidMain/kotlin/com/bitfun/mobile/core/feature/pairing/PairingStore.android.kt index 9614972748..dc05ead4f0 100644 --- a/src/apps/mobile/shared/core-feature/src/androidMain/kotlin/com/bitfun/mobile/core/feature/pairing/PairingStore.android.kt +++ b/src/apps/mobile/shared/core-feature/src/androidMain/kotlin/com/bitfun/mobile/core/feature/pairing/PairingStore.android.kt @@ -2,6 +2,7 @@ package com.bitfun.mobile.core.feature.pairing import android.content.Context import com.bitfun.mobile.core.feature.CoreLog +import com.bitfun.mobile.core.persistence.androidPersistenceStores import com.bitfun.mobile.core.persistence.androidSecureStore import kotlinx.coroutines.CoroutineScope @@ -17,9 +18,13 @@ public fun PairingStore.Companion.create( context: Context, device: DeviceIdentity, log: CoreLog, -): PairingStore = PairingStore.create( - scope = scope, - device = device, - protection = androidSecureStore(context.applicationContext, "pairing_protection"), - log = log, -) +): PairingStore { + val persistence = androidPersistenceStores(context.applicationContext, "bitfun-mobile.db") + return PairingStore.create( + scope = scope, + device = device, + protection = androidSecureStore(context.applicationContext, "pairing_protection"), + log = log, + persistence = persistence, + ) +} diff --git a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/bitfun/mobile/core/feature/account/AccountStore.kt b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/bitfun/mobile/core/feature/account/AccountStore.kt index 6d2a11fc49..7d1ac93b89 100644 --- a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/bitfun/mobile/core/feature/account/AccountStore.kt +++ b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/bitfun/mobile/core/feature/account/AccountStore.kt @@ -5,6 +5,7 @@ import com.bitfun.mobile.core.feature.CoreLog import com.bitfun.mobile.core.feature.pairing.asTransportLog import com.bitfun.mobile.core.feature.session.RemoteSessionStore import com.bitfun.mobile.core.feature.workspace.RemoteWorkspaceStore +import com.bitfun.mobile.core.persistence.MobilePersistenceStores import com.bitfun.mobile.core.persistence.SecureStore import com.bitfun.mobile.core.transport.AccountDeviceCommandTransport import com.bitfun.mobile.core.transport.CloudAccountClient @@ -60,6 +61,7 @@ public class AccountStore internal constructor( private val secureStore: SecureStore, private val deviceId: String, private val deviceName: String, + private val persistence: MobilePersistenceStores? = null, ) { private val _state = MutableStateFlow(AccountUiState.Idle) public val state: StateFlow = _state.asStateFlow() @@ -80,7 +82,12 @@ public class AccountStore internal constructor( public fun createSessionStore(scope: CoroutineScope): RemoteSessionStore? { val current = session ?: return null val target = current.targetDeviceId?.takeIf(String::isNotBlank) ?: return null - return RemoteSessionStore.create(scope, backend.transport(current, target)) + return RemoteSessionStore.create( + scope, + backend.transport(current, target), + deviceKey = target, + persistence = persistence, + ) } public fun createWorkspaceStore(scope: CoroutineScope): RemoteWorkspaceStore? { @@ -270,7 +277,8 @@ public class AccountStore internal constructor( secureStore: SecureStore, deviceId: String, deviceName: String, - ): AccountStore = AccountStore(scope, backend, secureStore, deviceId, deviceName) + persistence: MobilePersistenceStores? = null, + ): AccountStore = AccountStore(scope, backend, secureStore, deviceId, deviceName, persistence) internal fun backend(log: CoreLog, legacyMobileDeviceNames: Set): AccountBackend { val transportLog = log.asTransportLog() diff --git a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/bitfun/mobile/core/feature/pairing/PairingStore.kt b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/bitfun/mobile/core/feature/pairing/PairingStore.kt index bf57eb66a5..0c7d48a603 100644 --- a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/bitfun/mobile/core/feature/pairing/PairingStore.kt +++ b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/bitfun/mobile/core/feature/pairing/PairingStore.kt @@ -4,6 +4,7 @@ import com.bitfun.mobile.core.feature.CoreLog import com.bitfun.mobile.core.feature.session.RemoteSessionStore import com.bitfun.mobile.core.feature.session.RemoteSessionUiState import com.bitfun.mobile.core.feature.workspace.RemoteWorkspaceStore +import com.bitfun.mobile.core.persistence.MobilePersistenceStores import com.bitfun.mobile.core.persistence.SecureStore import com.bitfun.mobile.core.protocol.CommandStatusResponse import com.bitfun.mobile.core.protocol.RemoteCommand @@ -43,6 +44,7 @@ public class PairingStore internal constructor( private val pairing: RelayPairing, private val log: CoreLog, private val protection: UserIdProtection, + private val persistence: MobilePersistenceStores? = null, ) { private val _state = MutableStateFlow(PairingUiState.Idle) public val state: StateFlow = _state.asStateFlow() @@ -75,7 +77,14 @@ public class PairingStore internal constructor( /** Builds the session feature without exposing the paired transport to UI. */ public fun createSessionStore(scope: CoroutineScope): RemoteSessionStore? = - room?.let { RemoteSessionStore.create(scope, it).also { store -> sessionStore = store } } + room?.let { + RemoteSessionStore.create( + scope, + it, + deviceKey = it.descriptor.roomId, + persistence = persistence, + ).also { store -> sessionStore = store } + } /** Builds workspace and file-preview features over the same paired transport. */ public fun createWorkspaceStore(scope: CoroutineScope): RemoteWorkspaceStore? = @@ -307,12 +316,14 @@ public class PairingStore internal constructor( device: DeviceIdentity, protection: SecureStore, log: CoreLog, + persistence: MobilePersistenceStores?, ): PairingStore = PairingStore( scope = scope, device = device, pairing = relayPairing(log.asTransportLog()), log = log, protection = UserIdProtection(protection), + persistence = persistence, ) /** @@ -325,7 +336,7 @@ public class PairingStore internal constructor( scope: CoroutineScope, device: DeviceIdentity, protection: SecureStore, - ): PairingStore = create(scope, device, protection, CoreLog.None) + ): PairingStore = create(scope, device, protection, CoreLog.None, null) } } diff --git a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/bitfun/mobile/core/feature/session/ConversationPresentation.kt b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/bitfun/mobile/core/feature/session/ConversationPresentation.kt index 6719ac141c..5733d68339 100644 --- a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/bitfun/mobile/core/feature/session/ConversationPresentation.kt +++ b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/bitfun/mobile/core/feature/session/ConversationPresentation.kt @@ -84,6 +84,7 @@ public data class ToolCard public constructor( public val question: String?, public val questions: List, public val actions: Set, + public val expandable: Boolean, ) { public constructor( id: String, @@ -112,6 +113,7 @@ public data class ToolCard public constructor( question, emptyList(), actions, + expandable = phase != ToolPhase.WAITING || ToolAction.ANSWER in actions, ) } @@ -166,6 +168,7 @@ public fun ChatTimelineState.conversationRows(): List = // The app pages the session list, not the message list, so an empty // timeline here really is an empty session. false, + activeTurnAnchorId, ).map { item -> val message = item.message ConversationRow( @@ -297,6 +300,7 @@ internal fun toolCard(tool: RemoteToolStatusResponse): ToolCard { question = question, questions = questions, actions = actions, + expandable = ToolStatusPolicy.isExpandable(tool), ) } diff --git a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/bitfun/mobile/core/feature/session/ModelCatalogContract.kt b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/bitfun/mobile/core/feature/session/ModelCatalogContract.kt new file mode 100644 index 0000000000..4282e7c7e2 --- /dev/null +++ b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/bitfun/mobile/core/feature/session/ModelCatalogContract.kt @@ -0,0 +1,19 @@ +package com.bitfun.mobile.core.feature.session + +/** Whether the peer supports the model-catalog command known by this client. */ +public enum class ModelCatalogSupport { + SUPPORTED, + UNSUPPORTED_BY_PEER, +} + +/** + * Static model-catalog contract fact. SUPPORTED means this client knows + * `get_model_catalog` and decodes its catalog tolerantly. UNSUPPORTED_BY_PEER is + * reserved forward-compat for a real peer-capability signal; it is not derived + * from a generic command rejection or malformed response, which a modern + * desktop or a local protocol fault can also produce. + */ +public object ModelCatalogContract { + public val commandName: String = "get_model_catalog" + public val support: ModelCatalogSupport = ModelCatalogSupport.SUPPORTED +} diff --git a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/bitfun/mobile/core/feature/session/RemoteSessionFailureMapping.kt b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/bitfun/mobile/core/feature/session/RemoteSessionFailureMapping.kt index 79a53f8874..0172b51fc9 100644 --- a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/bitfun/mobile/core/feature/session/RemoteSessionFailureMapping.kt +++ b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/bitfun/mobile/core/feature/session/RemoteSessionFailureMapping.kt @@ -26,6 +26,10 @@ internal fun remoteSessionFailure(error: Throwable): RemoteSessionUiState.Failed RelayFailure.NetworkUnreachable -> RemoteSessionUiState.Failed(RemoteSessionFailureReason.NETWORK) RelayFailure.RateLimited -> RemoteSessionUiState.Failed(RemoteSessionFailureReason.RATE_LIMITED) RelayFailure.MalformedResponse -> RemoteSessionUiState.Failed(RemoteSessionFailureReason.PROTOCOL_MISMATCH) - else -> RemoteSessionUiState.Failed(RemoteSessionFailureReason.TRANSPORT) + RelayFailure.PairRejected, + RelayFailure.RoomNotFound, + is RelayFailure.RelayUnavailable, + is RelayFailure.UnexpectedStatus, + -> RemoteSessionUiState.Failed(RemoteSessionFailureReason.TRANSPORT) } } diff --git a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/bitfun/mobile/core/feature/session/RemoteSessionStore.kt b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/bitfun/mobile/core/feature/session/RemoteSessionStore.kt index a039e6bb92..1556f47bde 100644 --- a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/bitfun/mobile/core/feature/session/RemoteSessionStore.kt +++ b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/bitfun/mobile/core/feature/session/RemoteSessionStore.kt @@ -8,6 +8,10 @@ import com.bitfun.mobile.core.domain.ChatSessionPoller import com.bitfun.mobile.core.domain.ChatSessionSnapshot import com.bitfun.mobile.core.domain.ChatTimelineStore import com.bitfun.mobile.core.domain.PollSessionResult +import com.bitfun.mobile.core.persistence.MobilePersistenceStores +import com.bitfun.mobile.core.persistence.PersistedRemoteCursor +import com.bitfun.mobile.core.persistence.PersistedRemoteMessage +import com.bitfun.mobile.core.persistence.PersistedRemoteSession import com.bitfun.mobile.core.domain.RemoteSession import com.bitfun.mobile.core.domain.SessionNaming import com.bitfun.mobile.core.domain.SessionAgentTypes @@ -17,7 +21,9 @@ import com.bitfun.mobile.core.protocol.ChatMessageItemResponse import com.bitfun.mobile.core.protocol.ChatMessageResponse import com.bitfun.mobile.core.protocol.CreateSessionResponse import com.bitfun.mobile.core.protocol.InitialSyncResponse +import com.bitfun.mobile.core.protocol.ImageAttachment import com.bitfun.mobile.core.protocol.ModelCatalogResponse +import com.bitfun.mobile.core.protocol.RemoteToolStatusResponse import com.bitfun.mobile.core.protocol.PollSessionResponse import com.bitfun.mobile.core.protocol.RemoteCommand import com.bitfun.mobile.core.protocol.RemotePermissionMode @@ -42,6 +48,9 @@ import kotlinx.coroutines.launch import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json import kotlinx.serialization.json.put import kotlin.time.Clock @@ -49,7 +58,10 @@ import kotlin.time.Clock public class RemoteSessionStore internal constructor( private val scope: CoroutineScope, private val transport: RemoteCommandTransport, + private val deviceKey: String? = null, + private val persistence: MobilePersistenceStores? = null, ) { + private val persistenceEnabled: Boolean get() = persistence != null && !deviceKey.isNullOrBlank() private val _state = MutableStateFlow(RemoteSessionUiState.Idle) public val state: StateFlow = _state.asStateFlow() private val _connectionPhase = MutableStateFlow(ConnectionPhase.IDLE) @@ -58,6 +70,7 @@ public class RemoteSessionStore internal constructor( private val controller = ChatSessionController.create(scope, RoomPoller(transport), ControllerCallbacks()) private var work: Job? = null private var modelCatalog: RemoteModelCatalog? = null + private var modelCatalogFailure: ModelCatalogFailure? = null private val locallyCreatedSessions: MutableMap = mutableMapOf() /** @@ -123,9 +136,10 @@ public class RemoteSessionStore internal constructor( }, ), ) + is RemoteSessionIntent.UpdateDraft -> updateDraft(intent.text) is RemoteSessionIntent.SendMessage -> sendMessage(intent) is RemoteSessionIntent.CancelTurn -> cancelTurn(intent) - is RemoteSessionIntent.ApproveTool -> runAction(intent.sessionId, RemoteCommand(cmd = "confirm_tool", toolId = intent.toolId)) + is RemoteSessionIntent.ApproveTool -> approveTool(intent) is RemoteSessionIntent.RejectTool -> runAction( intent.sessionId, RemoteCommand(cmd = "reject_tool", toolId = intent.toolId, reason = intent.reason), @@ -136,6 +150,7 @@ public class RemoteSessionStore internal constructor( ) is RemoteSessionIntent.SetPermissionMode -> setPermissionMode(intent) is RemoteSessionIntent.RefreshPermissionMode -> refreshPermissionMode() + RemoteSessionIntent.RefreshModelCatalog -> refreshModelCatalog() is RemoteSessionIntent.SelectModel -> selectModel(intent) RemoteSessionIntent.Stop -> stop() } @@ -153,11 +168,23 @@ public class RemoteSessionStore internal constructor( private fun load(query: String, filter: SessionAgentFilter) { if (_state.value is RemoteSessionUiState.Loading) return val current = _state.value as? RemoteSessionUiState.Ready + if (current == null && persistenceEnabled) { + val cached = persistence!!.remoteSessions.load(deviceKey!!) + if (cached.isNotEmpty()) { + _state.value = RemoteSessionUiState.Ready( + sessions = cached.map(::toRemoteSession), selectedSessionId = null, timeline = null, + busy = true, permissionMode = null, permissionModeFailure = null, + query = query, agentFilter = filter, + hasMore = persistence.remoteSessions.hasMore(deviceKey), hasMoreMessages = false, + modelCatalog = null, + ) + } + } if (current == null) _connectionPhase.value = ConnectionPhase.CONNECTING work?.cancel() // Searching or switching tabs keeps the list on screen; only a cold start // blanks it, so typing in the search box does not flash a spinner. - _state.value = current?.copy(busy = true, query = query, agentFilter = filter) + _state.value = (_state.value as? RemoteSessionUiState.Ready)?.copy(busy = true, query = query, agentFilter = filter) ?: RemoteSessionUiState.Loading work = scope.launch { try { @@ -166,7 +193,10 @@ public class RemoteSessionStore internal constructor( return@launch } val page = listSessions(0, query, filter) - val catalog = loadModelCatalogIfNeeded() + val catalog = loadModelCatalog(force = false) + if (persistenceEnabled && query.isEmpty() && filter == SessionAgentFilter.ALL) { + persistence!!.remoteSessions.save(deviceKey!!, page.sessions.map(::toPersistedSession), page.hasMore) + } _state.value = RemoteSessionUiState.Ready( sessions = page.sessions, selectedSessionId = current?.selectedSessionId, @@ -178,13 +208,15 @@ public class RemoteSessionStore internal constructor( agentFilter = filter, hasMore = page.hasMore, hasMoreMessages = current?.hasMoreMessages ?: false, - modelCatalog = catalog ?: current?.modelCatalog, + modelCatalog = catalog.catalog ?: current?.modelCatalog, + modelCatalogFailure = catalog.failure, + draft = current?.draft ?: "", ) markConnected() } catch (cancelled: CancellationException) { throw cancelled } catch (error: Throwable) { - handleFailure(error, current) + handleFailure(error, _state.value as? RemoteSessionUiState.Ready) } } } @@ -199,8 +231,12 @@ public class RemoteSessionStore internal constructor( val page = listSessions(current.sessions.size, current.query, current.agentFilter) val known = current.sessions.mapTo(mutableSetOf()) { it.id } val ready = (_state.value as? RemoteSessionUiState.Ready) ?: current + val sessions = current.sessions + page.sessions.filterNot { it.id in known } + if (persistenceEnabled && current.query.isEmpty() && current.agentFilter == SessionAgentFilter.ALL) { + persistence!!.remoteSessions.save(deviceKey!!, sessions.map(::toPersistedSession), page.hasMore) + } _state.value = ready.copy( - sessions = current.sessions + page.sessions.filterNot { it.id in known }, + sessions = sessions, hasMore = page.hasMore, busy = false, ) @@ -274,23 +310,41 @@ public class RemoteSessionStore internal constructor( /** * A model catalog is useful before a session exists. Failure is deliberately - * non-fatal: older desktops may not implement this command, in which case - * the create screen hides the picker and every other remote feature remains - * available. + * non-fatal: the create screen hides the picker and every other remote + * feature remains available. + * + * Every catalog failure is typed as [ModelCatalogFailure.LOAD_FAILED] until + * the transport exposes a distinguishing peer-capability signal. A generic + * rejection or malformed response is not proof of an old peer: a modern + * desktop can reject the command transiently, and a malformed response can be + * a local protocol fault. + * + * [force] re-requests even when a catalog is already cached, which is how a + * settings retry reaches a catalog that a transient failure lost. */ - private suspend fun loadModelCatalogIfNeeded(): RemoteModelCatalog? { - modelCatalog?.let { return it } + private suspend fun loadModelCatalog(force: Boolean): ModelCatalogLoadResult { + if (!force) { + modelCatalog?.let { return ModelCatalogLoadResult(it, null) } + } return try { - transport.send(RemoteCommand(cmd = "get_model_catalog")) - .catalog + val catalog = transport.send(RemoteCommand(cmd = "get_model_catalog")).catalog + ?.takeUnless { it.version == 0L && it.models.isEmpty() } ?.also { modelCatalog = it } + modelCatalogFailure = null + ModelCatalogLoadResult(catalog, null) } catch (cancelled: CancellationException) { throw cancelled } catch (_: Throwable) { - null + modelCatalogFailure = ModelCatalogFailure.LOAD_FAILED + ModelCatalogLoadResult(null, ModelCatalogFailure.LOAD_FAILED) } } + private data class ModelCatalogLoadResult( + val catalog: RemoteModelCatalog?, + val failure: ModelCatalogFailure?, + ) + private suspend fun sendListSessions(limit: Int, offset: Int, query: String): SessionListResponse = transport.send( RemoteCommand( @@ -310,9 +364,35 @@ public class RemoteSessionStore internal constructor( return } val current = _state.value as? RemoteSessionUiState.Ready + val restoredDraft = if (persistenceEnabled) persistence!!.drafts.load(draftId(normalized)).orEmpty() else "" + if (persistenceEnabled) { + val cached = persistence!!.remoteTranscripts.load(deviceKey!!, normalized) + if (cached.isNotEmpty()) { + timelineStore.reset(normalized) + timelineStore.setPersistedMessages(cached.map(::toChatMessage)) + persistence.remoteTranscripts.loadCursor(deviceKey, normalized)?.let { cursor -> + timelineStore.setCursor(ChatSessionCursor( + cursor.pollVersion.toIntOrNull() ?: 0, + cursor.knownMessageCount, + cursor.knownModelCatalogVersion.toLongOrNull() ?: 0L, + )) + } + _state.value = RemoteSessionUiState.Ready( + sessions = current?.sessions.orEmpty(), selectedSessionId = normalized, + timeline = timelineStore.snapshot(), busy = true, + permissionMode = current?.permissionMode, permissionModeFailure = current?.permissionModeFailure, + query = current?.query.orEmpty(), agentFilter = current?.agentFilter ?: SessionAgentFilter.ALL, + hasMore = current?.hasMore ?: false, hasMoreMessages = false, + modelCatalog = modelCatalog ?: current?.modelCatalog, + modelCatalogFailure = modelCatalogFailure ?: current?.modelCatalogFailure, + draft = restoredDraft, + ) + } + } if (current == null) _connectionPhase.value = ConnectionPhase.CONNECTING work?.cancel() - _state.value = current?.copy(busy = true) ?: RemoteSessionUiState.Loading + _state.value = (_state.value as? RemoteSessionUiState.Ready)?.copy(busy = true) ?: current?.copy(busy = true) + ?: RemoteSessionUiState.Loading work = scope.launch { try { val opened = openSession(normalized) @@ -328,12 +408,14 @@ public class RemoteSessionStore internal constructor( hasMore = current?.hasMore ?: false, hasMoreMessages = opened.hasMoreMessages, modelCatalog = modelCatalog ?: current?.modelCatalog, + modelCatalogFailure = modelCatalogFailure ?: current?.modelCatalogFailure, + draft = restoredDraft, ) markConnected() } catch (cancelled: CancellationException) { throw cancelled } catch (error: Throwable) { - handleFailure(error, current) + handleFailure(error, _state.value as? RemoteSessionUiState.Ready) } } } @@ -343,9 +425,18 @@ public class RemoteSessionStore internal constructor( val response = transport.send( RemoteCommand(cmd = "get_session_messages", sessionId = sessionId, limit = 100), ) + val cursor = timelineStore.snapshot().cursor.takeIf { timelineStore.snapshot().sessionId == sessionId } timelineStore.reset(sessionId) timelineStore.setPersistedMessages(response.messages.map(RemoteResponseMapper::chatMessage)) - controller.start(sessionId, ChatSessionCursor(0, response.messages.size, 0)) + controller.start( + sessionId, + ChatSessionCursor( + pollVersion = 0, + knownMessageCount = response.messages.size, + knownModelCatalogVersion = cursor?.knownModelCatalogVersion ?: 0L, + ), + ) + persistTranscript(sessionId) return OpenedSession(readPermissionMode(), response.hasMore) } @@ -378,6 +469,7 @@ public class RemoteSessionStore internal constructor( .map(RemoteResponseMapper::chatMessage) .filterNot { it.id in visibleIds } timelineStore.setPersistedMessages(older + visible) + persistTranscript(sessionId) val ready = (_state.value as? RemoteSessionUiState.Ready) ?: current _state.value = ready.copy( timeline = timelineStore.snapshot(), @@ -407,7 +499,7 @@ public class RemoteSessionStore internal constructor( OpenedPermission( transport.send( RemoteCommand(cmd = "get_permission_mode"), - ).mode?.toUiMode(), + ).mode?.toUiMode() ?: SessionPermissionMode.UNKNOWN, null, ) } catch (cancelled: CancellationException) { @@ -459,7 +551,12 @@ public class RemoteSessionStore internal constructor( val opened = openSession(sessionId) intent.instruction.trim().takeIf(String::isNotEmpty)?.let { instruction -> val sent = transport.send( - RemoteCommand(cmd = "send_message", sessionId = sessionId, content = instruction), + RemoteCommand( + cmd = "send_message", + sessionId = sessionId, + content = instruction, + agentType = intent.agentType, + ), ) sent.turnId?.let(timelineStore::setLocalActiveTurn) controller.nudge() @@ -490,6 +587,8 @@ public class RemoteSessionStore internal constructor( hasMore = page.hasMore, hasMoreMessages = opened.hasMoreMessages, modelCatalog = modelCatalog ?: current?.modelCatalog, + modelCatalogFailure = modelCatalogFailure ?: current?.modelCatalogFailure, + draft = "", ) markConnected() } catch (cancelled: CancellationException) { @@ -514,6 +613,7 @@ public class RemoteSessionStore internal constructor( locallyCreatedSessions.remove(normalized) val closingOpenSession = current.selectedSessionId == normalized if (closingOpenSession) { + if (persistenceEnabled) persistence!!.drafts.delete(draftId(normalized)) controller.stop() timelineStore.reset("") } @@ -579,7 +679,10 @@ public class RemoteSessionStore internal constructor( ) markConnected() } - if (snapshot.shouldSyncAfterTurnEnded) syncAfterTurnEnded(snapshot.sessionId) + if (snapshot.shouldSyncAfterTurnEnded) { + persistTranscript(snapshot.sessionId) + syncAfterTurnEnded(snapshot.sessionId) + } } /** @@ -611,6 +714,7 @@ public class RemoteSessionStore internal constructor( knownModelCatalogVersion = timelineStore.snapshot().cursor.knownModelCatalogVersion, ) timelineStore.setCursor(cursor) + persistTranscript(sessionId) controller.updateCursor(cursor) val current = _state.value if (current is RemoteSessionUiState.Ready) { @@ -668,16 +772,22 @@ public class RemoteSessionStore internal constructor( work?.cancel() work = scope.launch { try { + val agentType = current.sessions.firstOrNull { it.id == sessionId }?.agentType + ?: locallyCreatedSessions[sessionId]?.agentType val response = transport.send( RemoteCommand( cmd = "send_message", sessionId = sessionId, content = content, + agentType = agentType, imageContexts = imageContexts, ), ) response.turnId?.let(timelineStore::setLocalActiveTurn) controller.nudge() + if (persistenceEnabled) persistence!!.drafts.delete(draftId(sessionId)) + val ready = ((_state.value as? RemoteSessionUiState.Ready) ?: current) + if (ready.selectedSessionId == sessionId) _state.value = ready.copy(draft = "") setBusy((_state.value as? RemoteSessionUiState.Ready) ?: current, false) } catch (cancelled: CancellationException) { throw cancelled @@ -689,20 +799,43 @@ public class RemoteSessionStore internal constructor( } } + private fun updateDraft(text: String) { + val current = _state.value as? RemoteSessionUiState.Ready ?: return + val id = current.selectedSessionId ?: return + if (persistenceEnabled) { + if (text.isEmpty()) persistence!!.drafts.delete(draftId(id)) + else persistence!!.drafts.save(draftId(id), text) + } + _state.value = current.copy(draft = text) + } + + private fun draftId(sessionId: String): String = "remote-composer:$deviceKey:$sessionId" + private fun cancelTurn(intent: RemoteSessionIntent.CancelTurn) { val sessionId = intent.sessionId.trim() if (sessionId.isEmpty()) return runAction(sessionId, RemoteCommand(cmd = "cancel_task", sessionId = sessionId, turnId = intent.turnId)) } + private fun approveTool(intent: RemoteSessionIntent.ApproveTool) { + if (intent.updatedInput != null) { + // The desktop confirm_tool handler accepts only tool_id. Sending an + // edited approval would approve while silently dropping the edit, so + // gate it to the typed unsupported fact (ToolApprovalEditContract). + return + } + runAction(intent.sessionId, RemoteCommand(cmd = "confirm_tool", toolId = intent.toolId)) + } + private fun setPermissionMode(intent: RemoteSessionIntent.SetPermissionMode) { + val wireMode = intent.mode.toWireMode() ?: return val current = _state.value as? RemoteSessionUiState.Ready ?: return setBusy(current, true) work?.cancel() work = scope.launch { try { transport.send( - RemoteCommand(cmd = "set_permission_mode", mode = intent.mode.toWireMode()), + RemoteCommand(cmd = "set_permission_mode", mode = wireMode), ) val ready = (_state.value as? RemoteSessionUiState.Ready) ?: current _state.value = ready.copy( @@ -740,6 +873,49 @@ public class RemoteSessionStore internal constructor( } } + /** + * Re-reads the model catalog alone, the way [refreshPermissionMode] re-reads + * the permission mode: the session list, transcript, and draft stay on + * screen, and only the model section changes. A failure keeps the store in + * [RemoteSessionUiState.Ready] with the typed failure instead of taking the + * transcript down with it. + */ + private fun refreshModelCatalog() { + val current = _state.value as? RemoteSessionUiState.Ready ?: return + if (current.busy) return + // Forward-compat: a future transport may produce a real unsupported-by- + // peer signal. That is the only case where a retry cannot help, because + // every generic failure is typed as LOAD_FAILED and remains retryable. + if (modelCatalogFailure == ModelCatalogFailure.UNSUPPORTED_BY_PEER) return + setBusy(current, true) + work?.cancel() + work = scope.launch { + try { + val result = loadModelCatalog(force = true) + val timeline = result.catalog?.let { catalog -> + val snapshot = timelineStore.snapshot() + if (snapshot.sessionId.isNotEmpty()) { + timelineStore.setModelCatalog(catalog, snapshot.selectedModelId) + timelineStore.snapshot() + } else { + null + } + } + val ready = (_state.value as? RemoteSessionUiState.Ready) ?: current + _state.value = ready.copy( + busy = false, + modelCatalog = result.catalog ?: ready.modelCatalog, + modelCatalogFailure = result.failure, + timeline = timeline ?: ready.timeline, + ) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Throwable) { + setBusy((_state.value as? RemoteSessionUiState.Ready) ?: current, false) + } + } + } + private fun selectModel(intent: RemoteSessionIntent.SelectModel) { val current = _state.value as? RemoteSessionUiState.Ready ?: return if (intent.modelId.trim().isEmpty()) return @@ -787,18 +963,50 @@ public class RemoteSessionStore internal constructor( _state.value = current.copy(busy = busy, permissionMode = permissionMode) } - private fun SessionPermissionMode.toWireMode(): RemotePermissionMode = when (this) { + private fun SessionPermissionMode.toWireMode(): RemotePermissionMode? = when (this) { SessionPermissionMode.ASK -> RemotePermissionMode.Ask SessionPermissionMode.AUTO -> RemotePermissionMode.Auto SessionPermissionMode.FULL_ACCESS -> RemotePermissionMode.FullAccess + SessionPermissionMode.UNKNOWN -> null } private fun RemotePermissionMode.toUiMode(): SessionPermissionMode = when (this) { RemotePermissionMode.Ask -> SessionPermissionMode.ASK RemotePermissionMode.Auto -> SessionPermissionMode.AUTO RemotePermissionMode.FullAccess -> SessionPermissionMode.FULL_ACCESS + RemotePermissionMode.Unknown -> SessionPermissionMode.UNKNOWN + } + + private fun persistTranscript(sessionId: String) { + if (!persistenceEnabled || sessionId.isEmpty()) return + val snapshot = timelineStore.snapshot() + if (snapshot.sessionId != sessionId) return + val p = persistence!! + val window = snapshot.persistedMessages.map { toPersisted(sessionId, it) } + val windowIds = window.mapTo(mutableSetOf()) { it.messageId } + // A paginated re-read (limit 100) must not truncate pages the user already + // loaded: keep older cached rows the current window does not cover. + val older = p.remoteTranscripts.load(deviceKey!!, sessionId).filterNot { it.messageId in windowIds } + p.remoteTranscripts.replace(deviceKey, sessionId, older + window) + p.remoteTranscripts.saveCursor(deviceKey, sessionId, PersistedRemoteCursor( + pollVersion = snapshot.cursor.pollVersion.toString(), + knownMessageCount = snapshot.cursor.knownMessageCount, + knownModelCatalogVersion = snapshot.cursor.knownModelCatalogVersion.toString(), + )) } + private fun toRemoteSession(s: PersistedRemoteSession): RemoteSession = RemoteSession( + id = s.sessionId, title = s.title, agentType = s.agentType, status = s.status, + updatedAt = s.updatedAt, createdAt = s.createdAt, messageCount = s.messageCount, + workspacePath = s.workspacePath, workspaceName = s.workspaceName, + ) + + private fun toPersistedSession(s: RemoteSession): PersistedRemoteSession = PersistedRemoteSession( + sessionId = s.id, title = s.title, agentType = s.agentType, status = s.status, + updatedAt = s.updatedAt, createdAt = s.createdAt, messageCount = s.messageCount, + lastMessageId = "", workspacePath = s.workspacePath, workspaceName = s.workspaceName, + ) + private inner class ControllerCallbacks : ChatSessionControllerCallbacks { override fun onSnapshot(snapshot: ChatSessionSnapshot) { updateTimeline(snapshot) @@ -864,6 +1072,7 @@ public class RemoteSessionStore internal constructor( knownModelCatalogVersion = knownModelCatalogVersion, ), ) + val version = if (response.version > 0) response.version else sinceVersion return PollSessionResult( version = response.version, changed = response.changed, @@ -871,7 +1080,7 @@ public class RemoteSessionStore internal constructor( title = response.title.orEmpty(), newMessages = response.newMessages.map(RemoteResponseMapper::chatMessage), totalMessageCount = response.totalMessageCount ?: knownMessageCount, - activeTurn = response.activeTurn?.let(RemoteResponseMapper::activeTurn), + activeTurn = response.activeTurn?.let { RemoteResponseMapper.activeTurn(it, version) }, modelCatalog = response.modelCatalog, ) } @@ -892,9 +1101,57 @@ public class RemoteSessionStore internal constructor( internal fun create(scope: CoroutineScope, transport: RemoteCommandTransport): RemoteSessionStore = RemoteSessionStore(scope, transport) + + internal fun create( + scope: CoroutineScope, + transport: RemoteCommandTransport, + deviceKey: String?, + persistence: MobilePersistenceStores?, + ): RemoteSessionStore = RemoteSessionStore(scope, transport, deviceKey, persistence) + + internal fun create( + scope: CoroutineScope, + room: PairedRoom, + deviceKey: String?, + persistence: MobilePersistenceStores?, + ): RemoteSessionStore = RemoteSessionStore(scope, room.transport, deviceKey, persistence) } } +@Serializable +private data class StoredRemoteMessagePayload( + val renderVersion: Int? = null, + val turnId: String? = null, + val detail: String? = null, + val tools: List? = null, + val items: List? = null, + val images: List? = null, +) + +private val STORE_JSON = Json { ignoreUnknownKeys = true } + +private fun toPersisted(sessionId: String, m: ChatMessage): PersistedRemoteMessage = PersistedRemoteMessage( + messageId = m.id, sessionId = sessionId, role = m.role, text = m.text, status = m.status, + timestamp = m.timestamp, thinking = m.thinking, + payloadJson = STORE_JSON.encodeToString(StoredRemoteMessagePayload( + m.renderVersion, m.turnId, m.detail, m.tools, m.items, m.images, + )), +) + +private fun toChatMessage(m: PersistedRemoteMessage): ChatMessage { + val payload = try { + STORE_JSON.decodeFromString(m.payloadJson) + } catch (_: Throwable) { + StoredRemoteMessagePayload() + } + return ChatMessage( + id = m.messageId, role = m.role, text = m.text, status = m.status, + renderVersion = payload.renderVersion, turnId = payload.turnId, detail = payload.detail, + timestamp = m.timestamp, thinking = m.thinking, tools = payload.tools, + items = payload.items, images = payload.images, + ) +} + internal object RemoteResponseMapper { fun session(item: SessionItemResponse): RemoteSession { val id = item.id.orEmpty() @@ -930,14 +1187,14 @@ internal object RemoteResponseMapper { ) } - fun activeTurn(turn: ActiveTurnSnapshotResponse): ChatMessage { + fun activeTurn(turn: ActiveTurnSnapshotResponse, renderVersion: Int = 0): ChatMessage { val tools = if (turn.tools.isNotEmpty()) turn.tools else itemTools(turn.items) return ChatMessage( id = "active-${turn.turnId}", role = "assistant", text = messageText(turn.text.orEmpty(), turn.items), status = turn.status, - renderVersion = null, + renderVersion = renderVersion, turnId = turn.turnId, detail = messageDetail(tools), timestamp = null, diff --git a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/bitfun/mobile/core/feature/session/RemoteSessionUiState.kt b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/bitfun/mobile/core/feature/session/RemoteSessionUiState.kt index d0b4f81a38..eef2f7f96e 100644 --- a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/bitfun/mobile/core/feature/session/RemoteSessionUiState.kt +++ b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/bitfun/mobile/core/feature/session/RemoteSessionUiState.kt @@ -28,6 +28,9 @@ public enum class SessionPermissionMode { ASK, AUTO, FULL_ACCESS, + + /** Unknown or unavailable permission mode, surfaced instead of assuming ask. */ + UNKNOWN, } /** @@ -46,6 +49,26 @@ public enum class PermissionModeFailure { SAVE, } +/** Why the optional model catalog could not be loaded. */ +public enum class ModelCatalogFailure { + /** + * The catalog command failed with any of the generic transport results + * (rejected, malformed, timed out, unreachable, ...). Retrying is + * meaningful, so the UI offers Retry. + */ + LOAD_FAILED, + + /** + * Reserved forward-compat state for a real peer-capability signal: the peer + * is known to lack `get_model_catalog`. The current transport produces no + * such signal, so this value is never set from a generic command failure — + * a rejection or malformed response can also come from a modern desktop or + * a local protocol fault. The UI still renders it explicitly if a future + * transport surfaces it. + */ + UNSUPPORTED_BY_PEER, +} + public enum class RemoteSessionFailureReason { TRANSPORT, SESSION_NOT_FOUND, @@ -106,10 +129,43 @@ public sealed interface RemoteSessionUiState { * * Keeping the catalog beside the session list lets a new-session surface * offer the same model picker before any transcript has been opened. - * Older peers that do not expose the command simply leave this null. + * It is null until a catalog loads successfully. */ public val modelCatalog: RemoteModelCatalog?, - ) : RemoteSessionUiState + /** + * Non-null when catalog loading failed. [ModelCatalogFailure.LOAD_FAILED] + * is the generic, retryable classification; [ModelCatalogFailure.UNSUPPORTED_BY_PEER] + * is reserved for a future peer-capability signal the transport does not + * yet produce. + */ + public val modelCatalogFailure: ModelCatalogFailure?, + public val draft: String, + ) : RemoteSessionUiState { + /** + * The pre-catalog/pre-draft shape. A secondary constructor rather than + * default arguments: Kotlin defaults do not survive into Swift, so an + * iOS caller would be forced to name fields it does not use — see the + * design doc §4.1. + */ + public constructor( + sessions: List, + selectedSessionId: String?, + timeline: ChatTimelineState?, + busy: Boolean, + permissionMode: SessionPermissionMode?, + permissionModeFailure: PermissionModeFailure?, + query: String, + agentFilter: SessionAgentFilter, + hasMore: Boolean, + hasMoreMessages: Boolean, + modelCatalog: RemoteModelCatalog?, + ) : this( + sessions = sessions, selectedSessionId = selectedSessionId, timeline = timeline, busy = busy, + permissionMode = permissionMode, permissionModeFailure = permissionModeFailure, + query = query, agentFilter = agentFilter, hasMore = hasMore, hasMoreMessages = hasMoreMessages, + modelCatalog = modelCatalog, modelCatalogFailure = null, draft = "", + ) + } /** * @param remoteMessage verbatim text from the desktop, present only for @@ -195,6 +251,10 @@ public sealed interface RemoteSessionIntent { public val answers: List, ) : RemoteSessionIntent + public data class UpdateDraft public constructor( + public val text: String, + ) : RemoteSessionIntent + public data class SendMessage public constructor( public val sessionId: String, public val content: String, @@ -211,7 +271,10 @@ public sealed interface RemoteSessionIntent { public data class ApproveTool public constructor( public val sessionId: String, public val toolId: String, - ) : RemoteSessionIntent + public val updatedInput: String?, + ) : RemoteSessionIntent { + public constructor(sessionId: String, toolId: String) : this(sessionId, toolId, null) + } public data class RejectTool public constructor( public val sessionId: String, @@ -242,6 +305,17 @@ public sealed interface RemoteSessionIntent { */ public data object RefreshPermissionMode : RemoteSessionIntent + /** + * Re-read the desktop's model catalog without touching the session list or + * the open transcript, as [RefreshPermissionMode] does for the permission + * mode. + * + * No session id, for the same reason [RefreshPermissionMode] carries none: + * `get_model_catalog` is addressed to the desktop and one catalog answers + * for every session on it. + */ + public data object RefreshModelCatalog : RemoteSessionIntent + public data class SelectModel public constructor( public val sessionId: String, public val modelId: String, diff --git a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/bitfun/mobile/core/feature/session/ToolApprovalEditContract.kt b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/bitfun/mobile/core/feature/session/ToolApprovalEditContract.kt new file mode 100644 index 0000000000..477a417e7e --- /dev/null +++ b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/bitfun/mobile/core/feature/session/ToolApprovalEditContract.kt @@ -0,0 +1,19 @@ +package com.bitfun.mobile.core.feature.session + +/** + * Whether the desktop peer can accept edited tool approvals. + */ +public enum class ToolApprovalEditSupport { + SUPPORTED, + UNSUPPORTED, +} + +/** + * Today the desktop confirm_tool accepts only tool_id, so edited tool approvals + * are UNSUPPORTED. This flips to SUPPORTED when the peer advertises confirm_tool + * edit support. Android UI reads this fact to decide whether to offer an edit + * affordance. + */ +public object ToolApprovalEditContract { + public val support: ToolApprovalEditSupport = ToolApprovalEditSupport.UNSUPPORTED +} diff --git a/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/bitfun/mobile/core/feature/session/ConversationModelPresentationTest.kt b/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/bitfun/mobile/core/feature/session/ConversationModelPresentationTest.kt index eb7f3b111f..7d3b4382a5 100644 --- a/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/bitfun/mobile/core/feature/session/ConversationModelPresentationTest.kt +++ b/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/bitfun/mobile/core/feature/session/ConversationModelPresentationTest.kt @@ -110,4 +110,5 @@ private fun timeline( sessionModelId = sessionModelId, ), selectedModelId = selectedModelId, + activeTurnAnchorId = "", ) diff --git a/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/bitfun/mobile/core/feature/session/ConversationPresentationTest.kt b/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/bitfun/mobile/core/feature/session/ConversationPresentationTest.kt index 8a3f83ade7..6d68491c63 100644 --- a/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/bitfun/mobile/core/feature/session/ConversationPresentationTest.kt +++ b/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/bitfun/mobile/core/feature/session/ConversationPresentationTest.kt @@ -20,6 +20,24 @@ class ConversationPresentationTest { assertEquals(listOf(ConversationRowKind.EMPTY), rows.map { it.kind }) } + @Test + fun anActiveTurnStaysBelowTheMessageThatStartedIt() { + val persisted = listOf(message("user-1", "user", "First")) + val optimistic = listOf(message("local-1", "user", "Second")) + val activeTurn = message("turn-1", "assistant", "Working") + + assertEquals( + listOf("message-user-1", "pending-local-1", "active-turn-1"), + timeline(persisted, optimistic, activeTurn, activeTurnAnchorId = "local-1") + .conversationRows().map { it.id }, + ) + // With no anchor, the live turn follows the persisted messages instead. + assertEquals( + listOf("message-user-1", "active-turn-1", "pending-local-1"), + timeline(persisted, optimistic, activeTurn).conversationRows().map { it.id }, + ) + } + @Test fun aMessageStillInFlightIsShownAsPendingUntilItsTwinArrives() { val optimistic = timeline(optimistic = listOf(message("local-1", "user", "ship it"))) @@ -135,6 +153,26 @@ class ConversationPresentationTest { assertEquals("ok", card.output) } + @Test + fun successMapsToCompletedWithItsOutput() { + val card = cardsFor(tool(status = "success", resultPreview = "ok")).single() + + assertEquals(ToolPhase.COMPLETED, card.phase) + assertTrue(card.actions.isEmpty()) + assertEquals("ok", card.output) + } + + @Test + fun toolExpandabilityComesFromTheStatusContract() { + assertEquals(true, toolCard(tool(status = "completed")).expandable) + assertEquals(true, toolCard(tool(status = "running")).expandable) + assertEquals(false, toolCard(tool(status = "queued", name = "Bash")).expandable) + assertEquals( + true, + toolCard(tool(name = "AskUserQuestion", status = "sent")).expandable, + ) + } + @Test fun aToolWithNoIdIsShownButNotActionable() { // Every action is addressed by id, so buttons here could not be delivered. @@ -179,11 +217,13 @@ private fun timeline( persisted: List = emptyList(), optimistic: List = emptyList(), activeTurn: ChatMessage? = null, + activeTurnAnchorId: String = "", ) = ChatTimelineState( sessionId = "s-1", persistedMessages = persisted, optimisticMessages = optimistic, activeTurn = activeTurn, + activeTurnAnchorId = activeTurnAnchorId, syncPhase = ChatSyncPhase.IDLE, cursor = ChatSessionCursor(0, 0, 0), modelCatalog = RemoteModelCatalog(version = 0), diff --git a/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/bitfun/mobile/core/feature/session/RemoteResponseMapperTest.kt b/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/bitfun/mobile/core/feature/session/RemoteResponseMapperTest.kt index 54ad702ef6..e04d6293d0 100644 --- a/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/bitfun/mobile/core/feature/session/RemoteResponseMapperTest.kt +++ b/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/bitfun/mobile/core/feature/session/RemoteResponseMapperTest.kt @@ -5,11 +5,70 @@ import com.bitfun.mobile.core.protocol.ChatMessageItemResponse import com.bitfun.mobile.core.protocol.ChatMessageResponse import com.bitfun.mobile.core.protocol.RemoteToolStatusResponse import com.bitfun.mobile.core.protocol.SessionItemResponse +import com.bitfun.mobile.core.domain.ChatTimelineStore import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue class RemoteResponseMapperTest { + @Test + fun activeTurnCarriesPollVersionAsRenderVersion() { + val turn = ActiveTurnSnapshotResponse(turnId = "turn-version", status = "active") + + assertEquals(7, RemoteResponseMapper.activeTurn(turn, renderVersion = 7).renderVersion) + // A mapper-only call has no poll cursor, so its documented default is zero. + assertEquals(0, RemoteResponseMapper.activeTurn(turn).renderVersion) + } + + @Test + fun lowerVersionPollSnapshotDoesNotRegressHigherVersionContent() { + val turn = ActiveTurnSnapshotResponse(turnId = "turn-order", status = "active") + val newer = RemoteResponseMapper.activeTurn( + turn.copy(items = listOf(ChatMessageItemResponse(type = "text", content = "newer"))), + renderVersion = 5, + ) + val stale = RemoteResponseMapper.activeTurn( + turn.copy(items = listOf(ChatMessageItemResponse(type = "text", content = "stale"))), + renderVersion = 2, + ) + val store = ChatTimelineStore().also { it.reset("session-f2") } + + store.setActiveTurn(newer) + store.setActiveTurn(stale) + + assertEquals("newer", store.snapshot().activeTurn?.items?.firstOrNull()?.content) + } + + @Test + fun lowerVersionPollSnapshotDoesNotRegressTextItemsOrTools() { + val turn = ActiveTurnSnapshotResponse(turnId = "turn-order-all", status = "active") + val newer = RemoteResponseMapper.activeTurn( + turn.copy( + text = "newer text", + items = listOf(ChatMessageItemResponse(type = "text", content = "newer item")), + tools = listOf(RemoteToolStatusResponse(id = "tool-1", name = "read_file", status = "completed")), + ), + renderVersion = 5, + ) + val stale = RemoteResponseMapper.activeTurn( + turn.copy( + text = "stale text", + items = listOf(ChatMessageItemResponse(type = "text", content = "stale item")), + tools = listOf(RemoteToolStatusResponse(id = "tool-1", name = "read_file", status = "running")), + ), + renderVersion = 2, + ) + val store = ChatTimelineStore().also { it.reset("session-f2-all") } + + store.setActiveTurn(newer) + store.setActiveTurn(stale) + + val active = store.snapshot().activeTurn + assertEquals("newer text", active?.text) + assertEquals("newer item", active?.items?.firstOrNull()?.content) + assertEquals("completed", active?.tools?.firstOrNull()?.status) + } + @Test fun mapsAssistantMessagesAndNestedTools() { val message = RemoteResponseMapper.chatMessage( diff --git a/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/bitfun/mobile/core/feature/session/RemoteSessionPersistenceTest.kt b/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/bitfun/mobile/core/feature/session/RemoteSessionPersistenceTest.kt new file mode 100644 index 0000000000..300cdb4cd7 --- /dev/null +++ b/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/bitfun/mobile/core/feature/session/RemoteSessionPersistenceTest.kt @@ -0,0 +1,211 @@ +package com.bitfun.mobile.core.feature.session + +import com.bitfun.mobile.core.feature.connection.ConnectionPhase +import com.bitfun.mobile.core.persistence.ChatLocalStore +import com.bitfun.mobile.core.persistence.DraftStore +import com.bitfun.mobile.core.persistence.MobilePersistenceStores +import com.bitfun.mobile.core.persistence.PersistedChatMessage +import com.bitfun.mobile.core.persistence.PersistedChatSession +import com.bitfun.mobile.core.persistence.PersistedRemoteCursor +import com.bitfun.mobile.core.persistence.PersistedRemoteMessage +import com.bitfun.mobile.core.persistence.PersistedRemoteSession +import com.bitfun.mobile.core.persistence.RemoteSessionListStore +import com.bitfun.mobile.core.persistence.RemoteTranscriptStore +import com.bitfun.mobile.core.protocol.CommandStatus +import com.bitfun.mobile.core.protocol.RelayJson +import com.bitfun.mobile.core.protocol.RemoteCommand +import com.bitfun.mobile.core.transport.RelayFailure +import com.bitfun.mobile.core.transport.RelayTransportException +import com.bitfun.mobile.core.transport.RemoteCommandTransport +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.DeserializationStrategy +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs + +@OptIn(ExperimentalCoroutinesApi::class) +class RemoteSessionPersistenceTest { + @Test + fun coldStartShowsCachedListThenServerList() = runTest { + val stores = MemoryPersistence() + stores.sessions.rows = listOf(PersistedRemoteSession(sessionId = "cached", title = "Cached")) + val transport = PersistenceTransport() + val store = RemoteSessionStore.create(this, transport, "device-a", stores.stores) + store.dispatch(RemoteSessionIntent.Load) + assertEquals("cached", assertIs(store.state.value).sessions.single().id) + advanceUntilIdle() + assertEquals("server", assertIs(store.state.value).sessions.single().id) + store.dispatch(RemoteSessionIntent.Stop) + } + + @Test + fun draftIsSavedRestoredAndClearedAfterSend() = runTest { + val stores = MemoryPersistence() + val transport = PersistenceTransport() + val first = RemoteSessionStore.create(this, transport, "device-a", stores.stores) + first.dispatch(RemoteSessionIntent.Open("server")); runCurrent() + first.dispatch(RemoteSessionIntent.UpdateDraft("keep me")) + assertEquals("keep me", stores.drafts.values["remote-composer:device-a:server"]) + val second = RemoteSessionStore.create(this, transport, "device-a", stores.stores) + second.dispatch(RemoteSessionIntent.Open("server")); runCurrent() + assertEquals("keep me", assertIs(second.state.value).draft) + second.dispatch(RemoteSessionIntent.SendMessage("server", "hello")); runCurrent() + assertEquals(null, stores.drafts.values["remote-composer:device-a:server"]) + first.dispatch(RemoteSessionIntent.Stop) + second.dispatch(RemoteSessionIntent.Stop) + } + + @Test + fun activeTurnIsPersistedOnEndAndRestoredOnReopen() = runTest { + val stores = MemoryPersistence() + val transport = PersistenceTransport() + transport.messagesJson = """[{"id":"m-1","role":"assistant","content":"All done"}]""" + transport.polls = listOf( + """{"resp":"ok","version":1,"changed":true,"session_state":"running","active_turn":{"turn_id":"t-1","status":"active","text":"All "}}""", + """{"resp":"ok","version":2,"changed":true,"session_state":"idle","active_turn":{"turn_id":"t-1","status":"completed","text":"All done"}}""", + """{"resp":"ok","version":2,"changed":false,"session_state":"idle"}""", + ) + val store = RemoteSessionStore.create(this, transport, "device-a", stores.stores) + store.dispatch(RemoteSessionIntent.Open("server")); runCurrent() + advanceTimeBy(350); runCurrent() + assertEquals("m-1", stores.transcripts.rows["device-a::server"]!!.single().messageId) + assertEquals("0", stores.transcripts.cursors["device-a::server"]!!.pollVersion) + store.dispatch(RemoteSessionIntent.Stop) + + val transport2 = PersistenceTransport() + transport2.messagesJson = transport.messagesJson + val store2 = RemoteSessionStore.create(this, transport2, "device-a", stores.stores) + store2.dispatch(RemoteSessionIntent.Open("server")) + assertEquals("m-1", assertIs(store2.state.value).timeline?.persistedMessages?.single()?.id) + runCurrent(); store2.dispatch(RemoteSessionIntent.Stop) + } + + @Test + fun disconnectDuringPollRetainsPersistedTranscriptAndRecovers() = runTest { + val stores = MemoryPersistence() + stores.transcripts.rows["device-a::server"] = listOf( + PersistedRemoteMessage( + messageId = "m-1", sessionId = "server", role = "assistant", text = "cached", + payloadJson = """{"render_version":null}""", + ), + ) + val transport = PersistenceTransport() + transport.messagesJson = """[{"id":"m-1","role":"assistant","content":"cached"}]""" + transport.pollFailure = RelayFailure.NetworkUnreachable + val store = RemoteSessionStore.create(this, transport, "device-a", stores.stores) + store.dispatch(RemoteSessionIntent.Open("server")); runCurrent() + assertEquals(ConnectionPhase.RECONNECTING, store.connectionPhase.value) + assertEquals(1, stores.transcripts.rows["device-a::server"]?.size) + transport.pollFailure = null + advanceTimeBy(10_000); runCurrent() + assertEquals(ConnectionPhase.CONNECTED, store.connectionPhase.value) + assertEquals("m-1", assertIs(store.state.value).timeline?.persistedMessages?.single()?.id) + store.dispatch(RemoteSessionIntent.Stop) + } + + @Test + fun partialRereadDoesNotTruncateOlderCachedMessages() = runTest { + val stores = MemoryPersistence() + stores.transcripts.rows["device-a::server"] = (0 until 120).map { i -> + PersistedRemoteMessage( + messageId = "m-$i", sessionId = "server", role = "assistant", + text = "msg $i", payloadJson = "{}", + ) + } + val transport = PersistenceTransport() + transport.messagesJson = (20 until 120).joinToString(prefix = "[", postfix = "]") { i -> + "{\"id\":\"m-$i\",\"role\":\"assistant\",\"content\":\"msg $i\"}" + } + transport.hasMore = true + val store = RemoteSessionStore.create(this, transport, "device-a", stores.stores) + store.dispatch(RemoteSessionIntent.Open("server")); runCurrent() + assertEquals(120, stores.transcripts.rows["device-a::server"]?.size) + store.dispatch(RemoteSessionIntent.Stop) + } + + @Test + fun corruptedPayloadIsRetainedAsDegradedMessage() = runTest { + val stores = MemoryPersistence() + stores.transcripts.rows["device-a::server"] = listOf(PersistedRemoteMessage(messageId = "bad", sessionId = "server", role = "assistant", text = "retained", payloadJson = "not-json")) + val transport = PersistenceTransport() + transport.messagesJson = "[{\"id\":\"bad\",\"role\":\"assistant\",\"content\":\"retained\"}]" + val store = RemoteSessionStore.create(this, transport, "device-a", stores.stores) + store.dispatch(RemoteSessionIntent.Open("server")); runCurrent() + assertEquals("bad", assertIs(store.state.value).timeline?.persistedMessages?.single()?.id) + assertEquals(1, stores.transcripts.rows["device-a::server"]?.size) + store.dispatch(RemoteSessionIntent.Stop) + } +} + +private class MemoryPersistence { + val drafts = MemoryDrafts() + val sessions = MemorySessions() + val transcripts = MemoryTranscripts() + val stores = MobilePersistenceStores(drafts, NoOpChats(), sessions, transcripts) +} + +private class MemoryDrafts : DraftStore { + val values = mutableMapOf() + override fun load(draftId: String): String? = values[draftId] + override fun save(draftId: String, text: String) { values[draftId] = text } + override fun delete(draftId: String) { values.remove(draftId) } +} + +private class NoOpChats : ChatLocalStore { + override fun listSessions(agentType: String): List = emptyList() + override fun loadSession(sessionId: String): PersistedChatSession? = null + override fun loadMessages(sessionId: String): List = emptyList() + override fun saveSession(session: PersistedChatSession) = Unit + override fun saveMessage(message: PersistedChatMessage) = Unit + override fun pinSession(agentType: String, sessionId: String, pinned: Boolean) = Unit + override fun setSessionStatus(sessionId: String, status: String) = Unit + override fun deleteSession(sessionId: String) = Unit +} + +private class MemorySessions : RemoteSessionListStore { + var rows = emptyList() + var more = false + override fun load(deviceKey: String): List = rows + override fun save(deviceKey: String, sessions: List, hasMore: Boolean) { rows = sessions; more = hasMore } + override fun hasMore(deviceKey: String): Boolean = more +} + +private class MemoryTranscripts : RemoteTranscriptStore { + val rows = mutableMapOf>() + val cursors = mutableMapOf() + override fun load(deviceKey: String, sessionId: String) = rows["$deviceKey::$sessionId"].orEmpty() + override fun append(deviceKey: String, sessionId: String, startSeq: Int, messages: List) = Unit + override fun replace(deviceKey: String, sessionId: String, messages: List) { rows["$deviceKey::$sessionId"] = messages } + override fun loadCursor(deviceKey: String, sessionId: String) = cursors["$deviceKey::$sessionId"] + override fun saveCursor(deviceKey: String, sessionId: String, cursor: PersistedRemoteCursor) { cursors["$deviceKey::$sessionId"] = cursor } +} + +private class PersistenceTransport : RemoteCommandTransport { + var messagesJson: String = "[]" + var hasMore: Boolean = false + var polls: List = listOf("""{"resp":"ok","version":1,"changed":false,"session_state":"idle"}""") + private var pollIndex = 0 + var pollFailure: RelayFailure? = null + val sinceVersions = mutableListOf() + override suspend fun send(deserializer: DeserializationStrategy, command: RemoteCommand, timeoutMs: Long): T { + if (command.cmd == "poll_session") { + sinceVersions += command.sinceVersion ?: 0 + pollFailure?.let { throw RelayTransportException(it) } + } + val json = when (command.cmd) { + "get_workspace_info" -> "{\"resp\":\"ok\",\"path\":\"/repo\"}" + "list_sessions" -> "{\"resp\":\"ok\",\"sessions\":[{\"id\":\"server\",\"title\":\"Server\",\"agent_type\":\"code\"}]}" + "get_session_messages" -> "{\"resp\":\"ok\",\"messages\":$messagesJson,\"has_more\":$hasMore}" + "get_permission_mode" -> "{\"resp\":\"ok\",\"mode\":\"ask\"}" + "get_model_catalog" -> "{\"resp\":\"ok\",\"catalog\":{\"version\":0,\"models\":[],\"default_models\":{}}}" + "poll_session" -> polls[minOf(pollIndex++, polls.lastIndex)] + "send_message" -> "{\"resp\":\"ok\",\"turn_id\":\"t\"}" + else -> "{\"resp\":\"ok\"}" + } + return RelayJson.decodeFromString(deserializer, json) + } +} diff --git a/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/bitfun/mobile/core/feature/session/RemoteSessionStoreTest.kt b/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/bitfun/mobile/core/feature/session/RemoteSessionStoreTest.kt index 954dd74ffb..f45a81856b 100644 --- a/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/bitfun/mobile/core/feature/session/RemoteSessionStoreTest.kt +++ b/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/bitfun/mobile/core/feature/session/RemoteSessionStoreTest.kt @@ -40,6 +40,110 @@ class RemoteSessionStoreTest { assertEquals(0, list.offset) assertNull(list.query) assertEquals("model-primary", ready.modelCatalog?.defaultModels?.primary) + assertNull(ready.modelCatalogFailure) + } + + @Test + fun modelCatalogRemoteRejectedIsRetryableAndRefreshRecovers() = runTest { + val transport = FakeSessionTransport() + transport.modelCatalogFailure = RelayFailure.RemoteRejected("Unknown command") + val store = RemoteSessionStore.create(this, transport) + + store.dispatch(RemoteSessionIntent.Load) + advanceUntilIdle() + + val failed = assertIs(store.state.value) + assertNull(failed.modelCatalog) + // A rejection is not proof of an old peer: a modern desktop can refuse + // the catalog command transiently, so it stays retryable. + assertEquals(ModelCatalogFailure.LOAD_FAILED, failed.modelCatalogFailure) + assertTrue(failed.sessions.isNotEmpty()) + + transport.modelCatalogFailure = null + transport.commands.clear() + store.dispatch(RemoteSessionIntent.RefreshModelCatalog) + runCurrent() + + val ready = assertIs(store.state.value) + assertEquals("model-primary", ready.modelCatalog?.defaultModels?.primary) + assertNull(ready.modelCatalogFailure) + assertFalse(ready.busy) + assertEquals(listOf("get_model_catalog"), transport.commands.map { it.cmd }) + store.dispatch(RemoteSessionIntent.Stop) + } + + @Test + fun modelCatalogMalformedResponseIsTypedAsRetryableFailure() = runTest { + val transport = FakeSessionTransport() + transport.modelCatalogFailure = RelayFailure.MalformedResponse + val store = RemoteSessionStore.create(this, transport) + + store.dispatch(RemoteSessionIntent.Load) + advanceUntilIdle() + + val ready = assertIs(store.state.value) + assertNull(ready.modelCatalog) + // Malformed is a local protocol fault, not a peer capability statement. + assertEquals(ModelCatalogFailure.LOAD_FAILED, ready.modelCatalogFailure) + assertTrue(ready.sessions.isNotEmpty()) + } + + @Test + fun modelCatalogNetworkFailureIsTypedWithoutFailingTheSession() = runTest { + val transport = FakeSessionTransport() + transport.modelCatalogFailure = RelayFailure.Timeout + val store = RemoteSessionStore.create(this, transport) + + store.dispatch(RemoteSessionIntent.Load) + advanceUntilIdle() + + val ready = assertIs(store.state.value) + assertNull(ready.modelCatalog) + assertEquals(ModelCatalogFailure.LOAD_FAILED, ready.modelCatalogFailure) + assertTrue(ready.sessions.isNotEmpty()) + } + + @Test + fun refreshModelCatalogRecoversFromATransientFailureAndUpdatesTheTimeline() = runTest { + val transport = FakeSessionTransport() + transport.modelCatalogFailure = RelayFailure.Timeout + val store = RemoteSessionStore.create(this, transport) + + store.dispatch(RemoteSessionIntent.Load) + advanceUntilIdle() + assertEquals( + ModelCatalogFailure.LOAD_FAILED, + assertIs(store.state.value).modelCatalogFailure, + ) + store.dispatch(RemoteSessionIntent.Open("s-code")) + runCurrent() + store.dispatch(RemoteSessionIntent.UpdateDraft("keep-draft")) + assertEquals("keep-draft", assertIs(store.state.value).draft) + assertNull(assertIs(store.state.value).timeline?.modelCatalog?.defaultModels?.primary) + + transport.modelCatalogFailure = null + transport.commands.clear() + store.dispatch(RemoteSessionIntent.RefreshModelCatalog) + runCurrent() + + val ready = assertIs(store.state.value) + assertEquals("model-primary", ready.modelCatalog?.defaultModels?.primary) + assertNull(ready.modelCatalogFailure) + assertFalse(ready.busy) + assertEquals("s-code", ready.selectedSessionId) + assertEquals("model-primary", ready.timeline?.modelCatalog?.defaultModels?.primary) + assertEquals("keep-draft", ready.draft) + assertTrue(ready.sessions.isNotEmpty()) + // The refresh is the catalog command alone; it does not re-read the + // session list or the transcript it must keep on screen. + assertEquals(listOf("get_model_catalog"), transport.commands.map { it.cmd }) + store.dispatch(RemoteSessionIntent.Stop) + } + + @Test + fun modelCatalogContractIsTheStaticSupportedFact() { + assertEquals("get_model_catalog", ModelCatalogContract.commandName) + assertEquals(ModelCatalogSupport.SUPPORTED, ModelCatalogContract.support) } @Test @@ -130,6 +234,9 @@ class RemoteSessionStoreTest { assertEquals("/repo", create.workspacePath) assertEquals("code", create.agentType) assertEquals("review the parser", transport.commands.first { it.cmd == "send_message" }.content) + // The desktop routes send_message by agent type, so it must match the + // session that create_session just opened, not fall back to "agentic". + assertEquals("code", transport.commands.first { it.cmd == "send_message" }.agentType) assertEquals("s-new", assertIs(store.state.value).selectedSessionId) store.dispatch(RemoteSessionIntent.Stop) } @@ -307,6 +414,70 @@ class RemoteSessionStoreTest { assertNull(failed.remoteMessage) } + @Test + fun unknownPermissionModeSurfacesAsUnknownNotAsk() = runTest { + val transport = FakeSessionTransport() + transport.permissionModeJson = """{"resp":"ok","mode":"future_mode"}""" + val store = RemoteSessionStore.create(this, transport) + + store.dispatch(RemoteSessionIntent.Open("s-code")) + runCurrent() + + val ready = assertIs(store.state.value) + assertEquals(SessionPermissionMode.UNKNOWN, ready.permissionMode) + assertNull(ready.permissionModeFailure) + store.dispatch(RemoteSessionIntent.Stop) + } + + @Test + fun missingPermissionModeSurfacesAsUnknown() = runTest { + val transport = FakeSessionTransport() + transport.permissionModeJson = """{"resp":"ok"}""" + val store = RemoteSessionStore.create(this, transport) + + store.dispatch(RemoteSessionIntent.Open("s-code")) + runCurrent() + + val ready = assertIs(store.state.value) + assertEquals(SessionPermissionMode.UNKNOWN, ready.permissionMode) + assertNull(ready.permissionModeFailure) + store.dispatch(RemoteSessionIntent.Stop) + } + + @Test + fun editedToolApprovalIsGatedUnsupportedWithoutSending() = runTest { + val transport = FakeSessionTransport() + val store = RemoteSessionStore.create(this, transport) + + store.dispatch(RemoteSessionIntent.Open("s-code")) + runCurrent() + store.dispatch( + RemoteSessionIntent.ApproveTool("s-code", "tool-1", updatedInput = """{"x":1}"""), + ) + runCurrent() + + assertTrue(transport.commands.none { it.cmd == "confirm_tool" }) + val ready = assertIs(store.state.value) + assertFalse(ready.busy) + assertEquals(ToolApprovalEditSupport.UNSUPPORTED, ToolApprovalEditContract.support) + store.dispatch(RemoteSessionIntent.Stop) + } + + @Test + fun plainToolApprovalStillSendsConfirmTool() = runTest { + val transport = FakeSessionTransport() + val store = RemoteSessionStore.create(this, transport) + + store.dispatch(RemoteSessionIntent.Open("s-code")) + runCurrent() + store.dispatch(RemoteSessionIntent.ApproveTool("s-code", "tool-1")) + runCurrent() + + val approval = transport.commands.last { it.cmd == "confirm_tool" } + assertEquals("tool-1", approval.toolId) + store.dispatch(RemoteSessionIntent.Stop) + } + @Test fun aSessionStillOpensWhenItsPermissionModeCannotBeRead() = runTest { val transport = FakeSessionTransport() @@ -325,6 +496,77 @@ class RemoteSessionStoreTest { store.dispatch(RemoteSessionIntent.Stop) } + @Test + fun sendMessageCarriesTheSessionsAgentType() = runTest { + val transport = FakeSessionTransport() + val store = RemoteSessionStore.create(this, transport) + store.dispatch(RemoteSessionIntent.Load) + advanceUntilIdle() + store.dispatch(RemoteSessionIntent.Open("s-code")) + runCurrent() + + store.dispatch(RemoteSessionIntent.SendMessage("s-code", "hello")) + runCurrent() + + val sent = transport.commands.last { it.cmd == "send_message" } + assertEquals("s-code", sent.sessionId) + assertEquals("hello", sent.content) + // Matches the session opened above; without it the desktop defaults to + // "agentic" and rejects a turn for a differently typed session. + assertEquals("code", sent.agentType) + store.dispatch(RemoteSessionIntent.Stop) + } + + @Test + fun sendMessageFallsBackToTheLocallyCreatedRecordWhenTheFilterHidesTheSession() = runTest { + val transport = FakeSessionTransport() + val store = RemoteSessionStore.create(this, transport) + store.dispatch(RemoteSessionIntent.Load) + advanceUntilIdle() + store.dispatch(RemoteSessionIntent.SetAgentFilter(SessionAgentFilter.CODE)) + advanceUntilIdle() + + // Cowork is not visible in the Code tab, so after a no-instruction + // create the session stays selected but is filtered out of + // Ready.sessions. send_message must still resolve its agent type from + // the locally created record instead of omitting agent_type. + store.dispatch(RemoteSessionIntent.CreateSession("cowork", "", "", null)) + runCurrent() + val ready = assertIs(store.state.value) + assertEquals("s-new", ready.selectedSessionId) + assertTrue(ready.sessions.none { it.id == "s-new" }) + + store.dispatch(RemoteSessionIntent.SendMessage("s-new", "hello")) + runCurrent() + + val sent = transport.commands.last { it.cmd == "send_message" } + assertEquals("s-new", sent.sessionId) + // The desktop normalizes cowork/Cowork case on its side; the store must + // simply forward the created session's agent type instead of null. + assertEquals("cowork", sent.agentType) + store.dispatch(RemoteSessionIntent.Stop) + } + + @Test + fun sendFailureKeepsTheDraftTheComposerWasAboutToSend() = runTest { + val transport = FakeSessionTransport() + val store = RemoteSessionStore.create(this, transport) + store.dispatch(RemoteSessionIntent.Load) + advanceUntilIdle() + store.dispatch(RemoteSessionIntent.Open("s-code")) + runCurrent() + + store.dispatch(RemoteSessionIntent.UpdateDraft("keep me")) + transport.sendMessageFailure = RelayFailure.NetworkUnreachable + store.dispatch(RemoteSessionIntent.SendMessage("s-code", "keep me")) + runCurrent() + + val ready = assertIs(store.state.value) + assertEquals("keep me", ready.draft) + assertEquals(false, ready.busy) + store.dispatch(RemoteSessionIntent.Stop) + } + @Test fun aDroppedPollKeepsTheTranscriptAndTheNextResponseRestoresTheConnection() = runTest { val transport = FakeSessionTransport() @@ -440,6 +682,8 @@ private class FakeSessionTransport : RemoteCommandTransport { /** When set, the permission commands fail while everything else works. */ var permissionFailure: RelayFailure? = null + var permissionModeJson: String? = null + /** When set, `list_sessions` serves one row per offset so paging is observable. */ var paged: Boolean = false @@ -449,9 +693,15 @@ private class FakeSessionTransport : RemoteCommandTransport { /** When set, `list_sessions` fails below the desktop instead. */ var failure: RelayFailure? = null + /** When set, `get_model_catalog` fails with the selected transport result. */ + var modelCatalogFailure: RelayFailure? = null + /** When set, the open conversation's health poll fails below the desktop. */ var pollFailure: RelayFailure? = null + /** When set, `send_message` fails below the desktop while the draft is kept. */ + var sendMessageFailure: RelayFailure? = null + /** Poll payloads served in order; the last one repeats, as a quiet desktop does. */ var polls: List = listOf(IDLE_POLL) private var pollIndex = 0 @@ -475,9 +725,15 @@ private class FakeSessionTransport : RemoteCommandTransport { if (command.cmd == "poll_session") { pollFailure?.let { throw RelayTransportException(it) } } + if (command.cmd == "send_message") { + sendMessageFailure?.let { throw RelayTransportException(it) } + } if (command.cmd == "get_permission_mode" || command.cmd == "set_permission_mode") { permissionFailure?.let { throw RelayTransportException(it) } } + if (command.cmd == "get_model_catalog") { + modelCatalogFailure?.let { throw RelayTransportException(it) } + } val json = when (command.cmd) { "get_workspace_info" -> """{"resp":"ok","has_workspace":${workspacePath.isNotEmpty()},"path":"$workspacePath"}""" @@ -498,11 +754,11 @@ private class FakeSessionTransport : RemoteCommandTransport { } else { """{"resp":"ok","messages":$messages,"has_more":${olderMessages != null}}""" } - "get_permission_mode" -> """{"resp":"ok","mode":"ask"}""" + "get_permission_mode" -> permissionModeJson ?: """{"resp":"ok","mode":"ask"}""" "poll_session" -> polls[minOf(pollIndex++, polls.lastIndex)] "create_session" -> """{"resp":"ok","session_id":"s-new"}""" "send_message" -> """{"resp":"ok","turn_id":"t-1"}""" - "delete_session", "update_session_title", "answer_question", "set_permission_mode" -> + "delete_session", "update_session_title", "answer_question", "set_permission_mode", "confirm_tool" -> """{"resp":"ok"}""" else -> error("Unexpected command ${command.cmd}") } diff --git a/src/apps/mobile/shared/core-feature/src/iosMain/kotlin/com/bitfun/mobile/core/feature/account/AccountStore.ios.kt b/src/apps/mobile/shared/core-feature/src/iosMain/kotlin/com/bitfun/mobile/core/feature/account/AccountStore.ios.kt index 7daf95ab3b..761dc76c27 100644 --- a/src/apps/mobile/shared/core-feature/src/iosMain/kotlin/com/bitfun/mobile/core/feature/account/AccountStore.ios.kt +++ b/src/apps/mobile/shared/core-feature/src/iosMain/kotlin/com/bitfun/mobile/core/feature/account/AccountStore.ios.kt @@ -1,6 +1,7 @@ package com.bitfun.mobile.core.feature.account import com.bitfun.mobile.core.feature.CoreLog +import com.bitfun.mobile.core.persistence.iosPersistenceStores import com.bitfun.mobile.core.persistence.iosSecureStore import kotlinx.coroutines.CoroutineScope @@ -11,10 +12,14 @@ public fun AccountStore.Companion.create( deviceId: String, deviceName: String, log: CoreLog, -): AccountStore = AccountStore.create( - scope = scope, - backend = AccountStore.backend(log, emptySet()), - secureStore = iosSecureStore(service), - deviceId = deviceId, - deviceName = deviceName, -) +): AccountStore { + val persistence = iosPersistenceStores("bitfun-mobile.db") + return AccountStore.create( + scope = scope, + backend = AccountStore.backend(log, emptySet()), + secureStore = iosSecureStore(service), + deviceId = deviceId, + deviceName = deviceName, + persistence = persistence, + ) +} diff --git a/src/apps/mobile/shared/core-feature/src/iosMain/kotlin/com/bitfun/mobile/core/feature/pairing/PairingStore.ios.kt b/src/apps/mobile/shared/core-feature/src/iosMain/kotlin/com/bitfun/mobile/core/feature/pairing/PairingStore.ios.kt index 59e2dc94af..9b83f93ae4 100644 --- a/src/apps/mobile/shared/core-feature/src/iosMain/kotlin/com/bitfun/mobile/core/feature/pairing/PairingStore.ios.kt +++ b/src/apps/mobile/shared/core-feature/src/iosMain/kotlin/com/bitfun/mobile/core/feature/pairing/PairingStore.ios.kt @@ -1,6 +1,7 @@ package com.bitfun.mobile.core.feature.pairing import com.bitfun.mobile.core.feature.CoreLog +import com.bitfun.mobile.core.persistence.iosPersistenceStores import com.bitfun.mobile.core.persistence.iosSecureStore import kotlinx.coroutines.CoroutineScope @@ -9,9 +10,13 @@ public fun PairingStore.Companion.create( scope: CoroutineScope, device: DeviceIdentity, log: CoreLog, -): PairingStore = PairingStore.create( - scope = scope, - device = device, - protection = iosSecureStore("com.bitfun.mobile.pairing"), - log = log, -) +): PairingStore { + val persistence = iosPersistenceStores("bitfun-mobile.db") + return PairingStore.create( + scope = scope, + device = device, + protection = iosSecureStore("com.bitfun.mobile.pairing"), + log = log, + persistence = persistence, + ) +} diff --git a/src/apps/mobile/shared/core-persistence/build.gradle.kts b/src/apps/mobile/shared/core-persistence/build.gradle.kts index 90b3367ffc..7d397872d7 100644 --- a/src/apps/mobile/shared/core-persistence/build.gradle.kts +++ b/src/apps/mobile/shared/core-persistence/build.gradle.kts @@ -2,6 +2,7 @@ plugins { alias(libs.plugins.kotlin.multiplatform) alias(libs.plugins.android.kmp.library) alias(libs.plugins.sqldelight) + alias(libs.plugins.kotlin.serialization) } sqldelight { @@ -34,6 +35,7 @@ kotlin { commonMain.dependencies { api(project(":core-protocol")) implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.serialization.json) implementation(libs.multiplatform.settings) implementation(libs.sqldelight.runtime) } diff --git a/src/apps/mobile/shared/core-persistence/src/commonMain/kotlin/com/bitfun/mobile/core/persistence/ChatLocalStore.kt b/src/apps/mobile/shared/core-persistence/src/commonMain/kotlin/com/bitfun/mobile/core/persistence/ChatLocalStore.kt index 7f65c6767f..64baba42f0 100644 --- a/src/apps/mobile/shared/core-persistence/src/commonMain/kotlin/com/bitfun/mobile/core/persistence/ChatLocalStore.kt +++ b/src/apps/mobile/shared/core-persistence/src/commonMain/kotlin/com/bitfun/mobile/core/persistence/ChatLocalStore.kt @@ -3,6 +3,7 @@ package com.bitfun.mobile.core.persistence import app.cash.sqldelight.db.SqlDriver import com.bitfun.mobile.core.persistence.db.Chat_session import com.bitfun.mobile.core.persistence.db.MobileDatabase +import kotlinx.serialization.Serializable public data class PersistedChatSession public constructor( public val sessionId: String, @@ -138,12 +139,177 @@ public class SqlDelightChatLocalStore public constructor( ) } +@Serializable +public data class PersistedRemoteSession public constructor( + public val sessionId: String = "", + public val title: String = "", + public val agentType: String = "", + public val status: String = "", + public val updatedAt: String = "", + public val createdAt: String = "", + public val messageCount: Int = 0, + public val lastMessageId: String = "", + public val workspacePath: String? = null, + public val workspaceName: String? = null, +) + +@Serializable +public data class PersistedRemoteMessage public constructor( + public val messageId: String = "", + public val sessionId: String = "", + public val role: String = "", + public val text: String = "", + public val status: String = "", + public val timestamp: String? = null, + public val thinking: String? = null, + public val payloadJson: String = "{}", +) + +@Serializable +public data class PersistedRemoteCursor public constructor( + public val pollVersion: String = "", + public val knownMessageCount: Int = 0, + public val knownModelCatalogVersion: String = "", +) + +public interface RemoteSessionListStore { + public fun load(deviceKey: String): List + public fun save(deviceKey: String, sessions: List, hasMore: Boolean = false) + public fun hasMore(deviceKey: String): Boolean +} + +public interface RemoteTranscriptStore { + public fun load(deviceKey: String, sessionId: String): List + public fun append(deviceKey: String, sessionId: String, startSeq: Int, messages: List) + public fun replace(deviceKey: String, sessionId: String, messages: List) + public fun loadCursor(deviceKey: String, sessionId: String): PersistedRemoteCursor? + public fun saveCursor(deviceKey: String, sessionId: String, cursor: PersistedRemoteCursor) +} + +public class SqlDelightRemoteSessionListStore public constructor( + driver: SqlDriver, +) : RemoteSessionListStore { + private val queries = MobileDatabase(driver).mobileQueries + private var lastSignature = "" + + override fun load(deviceKey: String): List = + queries.selectRemoteSessions(deviceKey).executeAsList().map { row -> + PersistedRemoteSession(row.session_id, row.title, row.agent_type, row.status, + row.updated_at, row.created_at, row.message_count.toInt(), row.last_message_id, + row.workspace_path, row.workspace_name) + } + + override fun hasMore(deviceKey: String): Boolean = + queries.selectRemoteSessions(deviceKey).executeAsList().firstOrNull()?.has_more == 1L + + override fun save(deviceKey: String, sessions: List, hasMore: Boolean) { + if (deviceKey.isBlank()) return + val kept = sessions.take(20) + val signature = "$deviceKey|${hasMore}|${kept.joinToString { it.sessionId + ":" + it.updatedAt + ":" + it.messageCount }}" + if (signature == lastSignature) return + queries.transaction { + queries.deleteRemoteSessionsForDevice(deviceKey) + kept.forEach { session -> queries.upsertRemoteSession( + deviceKey, session.sessionId, session.title, session.agentType, session.status, + session.updatedAt, session.createdAt, session.messageCount.toLong(), session.lastMessageId, + session.workspacePath, session.workspaceName, if (hasMore) 1L else 0L) + } + } + lastSignature = signature + } +} + +public typealias RemoteSessionListRdbStore = SqlDelightRemoteSessionListStore + +public typealias RemoteChatLocalRdbStore = SqlDelightRemoteTranscriptStore + +public class SqlDelightRemoteTranscriptStore public constructor( + driver: SqlDriver, +) : RemoteTranscriptStore { + private val queries = MobileDatabase(driver).mobileQueries + private val resident = LinkedHashMap>() + + override fun load(deviceKey: String, sessionId: String): List { + val key = "$deviceKey::$sessionId" + resident[key]?.let { return it } + val result = queries.selectRemoteMessages(deviceKey, sessionId).executeAsList().map { row -> + PersistedRemoteMessage( + messageId = row.message_id, + sessionId = row.session_id, + role = row.role, + text = row.text, + status = row.status, + timestamp = row.timestamp, + thinking = row.thinking, + payloadJson = row.payload_json, + ) + } + remember(key, result) + return result + } + + override fun append(deviceKey: String, sessionId: String, startSeq: Int, messages: List) { + if (messages.isEmpty()) return + queries.transaction { + // Replacing the range makes retries idempotent and safely repairs a partial append. + queries.deleteRemoteMessagesFrom(deviceKey, sessionId, startSeq.toLong()) + messages.forEachIndexed { index, message -> saveRow(deviceKey, sessionId, startSeq + index, message) } + } + resident.remove("$deviceKey::$sessionId") + } + + override fun replace(deviceKey: String, sessionId: String, messages: List) { + queries.transaction { + queries.deleteRemoteMessages(deviceKey, sessionId) + messages.forEachIndexed { index, message -> saveRow(deviceKey, sessionId, index, message) } + } + remember("$deviceKey::$sessionId", messages) + } + + override fun loadCursor(deviceKey: String, sessionId: String): PersistedRemoteCursor? = + queries.selectRemoteCursor(deviceKey, sessionId).executeAsOneOrNull()?.let { + PersistedRemoteCursor(it.poll_version, it.known_message_count.toInt(), it.known_model_catalog_version) + } + + override fun saveCursor(deviceKey: String, sessionId: String, cursor: PersistedRemoteCursor) { + queries.upsertRemoteCursor(deviceKey, sessionId, cursor.pollVersion, + cursor.knownMessageCount.toLong(), cursor.knownModelCatalogVersion) + } + + private fun saveRow(deviceKey: String, sessionId: String, seq: Int, message: PersistedRemoteMessage) { + queries.upsertRemoteMessage(deviceKey, sessionId, seq.toLong(), message.messageId, message.role, + message.text, message.status, message.timestamp, message.thinking, message.payloadJson) + } + + private fun remember(key: String, messages: List) { + resident[key] = messages + while (resident.size > 3) resident.remove(resident.entries.first().key) + } +} + +private object EmptyRemoteSessionListStore : RemoteSessionListStore { + override fun load(deviceKey: String): List = emptyList() + override fun save(deviceKey: String, sessions: List, hasMore: Boolean) = Unit + override fun hasMore(deviceKey: String): Boolean = false +} + +private object EmptyRemoteTranscriptStore : RemoteTranscriptStore { + override fun load(deviceKey: String, sessionId: String): List = emptyList() + override fun append(deviceKey: String, sessionId: String, startSeq: Int, messages: List) = Unit + override fun replace(deviceKey: String, sessionId: String, messages: List) = Unit + override fun loadCursor(deviceKey: String, sessionId: String): PersistedRemoteCursor? = null + override fun saveCursor(deviceKey: String, sessionId: String, cursor: PersistedRemoteCursor) = Unit +} + public data class MobilePersistenceStores public constructor( public val drafts: DraftStore, public val chats: ChatLocalStore, + public val remoteSessions: RemoteSessionListStore = EmptyRemoteSessionListStore, + public val remoteTranscripts: RemoteTranscriptStore = EmptyRemoteTranscriptStore, ) public fun mobilePersistenceStores(driver: SqlDriver): MobilePersistenceStores = MobilePersistenceStores( - drafts = SqlDelightDraftStore(driver), - chats = SqlDelightChatLocalStore(driver), + drafts = SqlDelightDraftStore(driver), chats = SqlDelightChatLocalStore(driver), + remoteSessions = SqlDelightRemoteSessionListStore(driver), + remoteTranscripts = SqlDelightRemoteTranscriptStore(driver), ) diff --git a/src/apps/mobile/shared/core-persistence/src/commonMain/sqldelight/com/bitfun/mobile/core/persistence/db/2.sqm b/src/apps/mobile/shared/core-persistence/src/commonMain/sqldelight/com/bitfun/mobile/core/persistence/db/2.sqm new file mode 100644 index 0000000000..8efe6d5c97 --- /dev/null +++ b/src/apps/mobile/shared/core-persistence/src/commonMain/sqldelight/com/bitfun/mobile/core/persistence/db/2.sqm @@ -0,0 +1,41 @@ +-- v2 -> v3: additive remote session-list and transcript persistence. +CREATE TABLE IF NOT EXISTS remote_session_list ( + device_key TEXT NOT NULL, + session_id TEXT NOT NULL, + title TEXT NOT NULL, + agent_type TEXT NOT NULL, + status TEXT NOT NULL, + updated_at TEXT NOT NULL, + created_at TEXT NOT NULL, + message_count INTEGER NOT NULL, + last_message_id TEXT NOT NULL, + workspace_path TEXT, + workspace_name TEXT, + has_more INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (device_key, session_id) +); +CREATE INDEX IF NOT EXISTS remote_session_list_device_updated_index +ON remote_session_list(device_key, updated_at DESC); +CREATE TABLE IF NOT EXISTS remote_session_message ( + device_key TEXT NOT NULL, + session_id TEXT NOT NULL, + seq INTEGER NOT NULL, + message_id TEXT NOT NULL, + role TEXT NOT NULL, + text TEXT NOT NULL, + status TEXT NOT NULL, + timestamp TEXT, + thinking TEXT, + payload_json TEXT NOT NULL, + PRIMARY KEY (device_key, session_id, seq) +); +CREATE INDEX IF NOT EXISTS remote_session_message_device_session_index +ON remote_session_message(device_key, session_id, seq); +CREATE TABLE IF NOT EXISTS remote_session_cursor ( + device_key TEXT NOT NULL, + session_id TEXT NOT NULL, + poll_version TEXT NOT NULL DEFAULT '', + known_message_count INTEGER NOT NULL DEFAULT 0, + known_model_catalog_version TEXT NOT NULL DEFAULT '', + PRIMARY KEY (device_key, session_id) +); \ No newline at end of file diff --git a/src/apps/mobile/shared/core-persistence/src/commonMain/sqldelight/com/bitfun/mobile/core/persistence/db/Mobile.sq b/src/apps/mobile/shared/core-persistence/src/commonMain/sqldelight/com/bitfun/mobile/core/persistence/db/Mobile.sq index 15c7131042..72379d00c4 100644 --- a/src/apps/mobile/shared/core-persistence/src/commonMain/sqldelight/com/bitfun/mobile/core/persistence/db/Mobile.sq +++ b/src/apps/mobile/shared/core-persistence/src/commonMain/sqldelight/com/bitfun/mobile/core/persistence/db/Mobile.sq @@ -78,3 +78,78 @@ INSERT OR REPLACE INTO chat_draft(draft_id, text, updated_at) VALUES (?, ?, ?); deleteDraft: DELETE FROM chat_draft WHERE draft_id = ?; + +CREATE TABLE remote_session_list ( + device_key TEXT NOT NULL, + session_id TEXT NOT NULL, + title TEXT NOT NULL, + agent_type TEXT NOT NULL, + status TEXT NOT NULL, + updated_at TEXT NOT NULL, + created_at TEXT NOT NULL, + message_count INTEGER NOT NULL, + last_message_id TEXT NOT NULL, + workspace_path TEXT, + workspace_name TEXT, + has_more INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (device_key, session_id) +); + +CREATE INDEX remote_session_list_device_updated_index +ON remote_session_list(device_key, updated_at DESC); + +CREATE TABLE remote_session_message ( + device_key TEXT NOT NULL, + session_id TEXT NOT NULL, + seq INTEGER NOT NULL, + message_id TEXT NOT NULL, + role TEXT NOT NULL, + text TEXT NOT NULL, + status TEXT NOT NULL, + timestamp TEXT, + thinking TEXT, + payload_json TEXT NOT NULL, + PRIMARY KEY (device_key, session_id, seq) +); + +CREATE INDEX remote_session_message_device_session_index +ON remote_session_message(device_key, session_id, seq); + +CREATE TABLE remote_session_cursor ( + device_key TEXT NOT NULL, + session_id TEXT NOT NULL, + poll_version TEXT NOT NULL DEFAULT '', + known_message_count INTEGER NOT NULL DEFAULT 0, + known_model_catalog_version TEXT NOT NULL DEFAULT '', + PRIMARY KEY (device_key, session_id) +); + +selectRemoteSessions: +SELECT * FROM remote_session_list WHERE device_key = ? ORDER BY updated_at DESC, session_id ASC; + +upsertRemoteSession: +INSERT OR REPLACE INTO remote_session_list(device_key, session_id, title, agent_type, status, updated_at, created_at, message_count, last_message_id, workspace_path, workspace_name, has_more) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?); + +deleteRemoteSessionsForDevice: +DELETE FROM remote_session_list WHERE device_key = ?; + +selectRemoteMessages: +SELECT * FROM remote_session_message WHERE device_key = ? AND session_id = ? ORDER BY seq ASC; + +upsertRemoteMessage: +INSERT OR REPLACE INTO remote_session_message(device_key, session_id, seq, message_id, role, text, status, timestamp, thinking, payload_json) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?); + +deleteRemoteMessages: +DELETE FROM remote_session_message WHERE device_key = ? AND session_id = ?; + +deleteRemoteMessagesFrom: +DELETE FROM remote_session_message WHERE device_key = ? AND session_id = ? AND seq >= ?; + +upsertRemoteCursor: +INSERT OR REPLACE INTO remote_session_cursor(device_key, session_id, poll_version, known_message_count, known_model_catalog_version) +VALUES (?, ?, ?, ?, ?); + +selectRemoteCursor: +SELECT * FROM remote_session_cursor WHERE device_key = ? AND session_id = ?; diff --git a/src/apps/mobile/shared/core-persistence/src/jvmTest/kotlin/com/bitfun/mobile/core/persistence/RemotePersistenceStoreTest.kt b/src/apps/mobile/shared/core-persistence/src/jvmTest/kotlin/com/bitfun/mobile/core/persistence/RemotePersistenceStoreTest.kt new file mode 100644 index 0000000000..17c05ab53d --- /dev/null +++ b/src/apps/mobile/shared/core-persistence/src/jvmTest/kotlin/com/bitfun/mobile/core/persistence/RemotePersistenceStoreTest.kt @@ -0,0 +1,80 @@ +package com.bitfun.mobile.core.persistence + +import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver +import com.bitfun.mobile.core.persistence.db.MobileDatabase +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlinx.coroutines.test.runTest + +class RemotePersistenceStoreTest { + private suspend fun stores(): Pair { + val driver = JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY) + MobileDatabase.Schema.create(driver).await() + return SqlDelightRemoteSessionListStore(driver) to SqlDelightRemoteTranscriptStore(driver) + } + + @Test + fun roundTripsSessionListAndTranscriptInSequenceOrder() = runTest { + val (sessions, transcript) = stores() + sessions.save("device-a", listOf(session("s1", "2026-01-01")), hasMore = true) + assertEquals("s1", sessions.load("device-a").single().sessionId) + assertTrue(sessions.hasMore("device-a")) + transcript.append("device-a", "s1", 0, listOf(message("m0", "zero"), message("m1", "one"))) + assertEquals(listOf("zero", "one"), transcript.load("device-a", "s1").map { it.text }) + } + + @Test + fun emptyServerListClearsCachedSessionsOnColdStart() = runTest { + val (sessions, _) = stores() + sessions.save("device-a", listOf(session("s1", "2026-01-01")), hasMore = true) + assertTrue(sessions.load("device-a").isNotEmpty()) + sessions.save("device-a", emptyList()) + assertTrue(sessions.load("device-a").isEmpty()) + assertEquals(false, sessions.hasMore("device-a")) + } + + @Test + fun appendIsIdempotentWhenRetried() = runTest { + val (_, transcript) = stores() + val value = listOf(message("m0", "zero")) + transcript.append("device-a", "s1", 0, value) + transcript.append("device-a", "s1", 0, value) + assertEquals(listOf("m0"), transcript.load("device-a", "s1").map { it.messageId }) + } + + @Test + fun legacyAndCorruptPayloadsRemainOpaqueAndRetained() = runTest { + val (_, transcript) = stores() + transcript.replace("device-a", "s1", listOf(message("legacy", "column text").copy(payloadJson = "{}"))) + transcript.append("device-a", "s1", 1, listOf(message("broken", "safe text").copy(payloadJson = "not-json"))) + assertEquals(listOf("column text", "safe text"), transcript.load("device-a", "s1").map { it.text }) + assertEquals(listOf("legacy", "broken"), transcript.load("device-a", "s1").map { it.messageId }) + assertEquals(listOf("{}", "not-json"), transcript.load("device-a", "s1").map { it.payloadJson }) + } + + @Test + fun sessionListPrunesOldestPerDevice() = runTest { + val (sessions, _) = stores() + sessions.save("device-a", (20 downTo 0).map { session("s$it", "%04d".format(it)) }) + assertEquals(20, sessions.load("device-a").size) + assertTrue(sessions.load("device-a").none { it.sessionId == "s0" }) + } + + @Test + fun cursorRoundTripsPollAndCatalogVersions() = runTest { + val (_, transcript) = stores() + transcript.saveCursor("device-a", "s1", PersistedRemoteCursor("poll-7", 12, "models-3")) + assertEquals(PersistedRemoteCursor("poll-7", 12, "models-3"), transcript.loadCursor("device-a", "s1")) + } + + private fun session(id: String, updated: String) = PersistedRemoteSession( + sessionId = id, title = "Title $id", agentType = "remote", status = "ready", + updatedAt = updated, createdAt = updated, messageCount = 1, lastMessageId = "m0", + ) + + private fun message(id: String, text: String) = PersistedRemoteMessage( + messageId = id, sessionId = "s1", role = "assistant", text = text, + status = "completed", timestamp = id, thinking = null, payloadJson = "{}", + ) +} diff --git a/src/apps/mobile/shared/core-protocol/src/commonMain/kotlin/com/bitfun/mobile/core/protocol/ModelCatalogDtos.kt b/src/apps/mobile/shared/core-protocol/src/commonMain/kotlin/com/bitfun/mobile/core/protocol/ModelCatalogDtos.kt index 5cdaa30bc3..64af3b78ec 100644 --- a/src/apps/mobile/shared/core-protocol/src/commonMain/kotlin/com/bitfun/mobile/core/protocol/ModelCatalogDtos.kt +++ b/src/apps/mobile/shared/core-protocol/src/commonMain/kotlin/com/bitfun/mobile/core/protocol/ModelCatalogDtos.kt @@ -32,13 +32,13 @@ public data class RemoteReasoningCatalogProjection( @Serializable public data class RemoteModelConfig( - @SerialName("id") val id: String, - @SerialName("name") val name: String, - @SerialName("provider") val provider: String, - @SerialName("base_url") val baseUrl: String, - @SerialName("model_name") val modelName: String, + @SerialName("id") val id: String = "", + @SerialName("name") val name: String = "", + @SerialName("provider") val provider: String = "", + @SerialName("base_url") val baseUrl: String = "", + @SerialName("model_name") val modelName: String = "", @SerialName("context_window") val contextWindow: Int? = null, - @SerialName("enabled") val enabled: Boolean, + @SerialName("enabled") val enabled: Boolean = false, @SerialName("capabilities") val capabilities: List = emptyList(), @SerialName("reasoning") val reasoning: RemoteReasoningCatalogProjection? = null, ) @@ -53,16 +53,18 @@ public data class RemoteDefaultModels( /** * [version] lets the client send `known_model_catalog_version` and skip the - * payload when nothing changed, so this arrives rarely despite its size. + * payload when nothing changed, so this arrives rarely despite its size. Legacy + * or minimal peers may omit `version`; the default keeps the catalog decodable + * instead of failing the whole reply as it did when the field was required. * * It is a [Long] rather than an [Int] because the desktop derives it from the * catalog's last-modified time in milliseconds and masks it to 53 bits — an * epoch-millisecond value passes 2^31 and stays there, so every real desktop - * sends a number an [Int] cannot hold, and the whole poll reply fails to decode. + * sends a number an [Int] cannot hold. */ @Serializable public data class RemoteModelCatalog( - @SerialName("version") val version: Long, + @SerialName("version") val version: Long = 0L, @SerialName("models") val models: List = emptyList(), @SerialName("default_models") val defaultModels: RemoteDefaultModels = RemoteDefaultModels(), @SerialName("session_model_id") val sessionModelId: String? = null, diff --git a/src/apps/mobile/shared/core-protocol/src/commonMain/kotlin/com/bitfun/mobile/core/protocol/ProvisionPeerDeviceContract.kt b/src/apps/mobile/shared/core-protocol/src/commonMain/kotlin/com/bitfun/mobile/core/protocol/ProvisionPeerDeviceContract.kt new file mode 100644 index 0000000000..073011b0e2 --- /dev/null +++ b/src/apps/mobile/shared/core-protocol/src/commonMain/kotlin/com/bitfun/mobile/core/protocol/ProvisionPeerDeviceContract.kt @@ -0,0 +1,18 @@ +package com.bitfun.mobile.core.protocol + +/** + * Contract fact for the provision_peer_device command. + * + * provision_peer_device mints a full credential for a keyboard-less companion + * device (a watch) relayed through a phone. It is not an Android/mobile-phone + * product capability, so this client exposes no outbound command, response, or + * intent for it, only this exclusion fact. + */ +public enum class ProvisionPeerDeviceSupport { + UNSUPPORTED, +} + +public object ProvisionPeerDeviceContract { + public val support: ProvisionPeerDeviceSupport = ProvisionPeerDeviceSupport.UNSUPPORTED + public val commandName: String = "provision_peer_device" +} diff --git a/src/apps/mobile/shared/core-protocol/src/commonMain/kotlin/com/bitfun/mobile/core/protocol/RemoteCommand.kt b/src/apps/mobile/shared/core-protocol/src/commonMain/kotlin/com/bitfun/mobile/core/protocol/RemoteCommand.kt index fd76580cac..fce841ed7c 100644 --- a/src/apps/mobile/shared/core-protocol/src/commonMain/kotlin/com/bitfun/mobile/core/protocol/RemoteCommand.kt +++ b/src/apps/mobile/shared/core-protocol/src/commonMain/kotlin/com/bitfun/mobile/core/protocol/RemoteCommand.kt @@ -1,20 +1,47 @@ package com.bitfun.mobile.core.protocol +import kotlinx.serialization.KSerializer import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder import kotlinx.serialization.json.JsonElement /** Permission modes the desktop peer accepts for a session. */ -@Serializable +@Serializable(with = RemotePermissionModeSerializer::class) public enum class RemotePermissionMode { - @SerialName("ask") Ask, - - @SerialName("auto") Auto, - - @SerialName("full_access") FullAccess, + Unknown, +} + +public object RemotePermissionModeSerializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor( + "com.bitfun.mobile.core.protocol.RemotePermissionMode", + PrimitiveKind.STRING, + ) + + override fun serialize(encoder: Encoder, value: RemotePermissionMode) { + encoder.encodeString( + when (value) { + RemotePermissionMode.Ask -> "ask" + RemotePermissionMode.Auto -> "auto" + RemotePermissionMode.FullAccess -> "full_access" + RemotePermissionMode.Unknown -> "unknown" + }, + ) + } + + override fun deserialize(decoder: Decoder): RemotePermissionMode = when (decoder.decodeString()) { + "ask" -> RemotePermissionMode.Ask + "auto" -> RemotePermissionMode.Auto + "full_access" -> RemotePermissionMode.FullAccess + else -> RemotePermissionMode.Unknown + } } /** @@ -49,6 +76,13 @@ public data class RemoteCommand( @SerialName("known_model_catalog_version") val knownModelCatalogVersion: Long? = null, @SerialName("turn_id") val turnId: String? = null, @SerialName("tool_id") val toolId: String? = null, + /** + * The desktop confirm_tool handler accepts only tool_id today. This client + * never sends updated_input until a peer advertises support, and the + * core-feature store gates edited approvals to an explicit unsupported state + * so an edit is never silently dropped. + */ + @SerialName("updated_input") val updatedInput: JsonElement? = null, @SerialName("model_id") val modelId: String? = null, @SerialName("reason") val reason: String? = null, @SerialName("mode") val mode: RemotePermissionMode? = null, diff --git a/src/apps/mobile/shared/core-protocol/src/commonTest/kotlin/com/bitfun/mobile/core/protocol/ModelCatalogDtoTest.kt b/src/apps/mobile/shared/core-protocol/src/commonTest/kotlin/com/bitfun/mobile/core/protocol/ModelCatalogDtoTest.kt new file mode 100644 index 0000000000..561084dc58 --- /dev/null +++ b/src/apps/mobile/shared/core-protocol/src/commonTest/kotlin/com/bitfun/mobile/core/protocol/ModelCatalogDtoTest.kt @@ -0,0 +1,126 @@ +package com.bitfun.mobile.core.protocol + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlinx.serialization.json.JsonPrimitive + +class ModelCatalogDtoTest { + @Test + fun oldShapeCatalogUsesSafeDefaultsForMissingFields() { + val decoded = RelayJson.decodeFromString( + """{"models":[{"id":"legacy-id","name":"Legacy Model"}]}""", + ) + + assertEquals(0L, decoded.version) + assertEquals(1, decoded.models.size) + assertEquals( + RemoteModelConfig( + id = "legacy-id", + name = "Legacy Model", + ), + decoded.models.single(), + ) + } + + @Test + fun fullCurrentShapeCatalogPreservesAllValues() { + val decoded = RelayJson.decodeFromString( + """ + { + "version": 1700000000123, + "models": [ + { + "id": "cloud-main", + "name": "Cloud Main", + "provider": "cloud", + "base_url": "https://models.example.test/v1", + "model_name": "main-v2", + "context_window": 128000, + "enabled": true, + "capabilities": ["chat", "vision"], + "reasoning": { + "status": "ready", + "default_preset": "balanced", + "presets": [ + { + "id": "balanced", + "label": "Balanced", + "order": 1, + "actions": [ + {"type": "set_effort", "value": "medium", "enabled": true} + ], + "source": "catalog" + } + ] + } + } + ], + "default_models": { + "primary": "cloud-main", + "fast": "cloud-fast", + "search": "cloud-search", + "image_understanding": "cloud-vision" + }, + "session_model_id": "cloud-main" + } + """.trimIndent(), + ) + + val expected = RemoteModelCatalog( + version = 1700000000123L, + models = listOf( + RemoteModelConfig( + id = "cloud-main", + name = "Cloud Main", + provider = "cloud", + baseUrl = "https://models.example.test/v1", + modelName = "main-v2", + contextWindow = 128000, + enabled = true, + capabilities = listOf("chat", "vision"), + reasoning = RemoteReasoningCatalogProjection( + status = "ready", + defaultPreset = "balanced", + presets = listOf( + RemoteReasoningPresetDescriptor( + id = "balanced", + label = "Balanced", + order = 1, + actions = listOf( + RemoteReasoningPresetAction( + type = "set_effort", + value = JsonPrimitive("medium"), + enabled = true, + ), + ), + source = "catalog", + ), + ), + ), + ), + ), + defaultModels = RemoteDefaultModels( + primary = "cloud-main", + fast = "cloud-fast", + search = "cloud-search", + imageUnderstanding = "cloud-vision", + ), + sessionModelId = "cloud-main", + ) + + assertEquals(expected, decoded) + } + + @Test + fun missingOrEmptyModelsDecodeToAnEmptyList() { + val empty = RelayJson.decodeFromString( + """{"version":1,"models":[]}""", + ) + val absent = RelayJson.decodeFromString( + """{"version":1}""", + ) + + assertEquals(emptyList(), empty.models) + assertEquals(emptyList(), absent.models) + } +} diff --git a/src/apps/mobile/shared/core-protocol/src/commonTest/kotlin/com/bitfun/mobile/core/protocol/RemoteCommandTest.kt b/src/apps/mobile/shared/core-protocol/src/commonTest/kotlin/com/bitfun/mobile/core/protocol/RemoteCommandTest.kt index d6e0615f07..6ba056a534 100644 --- a/src/apps/mobile/shared/core-protocol/src/commonTest/kotlin/com/bitfun/mobile/core/protocol/RemoteCommandTest.kt +++ b/src/apps/mobile/shared/core-protocol/src/commonTest/kotlin/com/bitfun/mobile/core/protocol/RemoteCommandTest.kt @@ -4,6 +4,8 @@ import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertNull +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put import kotlin.test.assertTrue class RemoteCommandTest { @@ -27,6 +29,26 @@ class RemoteCommandTest { assertEquals("""{"cmd":"set_permission_mode","mode":"full_access"}""", encoded) } + @Test + fun permissionModeToleratesFutureWireValues() { + val future = RelayJson.decodeFromString( + PermissionModeResponse.serializer(), + """{"resp":"ok","mode":"future_mode"}""", + ) + val ask = RelayJson.decodeFromString( + PermissionModeResponse.serializer(), + """{"resp":"ok","mode":"ask"}""", + ) + val encoded = RelayJson.encodeToString( + RemoteCommand.serializer(), + RemoteCommand(cmd = "set_permission_mode", mode = RemotePermissionMode.Unknown), + ) + + assertEquals(RemotePermissionMode.Unknown, future.mode) + assertEquals(RemotePermissionMode.Ask, ask.mode) + assertEquals("""{"cmd":"set_permission_mode","mode":"unknown"}""", encoded) + } + @Test fun messageImagesUseDesktopImageContextsField() { val encoded = RelayJson.encodeToString( @@ -46,6 +68,87 @@ class RemoteCommandTest { ) } + @Test + fun updatedInputRoundTripsAndOldPeerPayloadDefaultsToNull() { + val encoded = RelayJson.encodeToString( + RemoteCommand.serializer(), + RemoteCommand( + cmd = "confirm_tool", + toolId = "t", + updatedInput = buildJsonObject { put("command", "ls") }, + ), + ) + val decoded = RelayJson.decodeFromString( + RemoteCommand.serializer(), + """{"cmd":"confirm_tool","tool_id":"t"}""", + ) + + assertTrue("\"updated_input\":{" in encoded, encoded) + assertEquals("t", decoded.toolId) + assertNull(decoded.updatedInput) + } + + @Test + fun sessionMessageCursorAndMoreFieldsKeepWireContract() { + val encoded = RelayJson.encodeToString( + RemoteCommand.serializer(), + RemoteCommand(cmd = "get_session_messages", sessionId = "s", limit = 100, beforeMessageId = "m9"), + ) + val trueResponse = RelayJson.decodeFromString( + SessionMessagesResponse.serializer(), + """{"resp":"ok","has_more":true}""", + ) + val falseResponse = RelayJson.decodeFromString( + SessionMessagesResponse.serializer(), + """{"resp":"ok","has_more":false}""", + ) + val absentResponse = RelayJson.decodeFromString( + SessionMessagesResponse.serializer(), + """{"resp":"ok"}""", + ) + + assertTrue("\"before_message_id\":\"m9\"" in encoded, encoded) + assertTrue(trueResponse.hasMore) + assertFalse(falseResponse.hasMore) + assertFalse(absentResponse.hasMore) + assertTrue(absentResponse.messages.isEmpty()) + } + + @Test + fun sendMessageImagesKeepMimeAndLegacyImageFields() { + val encoded = RelayJson.encodeToString( + RemoteCommand.serializer(), + RemoteCommand( + cmd = "send_message", + images = listOf(ImageAttachment("n", "data:image/png;base64,abc")), + imageContexts = listOf(RemoteImageContext("i", null, "data:image/png;base64,abc", "image/png", null)), + ), + ) + + assertTrue("\"mime_type\":\"image/png\"" in encoded, encoded) + assertTrue("\"images\":[{\"name\":\"n\",\"data_url\":\"data:image/png;base64,abc\"}]" in encoded, encoded) + } + + @Test + fun provisionPeerDeviceIsAnExplicitlyUnsupportedCommand() { + val provision = RelayJson.encodeToString( + RemoteCommand.serializer(), + RemoteCommand(cmd = "provision_peer_device"), + ) + val request = RelayJson.encodeToString( + RemoteCommand.serializer(), + RemoteCommand(cmd = "x", requestId = "r1"), + ) + + assertEquals("""{"cmd":"provision_peer_device"}""", provision) + assertFalse("device_id" in provision) + assertFalse("device_name" in provision) + assertFalse("request_id" in provision) + assertTrue("\"_request_id\":\"r1\"" in request, request) + assertFalse("\"request_id\"" in request, request) + assertEquals(ProvisionPeerDeviceSupport.UNSUPPORTED, ProvisionPeerDeviceContract.support) + } + @Test fun unchangedPollDecodesWithoutOptionalPayloads() { val decoded = RelayJson.decodeFromString( From 9ea370c82f29abeda6e75d77529688ad94bf17ee Mon Sep 17 00:00:00 2001 From: wgqqqqq Date: Fri, 28 Aug 2026 09:45:11 +0800 Subject: [PATCH 3/5] fix(mobile): complete iOS remote control parity Reconcile durable remote session creation and device directories across shared mobile state.\n\nMake iOS account and target authority explicit, fail closed when secure storage is unavailable, preserve exact session re-entry, and add focused migration and authority tests. --- src/apps/mobile/AGENTS.md | 5 +- .../ios/BitFun.xcodeproj/project.pbxproj | 26 +- .../Features/Chat/ChatTimelineView.swift | 14 +- .../Features/Shell/AccountPairingViews.swift | 696 +++ .../Features/Shell/MobileShellView.swift | 2465 +------- .../Shell/RemoteCreateSessionView.swift | 495 ++ .../Shell/RemoteFilePreviewView.swift | 303 + .../Features/Shell/RemoteSettingsViews.swift | 825 +++ .../BitFun/Features/Shell/SidebarView.swift | 217 +- .../Infrastructure/AccountFailureCopy.swift | 29 + .../MobileAppModel+Account.swift | 301 + .../MobileAppModel+FilePreview.swift | 346 ++ .../MobileAppModel+GeneralChat.swift | 294 + .../MobileAppModel+RemoteSession.swift | 1061 ++++ .../Infrastructure/MobileAppModel.swift | 1314 +--- .../Infrastructure/MobileCoreAdapter.swift | 426 +- .../Infrastructure/RemoteAuthorityGate.swift | 213 + .../BitFun/Resources/Localizable.xcstrings | 5338 ++++++++++++++++- src/apps/mobile/ios/README.md | 9 + .../ios/Testing/AccountFailureCopyTests.swift | 28 + .../Testing/RemoteAuthorityGateTests.swift | 436 ++ .../ios/Testing/run-pure-swift-tests.sh | 21 + .../shared/core-crypto/build.gradle.kts | 14 +- .../core/feature/account/AccountStore.kt | 229 +- .../core/feature/account/AccountUiState.kt | 11 + .../feature/directory/DeviceDirectoryStore.kt | 370 ++ .../directory/DeviceDirectoryUiState.kt | 116 + .../feature/generalchat/GeneralChatStore.kt | 31 +- .../feature/session/RemoteSessionStore.kt | 505 +- .../feature/session/RemoteSessionUiState.kt | 90 + .../feature/workspace/RemoteWorkspaceStore.kt | 83 +- .../workspace/RemoteWorkspaceUiState.kt | 48 +- .../core/feature/account/AccountStoreTest.kt | 316 +- .../directory/DeviceDirectoryStoreTest.kt | 367 ++ .../generalchat/GeneralChatStoreTest.kt | 65 + .../session/RemoteSessionPersistenceTest.kt | 119 +- .../feature/session/RemoteSessionStoreTest.kt | 504 +- .../workspace/RemoteWorkspaceStoreTest.kt | 122 +- .../mobile/core/persistence/ChatLocalStore.kt | 9 +- .../bitfun/mobile/core/persistence/db/3.sqm | 3 + .../mobile/core/persistence/db/Mobile.sq | 5 +- .../mobile/core/persistence/IosSecureStore.kt | 16 +- .../core/persistence/IosSecureStoreTest.kt | 10 + .../persistence/RemotePersistenceStoreTest.kt | 96 + 44 files changed, 14415 insertions(+), 3576 deletions(-) create mode 100644 src/apps/mobile/ios/BitFun/Features/Shell/AccountPairingViews.swift create mode 100644 src/apps/mobile/ios/BitFun/Features/Shell/RemoteCreateSessionView.swift create mode 100644 src/apps/mobile/ios/BitFun/Features/Shell/RemoteFilePreviewView.swift create mode 100644 src/apps/mobile/ios/BitFun/Features/Shell/RemoteSettingsViews.swift create mode 100644 src/apps/mobile/ios/BitFun/Infrastructure/AccountFailureCopy.swift create mode 100644 src/apps/mobile/ios/BitFun/Infrastructure/MobileAppModel+Account.swift create mode 100644 src/apps/mobile/ios/BitFun/Infrastructure/MobileAppModel+FilePreview.swift create mode 100644 src/apps/mobile/ios/BitFun/Infrastructure/MobileAppModel+GeneralChat.swift create mode 100644 src/apps/mobile/ios/BitFun/Infrastructure/MobileAppModel+RemoteSession.swift create mode 100644 src/apps/mobile/ios/BitFun/Infrastructure/RemoteAuthorityGate.swift create mode 100644 src/apps/mobile/ios/Testing/AccountFailureCopyTests.swift create mode 100644 src/apps/mobile/ios/Testing/RemoteAuthorityGateTests.swift create mode 100755 src/apps/mobile/ios/Testing/run-pure-swift-tests.sh create mode 100644 src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/bitfun/mobile/core/feature/directory/DeviceDirectoryStore.kt create mode 100644 src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/bitfun/mobile/core/feature/directory/DeviceDirectoryUiState.kt create mode 100644 src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/bitfun/mobile/core/feature/directory/DeviceDirectoryStoreTest.kt create mode 100644 src/apps/mobile/shared/core-persistence/src/commonMain/sqldelight/com/bitfun/mobile/core/persistence/db/3.sqm diff --git a/src/apps/mobile/AGENTS.md b/src/apps/mobile/AGENTS.md index bfbfd1e1f2..0e14126c64 100644 --- a/src/apps/mobile/AGENTS.md +++ b/src/apps/mobile/AGENTS.md @@ -72,7 +72,10 @@ UiState or an Intent declared there, and no module above it is visible to them. above, mirroring `pnpm run harmony:architecture`. - Build and test from `shared/`: `./gradlew jvmTest` for host logic, `./gradlew assembleAndroidMain testAndroidHostTest` for Android, and - `./gradlew compileKotlinIosSimulatorArm64` for iOS. `local.properties` holds + `./gradlew compileKotlinIosSimulatorArm64` for iOS. Run iOS pure Swift + infrastructure checks from the repository root with + `(cd src/apps/mobile/ios && ./Testing/run-pure-swift-tests.sh)`. + `local.properties` holds the local SDK path and is not committed. - The Android app builds from `android/`: `./gradlew :app:assembleDebug` and `:app:installDebug`; release verification is `./gradlew :app:assembleRelease`. diff --git a/src/apps/mobile/ios/BitFun.xcodeproj/project.pbxproj b/src/apps/mobile/ios/BitFun.xcodeproj/project.pbxproj index 1a9b7503fb..18bde05ac1 100644 --- a/src/apps/mobile/ios/BitFun.xcodeproj/project.pbxproj +++ b/src/apps/mobile/ios/BitFun.xcodeproj/project.pbxproj @@ -26,6 +26,16 @@ A10000000000000000000018 /* AdaptiveModalComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000018 /* AdaptiveModalComponents.swift */; }; A10000000000000000000019 /* SessionActionComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000019 /* SessionActionComponents.swift */; }; A10000000000000000000009 /* Resources.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000009 /* Resources.xcassets */; }; + A10000000000000000000020 /* RemoteCreateSessionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000020 /* RemoteCreateSessionView.swift */; }; + A10000000000000000000021 /* RemoteFilePreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000021 /* RemoteFilePreviewView.swift */; }; + A10000000000000000000022 /* RemoteSettingsViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000022 /* RemoteSettingsViews.swift */; }; + A10000000000000000000023 /* AccountPairingViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000023 /* AccountPairingViews.swift */; }; + A10000000000000000000024 /* MobileAppModel+FilePreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000024 /* MobileAppModel+FilePreview.swift */; }; + A10000000000000000000025 /* MobileAppModel+Account.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000025 /* MobileAppModel+Account.swift */; }; + A10000000000000000000026 /* MobileAppModel+RemoteSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000026 /* MobileAppModel+RemoteSession.swift */; }; + A10000000000000000000027 /* MobileAppModel+GeneralChat.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000027 /* MobileAppModel+GeneralChat.swift */; }; + A10000000000000000000028 /* RemoteAuthorityGate.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000028 /* RemoteAuthorityGate.swift */; }; + A10000000000000000000029 /* AccountFailureCopy.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000029 /* AccountFailureCopy.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -49,6 +59,16 @@ B10000000000000000000018 /* AdaptiveModalComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdaptiveModalComponents.swift; sourceTree = ""; }; B10000000000000000000019 /* SessionActionComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionActionComponents.swift; sourceTree = ""; }; B10000000000000000000009 /* Resources.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Resources.xcassets; sourceTree = ""; }; + B10000000000000000000020 /* RemoteCreateSessionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteCreateSessionView.swift; sourceTree = ""; }; + B10000000000000000000021 /* RemoteFilePreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteFilePreviewView.swift; sourceTree = ""; }; + B10000000000000000000022 /* RemoteSettingsViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteSettingsViews.swift; sourceTree = ""; }; + B10000000000000000000023 /* AccountPairingViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountPairingViews.swift; sourceTree = ""; }; + B10000000000000000000024 /* MobileAppModel+FilePreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MobileAppModel+FilePreview.swift"; sourceTree = ""; }; + B10000000000000000000025 /* MobileAppModel+Account.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MobileAppModel+Account.swift"; sourceTree = ""; }; + B10000000000000000000026 /* MobileAppModel+RemoteSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MobileAppModel+RemoteSession.swift"; sourceTree = ""; }; + B10000000000000000000027 /* MobileAppModel+GeneralChat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MobileAppModel+GeneralChat.swift"; sourceTree = ""; }; + B10000000000000000000028 /* RemoteAuthorityGate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteAuthorityGate.swift; sourceTree = ""; }; + B10000000000000000000029 /* AccountFailureCopy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountFailureCopy.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -61,8 +81,8 @@ D10000000000000000000002 /* App */ = {isa = PBXGroup; children = (B10000000000000000000001 /* BitFunApp.swift */); path = App; sourceTree = ""; }; D10000000000000000000003 /* Features */ = {isa = PBXGroup; children = (D10000000000000000000004 /* Chat */, D10000000000000000000005 /* Shell */, D10000000000000000000010 /* DesignSystem */); path = Features; sourceTree = ""; }; D10000000000000000000004 /* Chat */ = {isa = PBXGroup; children = (B10000000000000000000005 /* ConversationHeader.swift */, B10000000000000000000006 /* ChatTimelineView.swift */, B10000000000000000000007 /* ComposerBar.swift */); path = Chat; sourceTree = ""; }; - D10000000000000000000005 /* Shell */ = {isa = PBXGroup; children = (B10000000000000000000003 /* BitFunTheme.swift */, B10000000000000000000004 /* SidebarView.swift */, B10000000000000000000008 /* MobileShellView.swift */, B10000000000000000000019 /* SessionActionComponents.swift */); path = Shell; sourceTree = ""; }; - D10000000000000000000008 /* Infrastructure */ = {isa = PBXGroup; children = (B10000000000000000000002 /* MobileAppModel.swift */, B10000000000000000000010 /* MobileCoreAdapter.swift */, B10000000000000000000017 /* MobileLocalization.swift */); path = Infrastructure; sourceTree = ""; }; + D10000000000000000000005 /* Shell */ = {isa = PBXGroup; children = (B10000000000000000000003 /* BitFunTheme.swift */, B10000000000000000000004 /* SidebarView.swift */, B10000000000000000000008 /* MobileShellView.swift */, B10000000000000000000019 /* SessionActionComponents.swift */, B10000000000000000000020 /* RemoteCreateSessionView.swift */, B10000000000000000000021 /* RemoteFilePreviewView.swift */, B10000000000000000000022 /* RemoteSettingsViews.swift */, B10000000000000000000023 /* AccountPairingViews.swift */); path = Shell; sourceTree = ""; }; + D10000000000000000000008 /* Infrastructure */ = {isa = PBXGroup; children = (B10000000000000000000002 /* MobileAppModel.swift */, B10000000000000000000010 /* MobileCoreAdapter.swift */, B10000000000000000000017 /* MobileLocalization.swift */, B10000000000000000000024 /* MobileAppModel+FilePreview.swift */, B10000000000000000000025 /* MobileAppModel+Account.swift */, B10000000000000000000026 /* MobileAppModel+RemoteSession.swift */, B10000000000000000000027 /* MobileAppModel+GeneralChat.swift */, B10000000000000000000028 /* RemoteAuthorityGate.swift */, B10000000000000000000029 /* AccountFailureCopy.swift */); path = Infrastructure; sourceTree = ""; }; D10000000000000000000011 /* Resources */ = {isa = PBXGroup; children = (B10000000000000000000016 /* Localizable.xcstrings */); path = Resources; sourceTree = ""; }; D10000000000000000000009 /* Products */ = {isa = PBXGroup; children = (B10000000000000000000000 /* BitFun.app */); name = Products; sourceTree = ""; }; D10000000000000000000010 /* DesignSystem */ = {isa = PBXGroup; children = (B10000000000000000000013 /* GeneratedMobileDesignTokens.swift */, B10000000000000000000014 /* GeneratedMobilePreviewScenarios.swift */, B10000000000000000000015 /* MobileDesignGallery.swift */, B10000000000000000000018 /* AdaptiveModalComponents.swift */); path = DesignSystem; sourceTree = ""; }; @@ -77,7 +97,7 @@ /* End PBXProject section */ /* Begin PBXSourcesBuildPhase section */ - C10000000000000000000002 /* Sources */ = {isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = (A10000000000000000000001, A10000000000000000000002, A10000000000000000000003, A10000000000000000000004, A10000000000000000000005, A10000000000000000000006, A10000000000000000000007, A10000000000000000000008, A10000000000000000000010, A10000000000000000000013, A10000000000000000000014, A10000000000000000000015, A10000000000000000000017, A10000000000000000000018, A10000000000000000000019); runOnlyForDeploymentPostprocessing = 0; }; + C10000000000000000000002 /* Sources */ = {isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = (A10000000000000000000001, A10000000000000000000002, A10000000000000000000003, A10000000000000000000004, A10000000000000000000005, A10000000000000000000006, A10000000000000000000007, A10000000000000000000008, A10000000000000000000010, A10000000000000000000013, A10000000000000000000014, A10000000000000000000015, A10000000000000000000017, A10000000000000000000018, A10000000000000000000019, A10000000000000000000020, A10000000000000000000021, A10000000000000000000022, A10000000000000000000023, A10000000000000000000024, A10000000000000000000025, A10000000000000000000026, A10000000000000000000027, A10000000000000000000028, A10000000000000000000029); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXSourcesBuildPhase section */ /* Begin PBXResourcesBuildPhase section */ diff --git a/src/apps/mobile/ios/BitFun/Features/Chat/ChatTimelineView.swift b/src/apps/mobile/ios/BitFun/Features/Chat/ChatTimelineView.swift index 49b2fae340..d2f5f7b76d 100644 --- a/src/apps/mobile/ios/BitFun/Features/Chat/ChatTimelineView.swift +++ b/src/apps/mobile/ios/BitFun/Features/Chat/ChatTimelineView.swift @@ -77,11 +77,19 @@ private struct ConversationRowView: View { let row: MobileConversationRow @ObservedObject var model: MobileAppModel + @ViewBuilder var body: some View { switch row.kind { - case "EMPTY": EmptyConversationRow() - case "USER": userRow - default: assistantRow + case "EMPTY": + EmptyConversationRow() + case "USER": + userRow + .accessibilityElement(children: .contain) + .accessibilityIdentifier("message.user.\(row.id)") + default: + assistantRow + .accessibilityElement(children: .contain) + .accessibilityIdentifier("message.assistant.\(row.id)") } } diff --git a/src/apps/mobile/ios/BitFun/Features/Shell/AccountPairingViews.swift b/src/apps/mobile/ios/BitFun/Features/Shell/AccountPairingViews.swift new file mode 100644 index 0000000000..b480fd9f64 --- /dev/null +++ b/src/apps/mobile/ios/BitFun/Features/Shell/AccountPairingViews.swift @@ -0,0 +1,696 @@ +import AVFoundation +import BitFunMobileCore +import SwiftUI + +struct PairingSheet: View { + private enum Step { case intro, scan } + + @ObservedObject var model: MobileAppModel + @Environment(\.dismiss) private var dismiss + @State private var step: Step = .intro + @State private var pairingURL = ProcessInfo.processInfo.arguments.contains("--pairing-account") + ? "https://relay.example.com/#/pair?room=preview-room&pk=preview-key&auth=account&user=preview" + : "" + @State private var pairingUserID = "" + // Intentionally transient: pairing passwords must never enter saved scene state. + @State private var pairingPassword = "" + @State private var scannerOpen = false + @State private var manualOpen = false + @FocusState private var focused: Bool + + var body: some View { + return ZStack { + if step == .intro { introPage } else { scanPage } + if manualOpen { manualPairingOverlay } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(BitFunTheme.card) + .onAppear { + if model.pairingScanRequested { + step = .scan + scannerOpen = true + model.consumePairingScanRequest() + } else if ProcessInfo.processInfo.arguments.contains("--pairing-manual") || + ProcessInfo.processInfo.arguments.contains("--pairing-account") { + step = .scan + manualOpen = true + focused = !ProcessInfo.processInfo.arguments.contains("--pairing-account") + } + } + .fullScreenCover(isPresented: $scannerOpen) { + QRCodeScannerView { code in + pairingURL = code + scannerOpen = false + if PairingLinkHintsKt.inspectPairingLink(url: code).requiresAccount { + manualOpen = true + focused = true + } else { + model.submitPairing(url: code) + } + } + .ignoresSafeArea() + } + } + + private var introPage: some View { + VStack(spacing: 0) { + hero(height: 250) + VStack(spacing: 15) { + Image(systemName: "desktopcomputer") + .font(.system(size: 54, weight: .medium)) + .foregroundStyle(BitFunTheme.ink) + .frame(width: 88, height: 88) + .background(BitFunTheme.card) + .clipShape(RoundedRectangle(cornerRadius: 28)) + .shadow(color: BitFunTheme.line, radius: 18, y: 7) + Text(model.localized("选择连接方式")) + .font(.system(size: 24, weight: .bold)) + .foregroundStyle(BitFunTheme.ink) + } + .padding(.horizontal, 28) + .offset(y: -10) + Spacer(minLength: 12) + SignedOutConnectionActions( + scanTitle: model.localized("扫码连接"), + accountTitle: model.localized("登录 BitFun 账号"), + onScan: { + step = .scan + scannerOpen = true + }, + onOpenAccount: model.openAccountFromPairing, + enabled: !model.pairingBusy, + buttonHeight: 58, + spacing: 12, + fontSize: 20 + ) + .padding(.horizontal, 44) + .padding(.bottom, 34) + } + } + + private var scanPage: some View { + VStack(spacing: 0) { + hero(height: 252) + VStack(spacing: 22) { + Button { scannerOpen = true } label: { + Image(systemName: "qrcode.viewfinder") + .font(.system(size: 72, weight: .regular)) + .foregroundStyle(BitFunTheme.ink) + .frame(width: 176, height: 176) + .background(MobileDesignColors.connectHeroSurface) + .overlay(RoundedRectangle(cornerRadius: 34).stroke(BitFunTheme.line, lineWidth: 1.5)) + .clipShape(RoundedRectangle(cornerRadius: 34)) + } + .buttonStyle(.plain) + Text(model.localized("扫描二维码")) + .font(.system(size: 24, weight: .bold)).foregroundStyle(BitFunTheme.ink) + if let error = model.pairingError { + Text(error).font(.system(size: 13)).foregroundStyle(BitFunTheme.red) + .multilineTextAlignment(.center) + } + } + .offset(y: -50) + Spacer(minLength: 12) + Button { manualOpen = true; focused = true } label: { + Text(model.localized("手动输入配对码")) + .font(.system(size: 20, weight: .bold)) + .foregroundStyle(BitFunTheme.ink) + .frame(maxWidth: .infinity, minHeight: 58) + .background(BitFunTheme.card) + .overlay(Capsule().stroke(BitFunTheme.line, lineWidth: 1.5)) + .clipShape(Capsule()) + } + .buttonStyle(.plain) + .padding(.horizontal, 44) + .padding(.bottom, 34) + } + } + + private func hero(height: CGFloat) -> some View { + ZStack(alignment: .topLeading) { + LinearGradient( + colors: [MobileDesignColors.connectHeroBg, MobileDesignColors.connectHeroSurface], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + Button { + if step == .scan { step = .intro } else { dismiss() } + } label: { + Image(systemName: "chevron.left") + .font(.system(size: 20, weight: .medium)) + .foregroundStyle(BitFunTheme.ink) + .frame(width: 44, height: 44) + .background(BitFunTheme.card) + .clipShape(Circle()) + } + .buttonStyle(.plain) + .padding(.top, 18).padding(.leading, 18) + } + .frame(height: height) + } + + private var manualPairingOverlay: some View { + let hints = PairingLinkHintsKt.inspectPairingLink(url: pairingURL) + let effectiveUserID = pairingUserID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + ? hints.suggestedUserId + : pairingUserID.trimmingCharacters(in: .whitespacesAndNewlines) + let canSubmit = !model.pairingBusy && + !pairingURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && + (!hints.requiresAccount || (!effectiveUserID.isEmpty && !pairingPassword.isEmpty)) + + return ZStack { + MobileDesignColors.modalScrim + .ignoresSafeArea() + .onTapGesture { + if !model.pairingBusy { + pairingPassword = "" + manualOpen = false + } + } + VStack(alignment: .leading, spacing: 20) { + Text(model.localized(hints.requiresAccount ? "账号认证配对" : "手动输入配对码")) + .font(.system(size: 24, weight: .bold)).foregroundStyle(BitFunTheme.ink) + Text(model.localized( + hints.requiresAccount + ? "此桌面要求使用 BitFun 账号验证身份。" + : "输入桌面端显示的配对链接或代码。" + )) + .font(.system(size: 17)).foregroundStyle(BitFunTheme.muted).lineSpacing(5) + TextField(model.localized("配对码或连接链接"), text: $pairingURL) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .keyboardType(.URL) + .lineLimit(1) + .font(.system(size: 20)).foregroundStyle(BitFunTheme.ink) + .padding(.horizontal, 20).frame(minHeight: 62) + .background(BitFunTheme.soft).clipShape(Capsule()) + .focused($focused) + if hints.requiresAccount { + TextField( + hints.suggestedUserId.isEmpty + ? model.localized("BitFun 用户名") + : hints.suggestedUserId, + text: $pairingUserID + ) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .textContentType(.username) + .font(.system(size: 18)).foregroundStyle(BitFunTheme.ink) + .padding(.horizontal, 20).frame(minHeight: 56) + .background(BitFunTheme.soft).clipShape(Capsule()) + + SecureField(model.localized("BitFun 密码"), text: $pairingPassword) + .textContentType(.password) + .font(.system(size: 18)).foregroundStyle(BitFunTheme.ink) + .padding(.horizontal, 20).frame(minHeight: 56) + .background(BitFunTheme.soft).clipShape(Capsule()) + + Text(model.localized("账号凭据只用于本次加密配对,不会保存。")) + .font(.system(size: 13)) + .foregroundStyle(BitFunTheme.muted) + .lineSpacing(3) + } + if let error = model.pairingError { + Text(error).font(.system(size: 13)).foregroundStyle(BitFunTheme.red) + } + HStack(spacing: 12) { + pairingButton("取消", primary: false) { + pairingPassword = "" + manualOpen = false + focused = false + } + pairingButton(model.pairingBusy ? "正在连接" : "配对", primary: true) { + if hints.requiresAccount { + model.submitPairing( + url: pairingURL, + userID: effectiveUserID, + password: pairingPassword + ) + pairingPassword = "" + } else { + model.submitPairing(url: pairingURL) + } + focused = false + } + .disabled(!canSubmit) + } + } + .padding(.horizontal, 28).padding(.top, 30).padding(.bottom, 28) + .frame(maxWidth: 520) + .background(BitFunTheme.card) + .clipShape(RoundedRectangle(cornerRadius: 34)) + .overlay(RoundedRectangle(cornerRadius: 34).stroke(BitFunTheme.line, lineWidth: 1)) + .padding(.horizontal, 34) + } + } + + private func pairingButton(_ title: String, primary: Bool, action: @escaping () -> Void) -> some View { + Button(action: action) { + Text(model.localized(title)) + .font(.system(size: 19, weight: .bold)) + .foregroundStyle(primary ? Color.white : BitFunTheme.ink) + .frame(maxWidth: .infinity, minHeight: 58) + .background(primary ? BitFunTheme.accent : BitFunTheme.soft) + .clipShape(Capsule()) + } + .buttonStyle(.plain) + } +} + +struct QRCodeScannerView: UIViewControllerRepresentable { + let onCode: (String) -> Void + + func makeUIViewController(context: Context) -> QRScannerController { + let controller = QRScannerController() + controller.onCode = onCode + return controller + } + + func updateUIViewController(_ uiViewController: QRScannerController, context: Context) {} +} + +final class QRScannerController: UIViewController, AVCaptureMetadataOutputObjectsDelegate { + private let session = AVCaptureSession() + private var previewLayer: AVCaptureVideoPreviewLayer? + var onCode: ((String) -> Void)? + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .black + let close = UIButton(type: .system) + close.setImage(UIImage(systemName: "xmark"), for: .normal) + close.tintColor = .white + close.backgroundColor = UIColor.black.withAlphaComponent(0.55) + close.layer.cornerRadius = 22 + close.addAction(UIAction { [weak self] _ in self?.dismiss(animated: true) }, for: .touchUpInside) + close.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(close) + NSLayoutConstraint.activate([ + close.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 16), + close.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20), + close.widthAnchor.constraint(equalToConstant: 44), + close.heightAnchor.constraint(equalToConstant: 44), + ]) + + guard AVCaptureDevice.authorizationStatus(for: .video) != .denied else { return } + AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in + guard granted else { return } + DispatchQueue.main.async { self?.configureCapture() } + } + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + previewLayer?.frame = view.bounds + } + + private func configureCapture() { + guard let device = AVCaptureDevice.default(for: .video), + let input = try? AVCaptureDeviceInput(device: device), + session.canAddInput(input) else { return } + let output = AVCaptureMetadataOutput() + guard session.canAddOutput(output) else { return } + session.addInput(input) + session.addOutput(output) + output.setMetadataObjectsDelegate(self, queue: .main) + output.metadataObjectTypes = [.qr] + let layer = AVCaptureVideoPreviewLayer(session: session) + layer.videoGravity = .resizeAspectFill + view.layer.insertSublayer(layer, at: 0) + previewLayer = layer + session.startRunning() + } + + func metadataOutput( + _ output: AVCaptureMetadataOutput, + didOutput metadataObjects: [AVMetadataObject], + from connection: AVCaptureConnection, + ) { + guard let value = (metadataObjects.first as? AVMetadataMachineReadableCodeObject)?.stringValue, + !value.isEmpty else { return } + session.stopRunning() + onCode?(value) + dismiss(animated: true) + } +} + +struct AccountSettingsView: View { + @ObservedObject var model: MobileAppModel + var onClose: (() -> Void)? = nil + @State private var relayURL = AccountDefaults.shared.CLOUD_RELAY_URL + @State private var username = "" + @State private var password = "" + + var body: some View { + Group { + if model.accountFailureStage == "DEVICE_LIST", model.accountFailureCanRetry { + deviceListRetryPage + } else if model.accountUser == nil { + loginPage + } else { + profilePage + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(BitFunTheme.page) + } + + private var loginPage: some View { + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 0) { + Button { close() } label: { + Image(systemName: "chevron.left") + .font(.system(size: 19, weight: .medium)) + .foregroundStyle(BitFunTheme.ink) + .frame(width: 44, height: 44) + } + .buttonStyle(.plain) + .accessibilityLabel(model.localized("返回")) + + Text(model.localized("登录 BitFun 账号")) + .font(.system(size: 32, weight: .bold)) + .foregroundStyle(BitFunTheme.ink) + Text(model.localized("登录后可查看并连接账号下的桌面设备。")) + .font(.system(size: 15)) + .foregroundStyle(BitFunTheme.muted) + .lineSpacing(4) + .padding(.top, 12) + .padding(.bottom, 42) + + accountField(model.localized("用户名"), text: $username, secure: false, height: 58) + accountField(model.localized("密码"), text: $password, secure: true, height: 58) + .padding(.top, 14) + + Text(model.localized("登录服务器")) + .font(.system(size: 13)) + .foregroundStyle(BitFunTheme.muted) + .padding(.leading, 4) + .padding(.top, 26) + .padding(.bottom, 8) + accountField(model.localized("Relay 地址"), text: $relayURL, secure: false, height: 52) + + if let error = model.coreErrorMessage, !error.isEmpty { + Text(error) + .font(.system(size: 13)) + .foregroundStyle(BitFunTheme.red) + .padding(.top, 12) + } + + Button { + model.loginAccount(relayURL: relayURL, username: username, password: password) + password = "" + } label: { + HStack(spacing: 8) { + if model.accountBusy { ProgressView().tint(.white) } + Text(model.localized(model.accountBusy ? "正在登录" : "登录")) + } + .font(.system(size: 17, weight: .bold)) + .foregroundStyle(.white) + .frame(maxWidth: .infinity, minHeight: 56) + .background(canLogin ? BitFunTheme.accent : BitFunTheme.muted.opacity(0.35)) + .clipShape(RoundedRectangle(cornerRadius: 18)) + } + .buttonStyle(.plain) + .disabled(!canLogin) + .padding(.top, model.coreErrorMessage == nil ? 30 : 22) + } + .padding(.horizontal, 28) + .padding(.top, 22) + .padding(.bottom, 44) + } + } + + private var deviceListRetryPage: some View { + VStack(alignment: .leading, spacing: 0) { + Button { close() } label: { + Image(systemName: "chevron.left") + .font(.system(size: 19, weight: .medium)) + .foregroundStyle(BitFunTheme.ink) + .frame(width: 44, height: 44) + } + .buttonStyle(.plain) + .accessibilityLabel(model.localized("返回")) + + Spacer() + Image(systemName: "desktopcomputer.trianglebadge.exclamationmark") + .font(.system(size: 48, weight: .medium)) + .foregroundStyle(BitFunTheme.muted) + .frame(maxWidth: .infinity) + Text(model.localized("无法加载设备列表")) + .font(.system(size: 26, weight: .bold)) + .foregroundStyle(BitFunTheme.ink) + .frame(maxWidth: .infinity) + .padding(.top, 20) + Text(model.coreErrorMessage ?? model.localized("登录已完成,但设备列表加载失败。请重试。")) + .font(.system(size: 15)) + .foregroundStyle(BitFunTheme.muted) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) + .padding(.top, 10) + + Button { model.retryAccountFailure() } label: { + HStack(spacing: 8) { + if model.accountBusy { ProgressView().tint(.white) } + Text(model.localized(model.accountBusy ? "正在重试" : "重试加载设备")) + } + .font(.system(size: 17, weight: .bold)) + .foregroundStyle(.white) + .frame(maxWidth: .infinity, minHeight: 56) + .background(BitFunTheme.accent) + .clipShape(RoundedRectangle(cornerRadius: 18)) + } + .buttonStyle(.plain) + .disabled(model.accountBusy) + .padding(.top, 30) + + Button(model.localized("使用其他账号重新登录")) { + model.logoutAccount() + } + .font(.system(size: 15, weight: .medium)) + .foregroundStyle(BitFunTheme.ink) + .frame(maxWidth: .infinity, minHeight: 48) + .buttonStyle(.plain) + .disabled(model.accountBusy) + .padding(.top, 8) + Spacer() + } + .padding(.horizontal, 28) + .padding(.top, 22) + .padding(.bottom, 44) + } + + private var profilePage: some View { + VStack(alignment: .leading, spacing: 0) { + BitFunModalHeader(title: "个人资料", onClose: close) + .padding(.horizontal, MobileDesignGeometry.sheetHorizontalPadding) + .padding(.top, 8) + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 0) { + VStack(spacing: 10) { + ZStack { + Circle().fill(BitFunTheme.soft) + Image(systemName: "person.fill") + .font(.system(size: 34, weight: .medium)) + .foregroundStyle(BitFunTheme.ink) + } + .frame(width: 70, height: 70) + Text(model.accountUser ?? "") + .font(.system(size: 22, weight: .bold)) + .foregroundStyle(BitFunTheme.ink) + .lineLimit(1) + Text(profileIdentifier) + .font(.system(size: 14)) + .foregroundStyle(BitFunTheme.muted) + .lineLimit(1) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 24) + .background(BitFunTheme.card) + .clipShape(RoundedRectangle(cornerRadius: 28)) + .padding(.bottom, 24) + + VStack(alignment: .leading, spacing: 10) { + HStack { + Text(model.localized("BitFun 账号")) + .font(.system(size: 17, weight: .bold)) + .foregroundStyle(BitFunTheme.ink) + Spacer() + Text(model.localized("已登录")) + .font(.system(size: 14)) + .foregroundStyle(BitFunTheme.green) + } + Text(model.localizedFormat("当前以 %@ 登录。", model.accountUser ?? "")) + .font(.system(size: 14)) + .foregroundStyle(BitFunTheme.muted) + .lineSpacing(3) + } + .padding(.horizontal, 18) + .padding(.vertical, 16) + .background(BitFunTheme.card) + .clipShape(RoundedRectangle(cornerRadius: 24)) + .padding(.bottom, 24) + + VStack(alignment: .leading, spacing: 8) { + HStack { + Text(model.localized("设备管理")) + .font(.system(size: 17, weight: .bold)) + .foregroundStyle(BitFunTheme.ink) + Spacer() + Button { model.refreshRemoteDevices() } label: { + Text(model.localized(model.accountRefreshing ? "正在刷新" : "刷新")) + .font(.system(size: 13)) + .foregroundStyle(model.accountRefreshing ? BitFunTheme.muted : BitFunTheme.ink) + } + .buttonStyle(.plain) + .disabled(model.accountRefreshing) + } + VStack(spacing: 0) { + ForEach(Array(model.accountDevices.enumerated()), id: \.offset) { index, device in + Button { model.selectRemoteDevice(device) } label: { + SettingsDeviceRow(device: device) + } + .buttonStyle(.plain) + .disabled(!device.online && !device.selected) + if index < model.accountDevices.count - 1 { + Divider().overlay(BitFunTheme.line).padding(.horizontal, 20) + } + } + if model.accountDevices.isEmpty { + Text(model.localized("暂无可连接的桌面设备")) + .font(.system(size: 13)) + .foregroundStyle(BitFunTheme.muted) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 12) + } + } + } + .padding(.horizontal, 18) + .padding(.vertical, 16) + .background(BitFunTheme.card) + .clipShape(RoundedRectangle(cornerRadius: 24)) + .padding(.bottom, 24) + + Text(model.localized("个人资料详情")) + .font(.system(size: 18, weight: .bold)) + .foregroundStyle(BitFunTheme.muted) + .padding(.leading, 18) + .padding(.bottom, 8) + + VStack(spacing: 0) { + profileDetailRow(label: model.localized("用户 ID"), value: profileIdentifier) + Divider().overlay(BitFunTheme.line).padding(.horizontal, 18) + profileDetailRow( + label: model.localized("设备 ID"), + value: model.localDeviceID.isEmpty ? "-" : model.localDeviceID + ) + } + .background(BitFunTheme.card) + .clipShape(RoundedRectangle(cornerRadius: 28)) + + Button(role: .destructive) { + model.logoutAccount() + } label: { + Text(model.localized("退出账号")) + .font(.system(size: 16, weight: .medium)) + .foregroundStyle(BitFunTheme.red) + .frame(maxWidth: .infinity, minHeight: 54) + .background(BitFunTheme.card) + .clipShape(RoundedRectangle(cornerRadius: 16)) + } + .buttonStyle(.plain) + .padding(.top, 18) + } + .padding(.horizontal, MobileDesignGeometry.sheetHorizontalPadding) + .padding(.top, 20) + .padding(.bottom, 34) + } + } + } + + private var profileIdentifier: String { + model.accountUserID?.isEmpty == false ? model.accountUserID! : (model.accountUser ?? "-") + } + + private func profileDetailRow(label: String, value: String) -> some View { + HStack(spacing: 12) { + Text(label) + .font(.system(size: 16)) + .foregroundStyle(BitFunTheme.ink) + Spacer(minLength: 8) + Text(value) + .font(.system(size: 16)) + .foregroundStyle(BitFunTheme.muted) + .lineLimit(1) + .truncationMode(.middle) + .multilineTextAlignment(.trailing) + } + .frame(minHeight: 56) + .padding(.horizontal, 18) + } + + private var canLogin: Bool { + !model.accountBusy && + !relayURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && + !username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && + !password.isEmpty + } + + private func close() { + if let onClose { onClose() } else { model.accountSheetOpen = false } + } + + @ViewBuilder + private func accountField( + _ placeholder: String, + text: Binding, + secure: Bool, + height: CGFloat + ) -> some View { + Group { + if secure { SecureField(placeholder, text: text) } + else { TextField(placeholder, text: text) } + } + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .font(.system(size: height == 58 ? 17 : 14)) + .foregroundStyle(BitFunTheme.ink) + .padding(.horizontal, 20) + .frame(height: height) + .background(BitFunTheme.card) + .clipShape(RoundedRectangle(cornerRadius: height == 58 ? 18 : 16)) + } +} + +struct SettingsDeviceRow: View { + let device: MobileAccountDevice + + var body: some View { + HStack(spacing: 14) { + Image(systemName: "desktopcomputer") + .font(.system(size: 21, weight: .regular)) + .foregroundStyle(BitFunTheme.muted) + .frame(width: 42, height: 42) + VStack(alignment: .leading, spacing: 3) { + Text(device.name) + .font(.system(size: 16, weight: .medium)) + .foregroundStyle(BitFunTheme.ink) + .lineLimit(1) + Text(MobileLocalization.text(device.online ? "在线" : "离线")) + .font(.system(size: 12)) + .foregroundStyle(device.online ? BitFunTheme.green : BitFunTheme.muted) + } + Spacer(minLength: 12) + if device.selected { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 18)) + .foregroundStyle(BitFunTheme.green) + } else { + Image(systemName: "chevron.right") + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(BitFunTheme.muted) + } + } + .padding(.horizontal, 20) + .frame(minHeight: 76) + } +} diff --git a/src/apps/mobile/ios/BitFun/Features/Shell/MobileShellView.swift b/src/apps/mobile/ios/BitFun/Features/Shell/MobileShellView.swift index 15d80bc0d3..a5b64749ec 100644 --- a/src/apps/mobile/ios/BitFun/Features/Shell/MobileShellView.swift +++ b/src/apps/mobile/ios/BitFun/Features/Shell/MobileShellView.swift @@ -287,15 +287,17 @@ struct MobileShellView: View { model: model, onBack: { model.remoteCreateOpen = false } ) + .accessibilityElement(children: .contain) + .accessibilityIdentifier("conversation.draft") } else { conversationContent( sidebarAction: sidebarAction, sidebarActionLabel: sidebarActionLabel ) + .ignoresSafeArea(.keyboard, edges: .bottom) } } .background(BitFunTheme.page) - .ignoresSafeArea(.keyboard, edges: .bottom) } private func conversationContent( @@ -330,6 +332,18 @@ struct MobileShellView: View { ComposerBar(model: model) } } + .accessibilityElement(children: .contain) + .accessibilityIdentifier(conversationAccessibilityIdentifier) + } + + private var conversationAccessibilityIdentifier: String { + guard model.surface == .remote else { return "conversation.local" } + let sessionID = model.selectedSessionID + guard model.remoteSessionSelected, + !sessionID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + return "conversation.draft" + } + return "conversation.session.\(sessionID)" } @ViewBuilder @@ -340,2256 +354,333 @@ struct MobileShellView: View { } } -private struct RemoteCreateSessionView: View { - @ObservedObject var model: MobileAppModel - let onBack: () -> Void - @Environment(\.horizontalSizeClass) private var horizontalSizeClass - @StateObject private var speech = SpeechInputController() - @State private var instruction = "" - @State private var selectedWorkspacePath = "" - @State private var selectedModelID: String? - @State private var pickerKind: RemoteCreateSelectionKind? = ProcessInfo.processInfo.arguments.contains( - "--remote-create-workspace-picker" - ) ? .workspace : nil - - var body: some View { - VStack(spacing: 0) { - HStack { - Button(action: onBack) { - Image(systemName: "chevron.left") - .font(.system(size: 19, weight: .medium)) - .foregroundStyle(BitFunTheme.ink) - .frame(width: 44, height: 44) - .background(BitFunTheme.card) - .clipShape(Circle()) - } - .buttonStyle(.plain) - .accessibilityLabel(model.localized("返回")) - Spacer() - } - .frame(height: 78, alignment: .top) - .padding(.leading, 18) - .padding(.top, 14) - - Spacer(minLength: 12) - - if horizontalSizeClass == .regular, !model.accountDevices.isEmpty { - contextButton( - kind: .device, - icon: "desktopcomputer", - label: model.accountDeviceName ?? model.localized("选择桌面设备") - ) - } - contextButton( - kind: .workspace, - icon: selectedWorkspacePath.isEmpty ? "message" : "folder", - label: selectedWorkspaceName - ) - createComposer - } - .background(BitFunTheme.page) - .overlayPreferenceValue(RemoteCreateSelectionAnchorKey.self) { anchors in - GeometryReader { proxy in - if horizontalSizeClass == .regular, - let kind = pickerKind, - let anchor = anchors[kind] { - let frame = proxy[anchor] - ZStack(alignment: .topLeading) { - Color.clear - .contentShape(Rectangle()) - .onTapGesture { pickerKind = nil } - selectionContent(kind: kind, includeHeader: false) - .bitFunPopoverSurface() - .fixedSize(horizontal: false, vertical: true) - .position( - x: min( - max(MobileDesignGeometry.popoverWidth / 2 + 8, frame.midX), - proxy.size.width - MobileDesignGeometry.popoverWidth / 2 - 8 - ), - y: max(120, frame.minY - selectionHeight(kind) / 2 - 8) - ) - } - } - } - } - .sheet(item: compactPicker) { kind in - selectionContent(kind: kind, includeHeader: true) - .presentationDetents([.height(selectionHeight(kind))]) - .presentationDragIndicator(.visible) - } - .onAppear { - if let selected = model.remoteWorkspaces.first(where: \.selected) { - selectedWorkspacePath = selected.path - } - selectedModelID = model.modelOptions.first(where: \.selected)?.id ?? model.modelOptions.first?.id - } - } - - private var compactPicker: Binding { - Binding( - get: { horizontalSizeClass == .regular ? nil : pickerKind }, - set: { pickerKind = $0 } - ) - } - - private var selectedWorkspaceName: String { - guard !selectedWorkspacePath.isEmpty else { return model.localized("对话") } - return model.remoteWorkspaces.first(where: { $0.path == selectedWorkspacePath })?.name - ?? selectedWorkspacePath - } - - private var selectedModel: ComposerModelOption? { - model.modelOptions.first(where: { $0.id == selectedModelID }) ?? model.modelOptions.first - } - - private func contextButton(kind: RemoteCreateSelectionKind, icon: String, label: String) -> some View { - Button { pickerKind = kind } label: { - HStack(spacing: 13) { - Image(systemName: icon) - .font(.system(size: 20, weight: .medium)) - .foregroundStyle(BitFunTheme.muted) - .frame(width: 26, height: 26) - Text(label) - .font(.system(size: 16, weight: .medium)) - .foregroundStyle(BitFunTheme.ink) - .lineLimit(1) - Image(systemName: pickerKind == kind ? "chevron.up" : "chevron.down") - .font(.system(size: 12, weight: .semibold)) - .foregroundStyle(BitFunTheme.muted) - Spacer(minLength: 0) - } - .frame(height: 48) - .padding(.horizontal, 28) - } - .buttonStyle(.plain) - .disabled(model.busy) - .anchorPreference(key: RemoteCreateSelectionAnchorKey.self, value: .bounds) { - [kind: $0] - } - } - private var createComposer: some View { - VStack(spacing: 2) { - TextField( - "", - text: $instruction, - prompt: Text(model.localized(speech.isListening ? "正在聆听" : "告诉 BitFun 要做什么")) - .foregroundColor(speech.isListening ? BitFunTheme.green : BitFunTheme.muted), - axis: .vertical - ) - .font(MobileDesignTypography.bodyLarge.font) - .lineLimit(1...4) - .padding(.horizontal, 6) - .frame(minHeight: MobileDesignGeometry.composerExpandedInputRowHeight) - HStack(spacing: 8) { - if let selectedModel { - Button { pickerKind = .model } label: { - HStack(spacing: 4) { - Text(selectedModel.primaryLabel) - .font(.system(size: 13, weight: .medium)) - .foregroundStyle(BitFunTheme.ink) - .lineLimit(1) - Image(systemName: pickerKind == .model ? "chevron.up" : "chevron.down") - .font(.system(size: 10, weight: .semibold)) - .foregroundStyle(BitFunTheme.muted) - } - .frame(height: 34) - } - .buttonStyle(.plain) - .anchorPreference(key: RemoteCreateSelectionAnchorKey.self, value: .bounds) { - [.model: $0] - } - } - Spacer(minLength: 0) - Button(action: primaryAction) { - Image(systemName: instruction.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - ? (speech.isListening ? "stop.fill" : "mic.fill") - : "arrow.up") - .font(.system(size: 17, weight: .semibold)) - .foregroundStyle(canSubmit ? Color.white : BitFunTheme.ink) - .frame( - width: MobileDesignGeometry.composerActionSize, - height: MobileDesignGeometry.composerActionSize - ) - .background(canSubmit ? BitFunTheme.accent : BitFunTheme.soft) - .clipShape(Circle()) - } - .buttonStyle(.plain) - .disabled(model.busy || !model.remoteConnected) - } - .frame(height: MobileDesignGeometry.composerExpandedActionRowHeight) - } - .padding(.horizontal, 8) - .padding(.top, 4) - .padding(.bottom, 2) - .frame(minHeight: MobileDesignGeometry.composerExpandedHeight) - .background(BitFunTheme.card) - .clipShape(RoundedRectangle(cornerRadius: MobileDesignGeometry.composerExpandedRadius)) - .shadow(color: .black.opacity(0.05), radius: 10, y: 2) - .padding(.horizontal, MobileDesignGeometry.contentGutter) - .padding(.top, 8) - .padding(.bottom, 14) - } - private var canSubmit: Bool { - !instruction.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && - model.remoteConnected && !model.busy - } +private struct LocalHomeView: View { + @ObservedObject var model: MobileAppModel - private func primaryAction() { - let value = instruction.trimmingCharacters(in: .whitespacesAndNewlines) - if !value.isEmpty { - guard canSubmit else { return } - model.createRemoteSession( - agentType: selectedWorkspacePath.isEmpty ? "Claw" : "code", - title: "", - instruction: value, - modelID: selectedModelID - ) - instruction = "" - return - } - if speech.isListening { - speech.stop() - return - } - speech.start( - localeIdentifier: model.appLanguage == .simplifiedChinese ? "zh-CN" : "en-US", - onPartial: { instruction = $0 }, - onFailure: { model.showToast(model.localized($0)) } - ) - } + private let prompts: [(String, String)] = [ + ("Aa", "帮我写点内容"), + ("≡", "梳理一个问题"), + ("✓", "制定行动计划") + ] - @ViewBuilder - private func selectionContent(kind: RemoteCreateSelectionKind, includeHeader: Bool) -> some View { + var body: some View { VStack(spacing: 0) { - if includeHeader { - BitFunSelectionHeader(title: kind.title, onClose: { pickerKind = nil }) - } - ScrollView(showsIndicators: false) { - VStack(spacing: 0) { - switch kind { - case .device: - ForEach(model.accountDevices) { device in - selectionRow( - icon: "desktopcomputer", - title: device.name.isEmpty ? device.id : device.name, - subtitle: model.localized(device.online ? "在线" : "离线"), - selected: device.selected, - enabled: device.online || device.selected - ) { - pickerKind = nil - model.selectRemoteDevice(device) - } - } - case .workspace: - selectionRow( - icon: "message", - title: model.localized("对话"), - subtitle: "", - selected: selectedWorkspacePath.isEmpty, - enabled: true - ) { - selectedWorkspacePath = "" - pickerKind = nil - if let assistant = model.remoteAssistants.first { - model.selectRemoteAssistant(assistant) - } - } - ForEach(model.remoteWorkspaces) { workspace in - selectionRow( - icon: "folder", - title: workspace.name, - subtitle: workspace.path, - selected: workspace.path == selectedWorkspacePath, - enabled: true - ) { - selectedWorkspacePath = workspace.path - pickerKind = nil - model.selectRemoteWorkspace(workspace) - } - } - case .model: - if model.modelOptions.isEmpty { - Text(model.localized("暂无可用模型")) - .font(.system(size: 13)) + Spacer(minLength: 0) + VStack(spacing: 12) { + ForEach(prompts, id: \.1) { icon, title in + let promptText = model.localized(title) + Button { + model.draft = promptText + model.send() + } label: { + HStack(spacing: 20) { + Text(icon) + .font(.system(size: 29, weight: .regular)) .foregroundStyle(BitFunTheme.muted) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(18) - } else { - ForEach(model.modelOptions) { option in - selectionRow( - icon: option.source == "LOCAL" ? "gearshape" : "cloud", - title: option.primaryLabel, - subtitle: option.secondaryLabel, - selected: option.id == selectedModelID, - enabled: true - ) { - selectedModelID = option.id - pickerKind = nil - } - } + .frame(width: 32) + .fixedSize() + Text(promptText) + .font(.system(size: 20, weight: .medium)) + .foregroundStyle(BitFunTheme.muted) + Spacer(minLength: 0) } + .frame(height: 48) + .contentShape(Rectangle()) } + .buttonStyle(.plain) } } + .padding(.horizontal, 20) + .padding(.bottom, 12) } - .background(BitFunTheme.card) - } - - private func selectionRow( - icon: String, - title: String, - subtitle: String, - selected: Bool, - enabled: Bool, - action: @escaping () -> Void - ) -> some View { - Button(action: action) { - HStack(spacing: 12) { - Image(systemName: selected ? "checkmark.circle" : "circle") - .font(.system(size: 19)) - .foregroundStyle(selected ? BitFunTheme.ink : Color.clear) - .frame(width: 20) - Image(systemName: icon) - .font(.system(size: 19, weight: .medium)) - .foregroundStyle(BitFunTheme.muted) - .frame(width: 24) - VStack(alignment: .leading, spacing: 2) { - Text(title) - .font(.system(size: 15, weight: .medium)) - .foregroundStyle(BitFunTheme.ink) - .lineLimit(1) - if !subtitle.isEmpty { - Text(subtitle) - .font(.system(size: 11)) - .foregroundStyle(BitFunTheme.muted) - .lineLimit(1) - } - } - Spacer(minLength: 0) - } - .frame(minHeight: 58) - .padding(.horizontal, 12) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .disabled(!enabled) - .opacity(enabled ? 1 : 0.55) - } - - private func selectionHeight(_ kind: RemoteCreateSelectionKind) -> CGFloat { - let count: Int - switch kind { - case .device: count = max(1, model.accountDevices.count) - case .workspace: count = max(1, model.remoteWorkspaces.count + 1) - case .model: count = max(1, model.modelOptions.count) - } - let header: CGFloat = horizontalSizeClass == .regular ? 16 : MobileDesignGeometry.sheetHeaderHeight - return min(440, header + CGFloat(count * 64) + 24) - } -} - -private enum RemoteCreateSelectionKind: String, Identifiable, Hashable { - case device - case workspace - case model - - var id: String { rawValue } - var title: String { - switch self { - case .device: return "桌面设备" - case .workspace: return "工作区" - case .model: return "选择模型" - } - } -} - -private struct RemoteCreateSelectionAnchorKey: PreferenceKey { - static var defaultValue: [RemoteCreateSelectionKind: Anchor] = [:] - - static func reduce( - value: inout [RemoteCreateSelectionKind: Anchor], - nextValue: () -> [RemoteCreateSelectionKind: Anchor] - ) { - value.merge(nextValue(), uniquingKeysWith: { _, next in next }) - } -} - -private struct MobileDownloadDocument: FileDocument { - static var readableContentTypes: [UTType] { [.data] } - let data: Data - - init(data: Data) { - self.data = data - } - - init(configuration: ReadConfiguration) throws { - data = configuration.file.regularFileContents ?? Data() - } - - func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { - FileWrapper(regularFileWithContents: data) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(BitFunTheme.page) } } -private struct RemoteFilePreviewSheet: View { +private struct RemoteHomeView: View { @ObservedObject var model: MobileAppModel - let preview: MobileFilePreview - var embedded = false - @Environment(\.dismiss) private var dismiss var body: some View { - VStack(spacing: 0) { - HStack(spacing: 12) { - Image(systemName: preview.imageData == nil ? "doc.text" : "photo") - .font(.system(size: 16, weight: .medium)) - .foregroundStyle(MobileDesignColors.fileLink) - .frame(width: 34, height: 34) - .background(MobileDesignColors.fileLink.opacity(0.1)) - .clipShape(RoundedRectangle(cornerRadius: 9)) - Text(preview.name) - .font(MobileDesignTypography.titleSmall.font) + ZStack(alignment: .topTrailing) { + VStack(spacing: 12) { + Spacer() + ZStack { + Image(systemName: "desktopcomputer") + .font(.system(size: 42, weight: .medium)) .foregroundStyle(BitFunTheme.ink) - .lineLimit(1) - Spacer() - Button { - model.downloadRemoteFile( - reference: "computer://\(preview.id)", - label: preview.name - ) - } label: { - Image(systemName: "arrow.down.circle") - .font(.system(size: 18, weight: .medium)) - .foregroundStyle(BitFunTheme.ink) - .frame(width: 36, height: 36) - } - .buttonStyle(.plain) - .accessibilityLabel(Text(model.localizedFormat("下载 %@", preview.name))) - Button { - model.dismissFilePreview() - if !embedded { dismiss() } - } label: { - Image(systemName: "xmark") - .font(.system(size: 14, weight: .medium)) - .foregroundStyle(BitFunTheme.ink) - .frame(width: 36, height: 36) - .background(BitFunTheme.soft) - .clipShape(Circle()) - } - .buttonStyle(.plain) - .accessibilityLabel(Text(model.localized("关闭文件预览"))) - } - .padding(.horizontal, 18) - .padding(.vertical, 12) - - Rectangle().fill(BitFunTheme.line).frame(height: 1) - - Group { - if model.filePreviewLoading { - VStack(spacing: 12) { - ProgressView() - Text(model.localized("正在加载文件")) - .font(MobileDesignTypography.bodySmall.font) - .foregroundStyle(BitFunTheme.muted) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if let failure = preview.failure { - VStack(spacing: 10) { - Image(systemName: "exclamationmark.triangle") - .font(.system(size: 28, weight: .medium)) - Text(model.localized("无法预览")) - .font(MobileDesignTypography.titleSmall.font) - Text(failure) - .font(MobileDesignTypography.bodySmall.font) - .multilineTextAlignment(.center) - } - .foregroundStyle(BitFunTheme.muted) - .padding(24) - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if let data = preview.imageData, let image = UIImage(data: data) { - ScrollView([.horizontal, .vertical], showsIndicators: false) { - Image(uiImage: image) - .resizable() - .scaledToFit() - .padding(18) - } - } else { - ScrollView(showsIndicators: false) { - if preview.mimeType.contains("markdown") || preview.name.lowercased().hasSuffix(".md") { - MarkdownMessageView(text: preview.content, model: model) - .padding(18) - } else { - Text(preview.content) - .font(.system(size: 13, design: .monospaced)) - .foregroundStyle(BitFunTheme.ink) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(18) - .textSelection(.enabled) - } - } - } } - .frame(maxWidth: .infinity, maxHeight: .infinity) - - if preview.truncated { - Text(model.localized("文件较大,当前仅显示部分内容")) - .font(MobileDesignTypography.labelSmall.font) - .foregroundStyle(BitFunTheme.muted) - .frame(maxWidth: .infinity) - .padding(.vertical, 10) - .background(BitFunTheme.soft) + .frame(width: 74, height: 74) + .background(BitFunTheme.card) + .overlay(RoundedRectangle(cornerRadius: 24).stroke(BitFunTheme.line, lineWidth: 1)) + .clipShape(RoundedRectangle(cornerRadius: 24)) + Text(model.localized("连接桌面端")) + .font(.system(size: 18, weight: .bold)) + .foregroundStyle(BitFunTheme.ink) + Text(model.localized("扫描桌面端显示的二维码,开始远程处理任务。")) + .font(.system(size: 13)) + .foregroundStyle(BitFunTheme.muted) + .multilineTextAlignment(.center) + .lineSpacing(7) + .padding(.horizontal, 20) + Button(model.localized("连接")) { model.connectRemote() } + .font(.system(size: 15, weight: .medium)) + .foregroundStyle(.white) + .frame(width: 136, height: 44) + .background(BitFunTheme.accent) + .clipShape(Capsule()) + Spacer() } + remoteSettingsButton } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(.horizontal, 20) + .padding(.bottom, 48) .background(BitFunTheme.page) - .presentationDetents([.large]) - .presentationDragIndicator(.visible) } -} -private struct PairingSheet: View { - private enum Step { case intro, scan } + private var remoteSettingsButton: some View { + Button { model.remoteControlSettingsOpen = true } label: { + Image(systemName: "gearshape") + .font(.system(size: 18, weight: .medium)) + .foregroundStyle(BitFunTheme.ink) + .frame(width: 44, height: 44) + .background(BitFunTheme.card) + .overlay(Circle().stroke(BitFunTheme.line, lineWidth: 1)) + .clipShape(Circle()) + } + .buttonStyle(.plain) + .accessibilityLabel(model.localized("远程控制设置")) + .padding(.top, 16).padding(.trailing, 16) + } +} +private struct RemoteConnectedHomeView: View { @ObservedObject var model: MobileAppModel - @Environment(\.dismiss) private var dismiss - @State private var step: Step = .intro - @State private var pairingURL = ProcessInfo.processInfo.arguments.contains("--pairing-account") - ? "https://relay.example.com/#/pair?room=preview-room&pk=preview-key&auth=account&user=preview" - : "" - @State private var pairingUserID = "" - // Intentionally transient: pairing passwords must never enter saved scene state. - @State private var pairingPassword = "" - @State private var scannerOpen = false - @State private var manualOpen = false - @FocusState private var focused: Bool var body: some View { - return ZStack { - if step == .intro { introPage } else { scanPage } - if manualOpen { manualPairingOverlay } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(BitFunTheme.card) - .onAppear { - if model.pairingScanRequested { - step = .scan - scannerOpen = true - model.consumePairingScanRequest() - } else if ProcessInfo.processInfo.arguments.contains("--pairing-manual") || - ProcessInfo.processInfo.arguments.contains("--pairing-account") { - step = .scan - manualOpen = true - focused = !ProcessInfo.processInfo.arguments.contains("--pairing-account") + ZStack(alignment: .topTrailing) { + VStack(spacing: 14) { + Spacer() + Image(systemName: "desktopcomputer.and.macbook") + .font(.system(size: 34, weight: .medium)).foregroundStyle(BitFunTheme.muted) + Text(model.localized("桌面端已连接")) + .font(MobileDesignTypography.titleMedium.font).foregroundStyle(BitFunTheme.ink) + Text(model.localized("选择已有会话,或在当前工作区创建一个新会话。")) + .font(MobileDesignTypography.bodySmall.font).foregroundStyle(BitFunTheme.muted) + .multilineTextAlignment(.center) + Button { model.remoteCreateOpen = true } label: { + Label(model.localized("新建远程会话"), systemImage: "plus") + .font(MobileDesignTypography.labelMedium.font).foregroundStyle(.white) + .frame(minWidth: 176, minHeight: 44).background(BitFunTheme.accent).clipShape(Capsule()) } - } - .fullScreenCover(isPresented: $scannerOpen) { - QRCodeScannerView { code in - pairingURL = code - scannerOpen = false - if PairingLinkHintsKt.inspectPairingLink(url: code).requiresAccount { - manualOpen = true - focused = true - } else { - model.submitPairing(url: code) - } + .buttonStyle(.plain) + Spacer() } - .ignoresSafeArea() - } - } - - private var introPage: some View { - VStack(spacing: 0) { - hero(height: 250) - VStack(spacing: 15) { - Image(systemName: "desktopcomputer") - .font(.system(size: 54, weight: .medium)) + Button { model.remoteControlSettingsOpen = true } label: { + Image(systemName: "gearshape") + .font(.system(size: 18, weight: .medium)) .foregroundStyle(BitFunTheme.ink) - .frame(width: 88, height: 88) + .frame(width: 44, height: 44) .background(BitFunTheme.card) - .clipShape(RoundedRectangle(cornerRadius: 28)) - .shadow(color: BitFunTheme.line, radius: 18, y: 7) - Text(model.localized("选择连接方式")) - .font(.system(size: 24, weight: .bold)) - .foregroundStyle(BitFunTheme.ink) - } - .padding(.horizontal, 28) - .offset(y: -10) - Spacer(minLength: 12) - SignedOutConnectionActions( - scanTitle: model.localized("扫码连接"), - accountTitle: model.localized("登录 BitFun 账号"), - onScan: { - step = .scan - scannerOpen = true - }, - onOpenAccount: model.openAccountFromPairing, - enabled: !model.pairingBusy, - buttonHeight: 58, - spacing: 12, - fontSize: 20 - ) - .padding(.horizontal, 44) - .padding(.bottom, 34) - } - } - - private var scanPage: some View { - VStack(spacing: 0) { - hero(height: 252) - VStack(spacing: 22) { - Button { scannerOpen = true } label: { - Image(systemName: "qrcode.viewfinder") - .font(.system(size: 72, weight: .regular)) - .foregroundStyle(BitFunTheme.ink) - .frame(width: 176, height: 176) - .background(MobileDesignColors.connectHeroSurface) - .overlay(RoundedRectangle(cornerRadius: 34).stroke(BitFunTheme.line, lineWidth: 1.5)) - .clipShape(RoundedRectangle(cornerRadius: 34)) - } - .buttonStyle(.plain) - Text(model.localized("扫描二维码")) - .font(.system(size: 24, weight: .bold)).foregroundStyle(BitFunTheme.ink) - if let error = model.pairingError { - Text(error).font(.system(size: 13)).foregroundStyle(BitFunTheme.red) - .multilineTextAlignment(.center) - } - } - .offset(y: -50) - Spacer(minLength: 12) - Button { manualOpen = true; focused = true } label: { - Text(model.localized("手动输入配对码")) - .font(.system(size: 20, weight: .bold)) - .foregroundStyle(BitFunTheme.ink) - .frame(maxWidth: .infinity, minHeight: 58) - .background(BitFunTheme.card) - .overlay(Capsule().stroke(BitFunTheme.line, lineWidth: 1.5)) - .clipShape(Capsule()) - } - .buttonStyle(.plain) - .padding(.horizontal, 44) - .padding(.bottom, 34) - } - } - - private func hero(height: CGFloat) -> some View { - ZStack(alignment: .topLeading) { - LinearGradient( - colors: [MobileDesignColors.connectHeroBg, MobileDesignColors.connectHeroSurface], - startPoint: .topLeading, - endPoint: .bottomTrailing - ) - Button { - if step == .scan { step = .intro } else { dismiss() } - } label: { - Image(systemName: "chevron.left") - .font(.system(size: 20, weight: .medium)) - .foregroundStyle(BitFunTheme.ink) - .frame(width: 44, height: 44) - .background(BitFunTheme.card) - .clipShape(Circle()) - } - .buttonStyle(.plain) - .padding(.top, 18).padding(.leading, 18) - } - .frame(height: height) - } - - private var manualPairingOverlay: some View { - let hints = PairingLinkHintsKt.inspectPairingLink(url: pairingURL) - let effectiveUserID = pairingUserID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - ? hints.suggestedUserId - : pairingUserID.trimmingCharacters(in: .whitespacesAndNewlines) - let canSubmit = !model.pairingBusy && - !pairingURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && - (!hints.requiresAccount || (!effectiveUserID.isEmpty && !pairingPassword.isEmpty)) - - return ZStack { - MobileDesignColors.modalScrim - .ignoresSafeArea() - .onTapGesture { - if !model.pairingBusy { - pairingPassword = "" - manualOpen = false - } - } - VStack(alignment: .leading, spacing: 20) { - Text(model.localized(hints.requiresAccount ? "账号认证配对" : "手动输入配对码")) - .font(.system(size: 24, weight: .bold)).foregroundStyle(BitFunTheme.ink) - Text(model.localized( - hints.requiresAccount - ? "此桌面要求使用 BitFun 账号验证身份。" - : "输入桌面端显示的配对链接或代码。" - )) - .font(.system(size: 17)).foregroundStyle(BitFunTheme.muted).lineSpacing(5) - TextField(model.localized("配对码或连接链接"), text: $pairingURL) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - .keyboardType(.URL) - .lineLimit(1) - .font(.system(size: 20)).foregroundStyle(BitFunTheme.ink) - .padding(.horizontal, 20).frame(minHeight: 62) - .background(BitFunTheme.soft).clipShape(Capsule()) - .focused($focused) - if hints.requiresAccount { - TextField( - hints.suggestedUserId.isEmpty - ? model.localized("BitFun 用户名") - : hints.suggestedUserId, - text: $pairingUserID - ) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - .textContentType(.username) - .font(.system(size: 18)).foregroundStyle(BitFunTheme.ink) - .padding(.horizontal, 20).frame(minHeight: 56) - .background(BitFunTheme.soft).clipShape(Capsule()) - - SecureField(model.localized("BitFun 密码"), text: $pairingPassword) - .textContentType(.password) - .font(.system(size: 18)).foregroundStyle(BitFunTheme.ink) - .padding(.horizontal, 20).frame(minHeight: 56) - .background(BitFunTheme.soft).clipShape(Capsule()) - - Text(model.localized("账号凭据只用于本次加密配对,不会保存。")) - .font(.system(size: 13)) - .foregroundStyle(BitFunTheme.muted) - .lineSpacing(3) - } - if let error = model.pairingError { - Text(error).font(.system(size: 13)).foregroundStyle(BitFunTheme.red) - } - HStack(spacing: 12) { - pairingButton("取消", primary: false) { - pairingPassword = "" - manualOpen = false - focused = false - } - pairingButton(model.pairingBusy ? "正在连接" : "配对", primary: true) { - if hints.requiresAccount { - model.submitPairing( - url: pairingURL, - userID: effectiveUserID, - password: pairingPassword - ) - pairingPassword = "" - } else { - model.submitPairing(url: pairingURL) - } - focused = false - } - .disabled(!canSubmit) - } - } - .padding(.horizontal, 28).padding(.top, 30).padding(.bottom, 28) - .frame(maxWidth: 520) - .background(BitFunTheme.card) - .clipShape(RoundedRectangle(cornerRadius: 34)) - .overlay(RoundedRectangle(cornerRadius: 34).stroke(BitFunTheme.line, lineWidth: 1)) - .padding(.horizontal, 34) - } - } - - private func pairingButton(_ title: String, primary: Bool, action: @escaping () -> Void) -> some View { - Button(action: action) { - Text(model.localized(title)) - .font(.system(size: 19, weight: .bold)) - .foregroundStyle(primary ? Color.white : BitFunTheme.ink) - .frame(maxWidth: .infinity, minHeight: 58) - .background(primary ? BitFunTheme.accent : BitFunTheme.soft) - .clipShape(Capsule()) - } - .buttonStyle(.plain) - } -} - -private struct QRCodeScannerView: UIViewControllerRepresentable { - let onCode: (String) -> Void - - func makeUIViewController(context: Context) -> QRScannerController { - let controller = QRScannerController() - controller.onCode = onCode - return controller - } - - func updateUIViewController(_ uiViewController: QRScannerController, context: Context) {} -} - -private final class QRScannerController: UIViewController, AVCaptureMetadataOutputObjectsDelegate { - private let session = AVCaptureSession() - private var previewLayer: AVCaptureVideoPreviewLayer? - var onCode: ((String) -> Void)? - - override func viewDidLoad() { - super.viewDidLoad() - view.backgroundColor = .black - let close = UIButton(type: .system) - close.setImage(UIImage(systemName: "xmark"), for: .normal) - close.tintColor = .white - close.backgroundColor = UIColor.black.withAlphaComponent(0.55) - close.layer.cornerRadius = 22 - close.addAction(UIAction { [weak self] _ in self?.dismiss(animated: true) }, for: .touchUpInside) - close.translatesAutoresizingMaskIntoConstraints = false - view.addSubview(close) - NSLayoutConstraint.activate([ - close.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 16), - close.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20), - close.widthAnchor.constraint(equalToConstant: 44), - close.heightAnchor.constraint(equalToConstant: 44), - ]) - - guard AVCaptureDevice.authorizationStatus(for: .video) != .denied else { return } - AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in - guard granted else { return } - DispatchQueue.main.async { self?.configureCapture() } - } - } - - override func viewDidLayoutSubviews() { - super.viewDidLayoutSubviews() - previewLayer?.frame = view.bounds - } - - private func configureCapture() { - guard let device = AVCaptureDevice.default(for: .video), - let input = try? AVCaptureDeviceInput(device: device), - session.canAddInput(input) else { return } - let output = AVCaptureMetadataOutput() - guard session.canAddOutput(output) else { return } - session.addInput(input) - session.addOutput(output) - output.setMetadataObjectsDelegate(self, queue: .main) - output.metadataObjectTypes = [.qr] - let layer = AVCaptureVideoPreviewLayer(session: session) - layer.videoGravity = .resizeAspectFill - view.layer.insertSublayer(layer, at: 0) - previewLayer = layer - session.startRunning() - } - - func metadataOutput( - _ output: AVCaptureMetadataOutput, - didOutput metadataObjects: [AVMetadataObject], - from connection: AVCaptureConnection, - ) { - guard let value = (metadataObjects.first as? AVMetadataMachineReadableCodeObject)?.stringValue, - !value.isEmpty else { return } - session.stopRunning() - onCode?(value) - dismiss(animated: true) - } -} - -private struct LocalHomeView: View { - @ObservedObject var model: MobileAppModel - - private let prompts: [(String, String)] = [ - ("Aa", "帮我写点内容"), - ("≡", "梳理一个问题"), - ("✓", "制定行动计划") - ] - - var body: some View { - VStack(spacing: 0) { - Spacer(minLength: 0) - VStack(spacing: 12) { - ForEach(prompts, id: \.1) { icon, title in - let promptText = model.localized(title) - Button { - model.draft = promptText - model.send() - } label: { - HStack(spacing: 20) { - Text(icon) - .font(.system(size: 29, weight: .regular)) - .foregroundStyle(BitFunTheme.muted) - .frame(width: 32) - .fixedSize() - Text(promptText) - .font(.system(size: 20, weight: .medium)) - .foregroundStyle(BitFunTheme.muted) - Spacer(minLength: 0) - } - .frame(height: 48) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - } - } - .padding(.horizontal, 20) - .padding(.bottom, 12) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(BitFunTheme.page) - } -} - -private struct RemoteHomeView: View { - @ObservedObject var model: MobileAppModel - - var body: some View { - ZStack(alignment: .topTrailing) { - VStack(spacing: 12) { - Spacer() - ZStack { - Image(systemName: "desktopcomputer") - .font(.system(size: 42, weight: .medium)) - .foregroundStyle(BitFunTheme.ink) - } - .frame(width: 74, height: 74) - .background(BitFunTheme.card) - .overlay(RoundedRectangle(cornerRadius: 24).stroke(BitFunTheme.line, lineWidth: 1)) - .clipShape(RoundedRectangle(cornerRadius: 24)) - Text(model.localized("连接桌面端")) - .font(.system(size: 18, weight: .bold)) - .foregroundStyle(BitFunTheme.ink) - Text(model.localized("扫描桌面端显示的二维码,开始远程处理任务。")) - .font(.system(size: 13)) - .foregroundStyle(BitFunTheme.muted) - .multilineTextAlignment(.center) - .lineSpacing(7) - .padding(.horizontal, 20) - Button(model.localized("连接")) { model.connectRemote() } - .font(.system(size: 15, weight: .medium)) - .foregroundStyle(.white) - .frame(width: 136, height: 44) - .background(BitFunTheme.accent) - .clipShape(Capsule()) - Spacer() - } - remoteSettingsButton - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .padding(.horizontal, 20) - .padding(.bottom, 48) - .background(BitFunTheme.page) - } - - private var remoteSettingsButton: some View { - Button { model.remoteControlSettingsOpen = true } label: { - Image(systemName: "gearshape") - .font(.system(size: 18, weight: .medium)) - .foregroundStyle(BitFunTheme.ink) - .frame(width: 44, height: 44) - .background(BitFunTheme.card) - .overlay(Circle().stroke(BitFunTheme.line, lineWidth: 1)) - .clipShape(Circle()) - } - .buttonStyle(.plain) - .accessibilityLabel(model.localized("远程控制设置")) - .padding(.top, 16).padding(.trailing, 16) - } -} - -private struct RemoteConnectedHomeView: View { - @ObservedObject var model: MobileAppModel - - var body: some View { - ZStack(alignment: .topTrailing) { - VStack(spacing: 14) { - Spacer() - Image(systemName: "desktopcomputer.and.macbook") - .font(.system(size: 34, weight: .medium)).foregroundStyle(BitFunTheme.muted) - Text(model.localized("桌面端已连接")) - .font(MobileDesignTypography.titleMedium.font).foregroundStyle(BitFunTheme.ink) - Text(model.localized("选择已有会话,或在当前工作区创建一个新会话。")) - .font(MobileDesignTypography.bodySmall.font).foregroundStyle(BitFunTheme.muted) - .multilineTextAlignment(.center) - Button { model.remoteCreateOpen = true } label: { - Label(model.localized("新建远程会话"), systemImage: "plus") - .font(MobileDesignTypography.labelMedium.font).foregroundStyle(.white) - .frame(minWidth: 176, minHeight: 44).background(BitFunTheme.accent).clipShape(Capsule()) - } - .buttonStyle(.plain) - Spacer() - } - Button { model.remoteControlSettingsOpen = true } label: { - Image(systemName: "gearshape") - .font(.system(size: 18, weight: .medium)) - .foregroundStyle(BitFunTheme.ink) - .frame(width: 44, height: 44) - .background(BitFunTheme.card) - .overlay(Circle().stroke(BitFunTheme.line, lineWidth: 1)) - .clipShape(Circle()) + .overlay(Circle().stroke(BitFunTheme.line, lineWidth: 1)) + .clipShape(Circle()) } .buttonStyle(.plain) .accessibilityLabel(model.localized("远程控制设置")) .padding(.top, 16).padding(.trailing, 16) } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(BitFunTheme.page) - } -} - -private struct ConnectionStatusBar: View { - let phase: ConnectionPhase - var detail: String? - let onRetry: () -> Void - var body: some View { - HStack(spacing: 8) { - Circle().fill(phase == .reconnecting ? BitFunTheme.muted : BitFunTheme.red).frame(width: 8, height: 8) - Text(MobileLocalization.text(phase == .reconnecting ? "正在恢复连接" : "连接不可用")) - .font(.system(size: 13, weight: .medium)) - Text( - detail ?? MobileLocalization.text( - phase == .reconnecting ? "正在重新连接桌面端" : "请重新连接" - ) - ) - .font(.system(size: 12)) - .foregroundStyle(BitFunTheme.muted) - Spacer() - if phase == .disconnected { - Button(MobileLocalization.text("重试"), action: onRetry) - .font(.system(size: 13, weight: .semibold)) - .buttonStyle(.plain) - .foregroundStyle(BitFunTheme.accent) - } - } - .foregroundStyle(BitFunTheme.ink) - .padding(.horizontal, 18) - .frame(height: 48) - .background(BitFunTheme.soft) - } -} - -private struct SettingsView: View { - @ObservedObject var model: MobileAppModel - @Environment(\.dismiss) private var dismiss - @State private var accountOpen = false - - private var appVersion: String { - Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "1.0.0" - } - - private var selectedModelName: String { - model.modelOptions.first(where: \.selected)?.primaryLabel - ?? model.modelOptions.first?.primaryLabel - ?? model.localized("未配置") - } - - var body: some View { - ZStack(alignment: .topTrailing) { - ScrollView(showsIndicators: false) { - VStack(alignment: .leading, spacing: 0) { - Text(model.localized("设置")) - .font(.system(size: 28, weight: .bold)) - .foregroundStyle(BitFunTheme.ink) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.bottom, 30) - - Button { accountOpen = true } label: { - SettingsCard { - SettingsProfileRow( - subtitle: model.accountUser ?? model.localized("未登录") - ) - } - } - .buttonStyle(.plain) - .padding(.bottom, 24) - - SettingsGroup(title: "通用") { - VStack(spacing: 0) { - Button { model.languagePickerOpen = true } label: { - SettingsValueRow( - icon: "textformat", - title: "语言", - value: model.appLanguage.nativeName, - showsChevron: true - ) - } - .buttonStyle(.plain) - Divider().overlay(BitFunTheme.line).padding(.horizontal, 26) - Button { model.generalConfigOpen = true } label: { - SettingsValueRow( - icon: "square.grid.2x2", - title: "模型", - value: selectedModelName, - showsChevron: true - ) - } - .buttonStyle(.plain) - } - } - SettingsGroup(title: "关于") { - VStack(spacing: 0) { - SettingsValueRow( - icon: nil, - title: "产品", - value: "BitFun iOS版" - ) - Divider().overlay(BitFunTheme.line).padding(.horizontal, 26) - SettingsValueRow(icon: nil, title: "版本", value: appVersion) - } - } - } - .padding(.horizontal, 16) - .padding(.top, 64) - .padding(.bottom, 34) - } - - Button { dismiss() } label: { - Image(systemName: "xmark") - .font(.system(size: 18, weight: .regular)) - .foregroundStyle(BitFunTheme.ink) - .frame(width: 40, height: 40) - .background(BitFunTheme.card) - .clipShape(Circle()) - } - .buttonStyle(.plain) - .accessibilityLabel(model.localized("关闭")) - .padding(.top, 22) - .padding(.trailing, 18) - - if model.languagePickerOpen { - LanguagePickerSheet(model: model) - .transition(.move(edge: .trailing).combined(with: .opacity)) - } else if model.generalConfigOpen { - GeneralChatConfigSheet(model: model) - .transition(.move(edge: .trailing).combined(with: .opacity)) - } else if accountOpen { - AccountSettingsView(model: model, onClose: { accountOpen = false }) - .transition(.move(edge: .trailing).combined(with: .opacity)) - } - } - .background(BitFunTheme.page) - .animation(.easeInOut(duration: 0.2), value: model.languagePickerOpen) - .animation(.easeInOut(duration: 0.2), value: model.generalConfigOpen) - .animation(.easeInOut(duration: 0.2), value: accountOpen) - } -} - -private struct LanguagePickerSheet: View { - @ObservedObject var model: MobileAppModel - - var body: some View { - VStack(alignment: .leading, spacing: 0) { - BitFunSelectionHeader(title: "选择语言", onClose: { model.languagePickerOpen = false }) - Divider().overlay(BitFunTheme.line) - - VStack(spacing: 0) { - ForEach(MobileLanguage.allCases) { language in - Button { - model.setLanguage(language) - model.languagePickerOpen = false - } label: { - HStack { - Text(language.nativeName) - .font(.system(size: 16, weight: .medium)) - .foregroundStyle(BitFunTheme.ink) - Spacer() - if model.appLanguage == language { - Image(systemName: "checkmark") - .font(.system(size: 18, weight: .medium)) - .foregroundStyle(BitFunTheme.ink) - } - } - .padding(.horizontal, 16) - .frame(height: MobileDesignGeometry.selectionRowHeight) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - } - } - .padding(.top, 8) - .padding(.bottom, 28) - - Spacer(minLength: 0) - } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) - .background(BitFunTheme.card) - .clipShape(RoundedRectangle(cornerRadius: MobileDesignGeometry.selectionTopRadius)) - } -} - -private struct RemoteViewSettingsView: View { - @ObservedObject var model: MobileAppModel - - private var statuses: [String] { - model.sessionListStatusOptions - } - - private var workspaces: [MobileSessionWorkspaceOption] { - model.sessionListWorkspaceOptions - } - - private var agentGroups: [String] { - model.sessionListAgentGroups - } - - var body: some View { - VStack(spacing: 0) { - BitFunModalHeader( - title: "视图设置", - subtitle: "调整会话列表的分组和信息密度", - onClose: { model.remoteViewSettingsOpen = false } - ) - .padding(.horizontal, 20) - Divider().overlay(BitFunTheme.line) - - ScrollView(showsIndicators: false) { - VStack(alignment: .leading, spacing: 8) { - sectionTitle("分组方式") - SettingsCard { - choiceRow("按项目", value: "PROJECT", selected: model.remoteGroupMode) - settingsDivider - choiceRow("按时间倒序排列", value: "TIME", selected: model.remoteGroupMode) - settingsDivider - choiceRow("聊天优先", value: "CHAT", selected: model.remoteGroupMode) - } - - sectionTitle("筛选") - filterLabel("工作区") - SettingsCard { - filterRow( - "所有工作区", - selected: model.remoteWorkspaceFilter.isEmpty, - action: { model.remoteWorkspaceFilter = "" } - ) - ForEach(workspaces) { workspace in - settingsDivider - filterRow( - workspace.name, - selected: normalizedPath(model.remoteWorkspaceFilter) == normalizedPath(workspace.path), - action: { model.remoteWorkspaceFilter = workspace.path } - ) - } - } - - filterLabel("Agent 类型") - SettingsCard { - filterRow( - "所有 Agent 类型", - selected: model.remoteViewAgentFilter.isEmpty, - action: { model.remoteViewAgentFilter = "" } - ) - ForEach(agentGroups, id: \.self) { group in - settingsDivider - filterRow( - agentLabel(group), - selected: model.remoteViewAgentFilter == group, - action: { model.remoteViewAgentFilter = group } - ) - } - } - - filterLabel("状态") - SettingsCard { - filterRow( - "所有状态", - selected: model.remoteStatusFilter.isEmpty, - action: { model.remoteStatusFilter = "" } - ) - ForEach(statuses, id: \.self) { status in - settingsDivider - filterRow( - statusLabel(status), - selected: model.remoteStatusFilter == status, - action: { model.remoteStatusFilter = status } - ) - } - } - - sectionTitle("显示信息") - SettingsCard { - metadataToggle("工作区", isOn: $model.remoteShowWorkspaceMetadata) - settingsDivider - metadataToggle("更新时间", isOn: $model.remoteShowUpdatedMetadata) - settingsDivider - metadataToggle("状态", isOn: $model.remoteShowStatusMetadata) - } - } - .padding(.horizontal, 20) - .padding(.top, 8) - .padding(.bottom, 34) - } - } - .background(BitFunTheme.page) - } - - private func sectionTitle(_ title: String) -> some View { - Text(model.localized(title)) - .font(MobileDesignTypography.labelLarge.font) - .foregroundStyle(BitFunTheme.muted) - .padding(.top, 8) - .padding(.leading, 4) - } - - private func filterLabel(_ title: String) -> some View { - Text(model.localized(title)) - .font(MobileDesignTypography.labelSmall.font) - .foregroundStyle(BitFunTheme.muted) - .padding(.top, 2) - .padding(.leading, 10) - } - - private var settingsDivider: some View { - Divider().overlay(BitFunTheme.line).padding(.horizontal, 20) - } - - private func choiceRow(_ title: String, value: String, selected: String) -> some View { - filterRow(title, selected: value == selected) { model.remoteGroupMode = value } - } - - private func filterRow(_ title: String, selected: Bool, action: @escaping () -> Void) -> some View { - Button(action: action) { - HStack(spacing: 12) { - Text(model.localized(title)) - .font(.system(size: 16, weight: .medium)) - .foregroundStyle(BitFunTheme.ink) - .lineLimit(1) - Spacer(minLength: 0) - if selected { - Image(systemName: "checkmark") - .font(.system(size: 15, weight: .semibold)) - .foregroundStyle(BitFunTheme.accent) - } - } - .padding(.horizontal, 20) - .frame(minHeight: 52) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - } - - private func metadataToggle(_ title: String, isOn: Binding) -> some View { - Toggle(isOn: isOn) { - Text(model.localized(title)) - .font(.system(size: 16, weight: .medium)) - .foregroundStyle(BitFunTheme.ink) - } - .tint(BitFunTheme.accent) - .padding(.horizontal, 20) - .frame(minHeight: 56) - } - - private func agentLabel(_ group: String) -> String { - switch group { - case "CHAT": return "聊天" - case "COWORK": return "Cowork" - default: return "Code" - } - } - - private func statusLabel(_ status: String) -> String { - switch status { - case "active", "running": return "运行中" - case "ready", "idle": return "就绪" - case "archived": return "已归档" - default: return status - } - } - - private func normalizedPath(_ path: String) -> String { - var result = path.trimmingCharacters(in: .whitespacesAndNewlines) - while result.count > 1 && (result.hasSuffix("/") || result.hasSuffix("\\")) { - result.removeLast() - } - return result - } -} - -/// The desktop-wide control page mirrors HarmonyOS' RemoteControlSettingsSheet. -/// Account navigation and full-access confirmation stay inside this adaptive -/// modal so a settings action never creates a second sheet or scrim. -private struct RemoteControlSettingsView: View { - private enum Page { case control, account } - - @ObservedObject var model: MobileAppModel - @State private var page: Page = .control - @State private var confirmingFullAccess = false - - var body: some View { - Group { - if page == .account { - AccountSettingsView(model: model, onClose: { page = .control }) - } else { - controlPage - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(BitFunTheme.page) - .animation(.easeInOut(duration: 0.2), value: page) - .onAppear { - if model.remoteConnected { model.refreshRemotePermissionMode() } - } - } - - private var controlPage: some View { - ZStack(alignment: .topTrailing) { - ScrollView(showsIndicators: false) { - VStack(alignment: .leading, spacing: 0) { - Text(model.localized("远程控制")) - .font(.system(size: 20, weight: .bold)) - .foregroundStyle(BitFunTheme.ink) - .frame(maxWidth: .infinity, minHeight: 56, alignment: .center) - .padding(.bottom, 30) - - Button { page = .account } label: { - SettingsCard { - HStack(spacing: 12) { - Image(systemName: "person.crop.circle") - .font(.system(size: 28, weight: .regular)) - .foregroundStyle(BitFunTheme.muted) - .frame(width: 34, height: 34) - Text(model.localized(model.accountUser == nil ? "登录 BitFun 账号" : "个人资料")) - .font(.system(size: 18, weight: .medium)) - .foregroundStyle(BitFunTheme.ink) - Spacer() - Image(systemName: "chevron.right") - .font(.system(size: 14, weight: .medium)) - .foregroundStyle(BitFunTheme.muted.opacity(0.72)) - } - .padding(.horizontal, 18) - .frame(height: 64) - } - } - .buttonStyle(.plain) - .padding(.bottom, 28) - - remoteSectionTitle("当前远程控制") - currentControlCard - - remoteSectionTitle("其他连接方式") - .padding(.top, 16) - Button { - model.remoteControlSettingsOpen = false - DispatchQueue.main.asyncAfter(deadline: .now() + 0.22) { - model.connectRemote() - } - } label: { - SettingsCard { - HStack(spacing: 12) { - Image(systemName: "link") - .font(.system(size: 20, weight: .regular)) - .foregroundStyle(BitFunTheme.muted) - .frame(width: 24, height: 24) - VStack(alignment: .leading, spacing: 2) { - Text(model.localized("扫描二维码连接")) - .font(.system(size: 16, weight: .medium)) - .foregroundStyle(BitFunTheme.ink) - Text(model.localized("适用于临时配对或未登录账号的桌面端。")) - .font(.system(size: 13)) - .foregroundStyle(BitFunTheme.muted) - .lineLimit(2) - } - Spacer(minLength: 8) - Image(systemName: "chevron.right") - .font(.system(size: 14, weight: .medium)) - .foregroundStyle(BitFunTheme.muted.opacity(0.72)) - } - .padding(.horizontal, 18) - .frame(minHeight: 78) - } - } - .buttonStyle(.plain) - - permissionSection - .padding(.top, 20) - } - .padding(.horizontal, 18) - .padding(.top, 20) - .padding(.bottom, 42) - } - - Button { model.remoteControlSettingsOpen = false } label: { - Image(systemName: "xmark") - .font(.system(size: 17, weight: .regular)) - .foregroundStyle(BitFunTheme.ink) - .frame(width: 40, height: 40) - .background(BitFunTheme.card) - .clipShape(Circle()) - } - .buttonStyle(.plain) - .accessibilityLabel(model.localized("关闭")) - .padding(.top, 16).padding(.trailing, 16) - } - } - - private var currentControlCard: some View { - SettingsCard { - HStack(spacing: 14) { - Image(systemName: "desktopcomputer") - .font(.system(size: 23, weight: .regular)) - .foregroundStyle(BitFunTheme.muted) - .frame(width: 40, height: 40) - VStack(alignment: .leading, spacing: 2) { - Text(model.localized("BitFun 桌面版")) - .font(.system(size: 14)).foregroundStyle(BitFunTheme.muted) - Text(model.accountDeviceName ?? model.localized("尚未连接桌面端")) - .font(.system(size: 18, weight: .medium)).foregroundStyle(BitFunTheme.ink) - .lineLimit(1) - Text(connectionStatus) - .font(.system(size: 14)).foregroundStyle(BitFunTheme.muted) - } - Spacer(minLength: 6) - if model.remoteConnected { - remoteChip("断开", action: model.disconnectRemote) - } else if model.connectionPhase == .disconnected { - remoteChip("重新连接", action: model.verifyRemoteConnection) - } - } - .padding(.horizontal, 18) - .frame(minHeight: 92) - - Divider().overlay(BitFunTheme.line).padding(.horizontal, 18) - - HStack(spacing: 10) { - Image(systemName: "link") - .font(.system(size: 18)).foregroundStyle(BitFunTheme.muted) - .frame(width: 20, height: 20) - Text(model.localized("连接来源")) - .font(.system(size: 14)).foregroundStyle(BitFunTheme.muted) - Spacer() - Text(connectionSource) - .font(.system(size: 13)).foregroundStyle(BitFunTheme.ink) - .padding(.horizontal, 10).padding(.vertical, 5) - .background(BitFunTheme.soft).clipShape(Capsule()) - } - .padding(.horizontal, 18) - .frame(height: 52) - } - } - - private var permissionSection: some View { - VStack(alignment: .leading, spacing: 10) { - HStack { - remoteSectionTitle("远程权限") - Spacer() - if model.remoteConnected { - Button(model.localized("刷新")) { model.refreshRemotePermissionMode() } - .font(.system(size: 14, weight: .medium)) - .foregroundStyle(BitFunTheme.ink) - .buttonStyle(.plain) - .disabled(model.busy) - } - } - SettingsCard { - Text(model.localized("控制桌面端执行工具时采用的确认方式。")) - .font(.system(size: 13)).foregroundStyle(BitFunTheme.muted) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 18).padding(.top, 16).padding(.bottom, 4) - permissionRow("ASK", title: "每次询问", detail: "执行需要授权的操作前先询问。") - Divider().overlay(BitFunTheme.line).padding(.horizontal, 18) - permissionRow("AUTO", title: "自动允许", detail: "自动允许常规操作,高风险操作仍会询问。") - Divider().overlay(BitFunTheme.line).padding(.horizontal, 18) - permissionRow("FULL_ACCESS", title: "完全访问", detail: "不再询问,允许桌面端执行所有操作。") - - if let failure = model.remotePermissionFailure, !failure.isEmpty { - Text(failure) - .font(.system(size: 12)).foregroundStyle(BitFunTheme.red) - .padding(.horizontal, 18).padding(.bottom, 10) - } - - if confirmingFullAccess { - fullAccessConfirmation - } - } - } - } - - private var fullAccessConfirmation: some View { - VStack(alignment: .leading, spacing: 10) { - Text(model.localized("确认完全访问")) - .font(.system(size: 15, weight: .bold)).foregroundStyle(BitFunTheme.red) - Text(model.localized("完全访问会取消所有操作确认。仅在你信任当前桌面端时启用。")) - .font(.system(size: 13)).foregroundStyle(BitFunTheme.ink).lineSpacing(4) - HStack(spacing: 10) { - confirmationButton("取消", destructive: false) { confirmingFullAccess = false } - confirmationButton("启用完全访问", destructive: true) { - model.setRemotePermissionMode("FULL_ACCESS") - confirmingFullAccess = false - } - } - } - .padding(16) - .overlay(RoundedRectangle(cornerRadius: 18).stroke(BitFunTheme.red, lineWidth: 1)) - .padding(.horizontal, 12).padding(.bottom, 14) - } - - private func permissionRow(_ mode: String, title: String, detail: String) -> some View { - Button { - if mode == "FULL_ACCESS" { confirmingFullAccess = true } - else { - confirmingFullAccess = false - model.setRemotePermissionMode(mode) - } - } label: { - HStack(spacing: 12) { - ZStack { - if model.remotePermissionMode == mode { - Image(systemName: "checkmark.circle.fill") - .font(.system(size: 20)).foregroundStyle(BitFunTheme.ink) - } - } - .frame(width: 22, height: 24) - VStack(alignment: .leading, spacing: 3) { - Text(model.localized(title)) - .font(.system(size: 16, weight: .medium)).foregroundStyle(BitFunTheme.ink) - Text(model.localized(detail)) - .font(.system(size: 12)).foregroundStyle(BitFunTheme.muted) - .lineLimit(2) - } - Spacer(minLength: 0) - } - .padding(.horizontal, 18) - .frame(minHeight: 72) - .contentShape(Rectangle()) - .opacity(model.remoteConnected && !model.busy ? 1 : 0.54) - } - .buttonStyle(.plain) - .disabled(!model.remoteConnected || model.busy) - } - - private func remoteSectionTitle(_ title: String) -> some View { - Text(model.localized(title)) - .font(.system(size: 18, weight: .bold)) - .foregroundStyle(BitFunTheme.muted) - .frame(maxWidth: .infinity, minHeight: 42, alignment: .leading) - .padding(.horizontal, 18) - } - - private func remoteChip(_ title: String, action: @escaping () -> Void) -> some View { - Button(action: action) { - Text(model.localized(title)) - .font(.system(size: 14)).foregroundStyle(BitFunTheme.ink) - .padding(.horizontal, 10).padding(.vertical, 7) - .background(BitFunTheme.soft).clipShape(Capsule()) - } - .buttonStyle(.plain) - } - - private func confirmationButton( - _ title: String, - destructive: Bool, - action: @escaping () -> Void - ) -> some View { - Button(action: action) { - Text(model.localized(title)) - .font(.system(size: 14, weight: .medium)) - .foregroundStyle(destructive ? Color.white : BitFunTheme.ink) - .frame(maxWidth: .infinity, minHeight: 42) - .background(destructive ? BitFunTheme.red : BitFunTheme.soft) - .clipShape(Capsule()) - } - .buttonStyle(.plain) - } - - private var connectionStatus: String { - switch model.connectionPhase { - case .connected: model.localized(model.remoteConnected ? "已连接" : "未连接") - case .reconnecting: model.localized("正在重新连接") - case .disconnected: model.localized("连接已断开") - } - } - - private var connectionSource: String { - if model.accountSelectedDeviceID != nil { return model.localized("账号设备") } - if model.remoteConnected { return model.localized("扫码配对") } - return model.localized("未连接") - } -} - -private struct AccountSettingsView: View { - @ObservedObject var model: MobileAppModel - var onClose: (() -> Void)? = nil - @State private var relayURL = AccountDefaults.shared.CLOUD_RELAY_URL - @State private var username = "" - @State private var password = "" - - var body: some View { - Group { - if model.accountUser == nil { - loginPage - } else { - profilePage - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(BitFunTheme.page) - } - - private var loginPage: some View { - ScrollView(showsIndicators: false) { - VStack(alignment: .leading, spacing: 0) { - Button { close() } label: { - Image(systemName: "chevron.left") - .font(.system(size: 19, weight: .medium)) - .foregroundStyle(BitFunTheme.ink) - .frame(width: 44, height: 44) - } - .buttonStyle(.plain) - .accessibilityLabel(model.localized("返回")) - - Text(model.localized("登录 BitFun 账号")) - .font(.system(size: 32, weight: .bold)) - .foregroundStyle(BitFunTheme.ink) - Text(model.localized("登录后可查看并连接账号下的桌面设备。")) - .font(.system(size: 15)) - .foregroundStyle(BitFunTheme.muted) - .lineSpacing(4) - .padding(.top, 12) - .padding(.bottom, 42) - - accountField(model.localized("用户名"), text: $username, secure: false, height: 58) - accountField(model.localized("密码"), text: $password, secure: true, height: 58) - .padding(.top, 14) - - Text(model.localized("登录服务器")) - .font(.system(size: 13)) - .foregroundStyle(BitFunTheme.muted) - .padding(.leading, 4) - .padding(.top, 26) - .padding(.bottom, 8) - accountField(model.localized("Relay 地址"), text: $relayURL, secure: false, height: 52) - - if let error = model.coreErrorMessage, !error.isEmpty { - Text(error) - .font(.system(size: 13)) - .foregroundStyle(BitFunTheme.red) - .padding(.top, 12) - } - - Button { - model.loginAccount(relayURL: relayURL, username: username, password: password) - password = "" - } label: { - HStack(spacing: 8) { - if model.accountBusy { ProgressView().tint(.white) } - Text(model.localized(model.accountBusy ? "正在登录" : "登录")) - } - .font(.system(size: 17, weight: .bold)) - .foregroundStyle(.white) - .frame(maxWidth: .infinity, minHeight: 56) - .background(canLogin ? BitFunTheme.accent : BitFunTheme.muted.opacity(0.35)) - .clipShape(RoundedRectangle(cornerRadius: 18)) - } - .buttonStyle(.plain) - .disabled(!canLogin) - .padding(.top, model.coreErrorMessage == nil ? 30 : 22) - } - .padding(.horizontal, 28) - .padding(.top, 22) - .padding(.bottom, 44) - } - } - - private var profilePage: some View { - VStack(alignment: .leading, spacing: 0) { - BitFunModalHeader(title: "个人资料", onClose: close) - .padding(.horizontal, MobileDesignGeometry.sheetHorizontalPadding) - .padding(.top, 8) - ScrollView(showsIndicators: false) { - VStack(alignment: .leading, spacing: 0) { - VStack(spacing: 10) { - ZStack { - Circle().fill(BitFunTheme.soft) - Image(systemName: "person.fill") - .font(.system(size: 34, weight: .medium)) - .foregroundStyle(BitFunTheme.ink) - } - .frame(width: 70, height: 70) - Text(model.accountUser ?? "") - .font(.system(size: 22, weight: .bold)) - .foregroundStyle(BitFunTheme.ink) - .lineLimit(1) - Text(profileIdentifier) - .font(.system(size: 14)) - .foregroundStyle(BitFunTheme.muted) - .lineLimit(1) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 24) - .background(BitFunTheme.card) - .clipShape(RoundedRectangle(cornerRadius: 28)) - .padding(.bottom, 24) - - VStack(alignment: .leading, spacing: 10) { - HStack { - Text(model.localized("BitFun 账号")) - .font(.system(size: 17, weight: .bold)) - .foregroundStyle(BitFunTheme.ink) - Spacer() - Text(model.localized("已登录")) - .font(.system(size: 14)) - .foregroundStyle(BitFunTheme.green) - } - Text(model.localizedFormat("当前以 %@ 登录。", model.accountUser ?? "")) - .font(.system(size: 14)) - .foregroundStyle(BitFunTheme.muted) - .lineSpacing(3) - } - .padding(.horizontal, 18) - .padding(.vertical, 16) - .background(BitFunTheme.card) - .clipShape(RoundedRectangle(cornerRadius: 24)) - .padding(.bottom, 24) - - VStack(alignment: .leading, spacing: 8) { - HStack { - Text(model.localized("设备管理")) - .font(.system(size: 17, weight: .bold)) - .foregroundStyle(BitFunTheme.ink) - Spacer() - Button { model.refreshRemoteDevices() } label: { - Text(model.localized(model.accountRefreshing ? "正在刷新" : "刷新")) - .font(.system(size: 13)) - .foregroundStyle(model.accountRefreshing ? BitFunTheme.muted : BitFunTheme.ink) - } - .buttonStyle(.plain) - .disabled(model.accountRefreshing) - } - VStack(spacing: 0) { - ForEach(Array(model.accountDevices.enumerated()), id: \.offset) { index, device in - Button { model.selectRemoteDevice(device) } label: { - SettingsDeviceRow(device: device) - } - .buttonStyle(.plain) - .disabled(!device.online && !device.selected) - if index < model.accountDevices.count - 1 { - Divider().overlay(BitFunTheme.line).padding(.horizontal, 20) - } - } - if model.accountDevices.isEmpty { - Text(model.localized("暂无可连接的桌面设备")) - .font(.system(size: 13)) - .foregroundStyle(BitFunTheme.muted) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.vertical, 12) - } - } - } - .padding(.horizontal, 18) - .padding(.vertical, 16) - .background(BitFunTheme.card) - .clipShape(RoundedRectangle(cornerRadius: 24)) - .padding(.bottom, 24) - - Text(model.localized("个人资料详情")) - .font(.system(size: 18, weight: .bold)) - .foregroundStyle(BitFunTheme.muted) - .padding(.leading, 18) - .padding(.bottom, 8) - - VStack(spacing: 0) { - profileDetailRow(label: model.localized("用户 ID"), value: profileIdentifier) - Divider().overlay(BitFunTheme.line).padding(.horizontal, 18) - profileDetailRow( - label: model.localized("设备 ID"), - value: model.localDeviceID.isEmpty ? "-" : model.localDeviceID - ) - } - .background(BitFunTheme.card) - .clipShape(RoundedRectangle(cornerRadius: 28)) - - Button(role: .destructive) { - model.logoutAccount() - } label: { - Text(model.localized("退出账号")) - .font(.system(size: 16, weight: .medium)) - .foregroundStyle(BitFunTheme.red) - .frame(maxWidth: .infinity, minHeight: 54) - .background(BitFunTheme.card) - .clipShape(RoundedRectangle(cornerRadius: 16)) - } - .buttonStyle(.plain) - .padding(.top, 18) - } - .padding(.horizontal, MobileDesignGeometry.sheetHorizontalPadding) - .padding(.top, 20) - .padding(.bottom, 34) - } - } - } - - private var profileIdentifier: String { - model.accountUserID?.isEmpty == false ? model.accountUserID! : (model.accountUser ?? "-") - } - - private func profileDetailRow(label: String, value: String) -> some View { - HStack(spacing: 12) { - Text(label) - .font(.system(size: 16)) - .foregroundStyle(BitFunTheme.ink) - Spacer(minLength: 8) - Text(value) - .font(.system(size: 16)) - .foregroundStyle(BitFunTheme.muted) - .lineLimit(1) - .truncationMode(.middle) - .multilineTextAlignment(.trailing) - } - .frame(minHeight: 56) - .padding(.horizontal, 18) - } - - private var canLogin: Bool { - !model.accountBusy && - !relayURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && - !username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && - !password.isEmpty - } - - private func close() { - if let onClose { onClose() } else { model.accountSheetOpen = false } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(BitFunTheme.page) } +} - @ViewBuilder - private func accountField( - _ placeholder: String, - text: Binding, - secure: Bool, - height: CGFloat - ) -> some View { - Group { - if secure { SecureField(placeholder, text: text) } - else { TextField(placeholder, text: text) } +private struct ConnectionStatusBar: View { + let phase: ConnectionPhase + var detail: String? + let onRetry: () -> Void + var body: some View { + HStack(spacing: 8) { + Circle().fill(phase == .reconnecting ? BitFunTheme.muted : BitFunTheme.red).frame(width: 8, height: 8) + Text(MobileLocalization.text(phase == .reconnecting ? "正在恢复连接" : "连接不可用")) + .font(.system(size: 13, weight: .medium)) + Text( + detail ?? MobileLocalization.text( + phase == .reconnecting ? "正在重新连接桌面端" : "请重新连接" + ) + ) + .font(.system(size: 12)) + .foregroundStyle(BitFunTheme.muted) + Spacer() + if phase == .disconnected { + Button(MobileLocalization.text("重试"), action: onRetry) + .font(.system(size: 13, weight: .semibold)) + .buttonStyle(.plain) + .foregroundStyle(BitFunTheme.accent) + } } - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - .font(.system(size: height == 58 ? 17 : 14)) .foregroundStyle(BitFunTheme.ink) - .padding(.horizontal, 20) - .frame(height: height) - .background(BitFunTheme.card) - .clipShape(RoundedRectangle(cornerRadius: height == 58 ? 18 : 16)) + .padding(.horizontal, 18) + .frame(height: 48) + .background(BitFunTheme.soft) } } -private struct GeneralChatConfigSheet: View { - private enum Page { case overview, account, local } - +private struct SettingsView: View { @ObservedObject var model: MobileAppModel - @State private var page: Page = .overview - @State private var baseURL = "" - @State private var modelName = "" - @State private var apiKey = "" - @State private var clearAPIKey = false - - private var selectedModel: ComposerModelOption? { - model.modelOptions.first(where: \.selected) - } - - private var accountModels: [ComposerModelOption] { - model.modelOptions.filter { $0.source == "ACCOUNT" } - } + @Environment(\.dismiss) private var dismiss + @State private var accountOpen = false - private var localModel: ComposerModelOption? { - model.modelOptions.first { $0.source == "LOCAL" } + private var appVersion: String { + Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "1.0.0" } - private var localComplete: Bool { - !model.generalConfigBaseURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && - !model.generalConfigModel.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && - model.generalConfigHasAPIKey + private var selectedModelName: String { + model.modelOptions.first(where: \.selected)?.primaryLabel + ?? model.modelOptions.first?.primaryLabel + ?? model.localized("未配置") } var body: some View { - VStack(alignment: .leading, spacing: 0) { - modelHeader - Divider().overlay(BitFunTheme.line) - switch page { - case .overview: overview - case .account: accountSelection - case .local: localEditor - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) - .background(BitFunTheme.card) - .onAppear { - baseURL = model.generalConfigBaseURL - modelName = model.generalConfigModel - } - } - - private var modelHeader: some View { - HStack(spacing: 8) { - if page != .overview { - Button { page = .overview } label: { - Image(systemName: "chevron.left") - .font(.system(size: 18, weight: .medium)) - .frame(width: 42, height: 42) - } - .buttonStyle(.plain) - .foregroundStyle(BitFunTheme.ink) - .accessibilityLabel(model.localized("返回")) - } - Text(model.localized(headerTitle)) - .font(MobileDesignTypography.headlineSmall.font) - .foregroundStyle(BitFunTheme.ink) - .lineLimit(1) - Spacer(minLength: 8) - Button { model.generalConfigOpen = false } label: { - Image(systemName: "xmark") - .font(.system(size: 18, weight: .regular)) - .foregroundStyle(BitFunTheme.muted) - .frame( - width: MobileDesignGeometry.selectionCloseSize, - height: MobileDesignGeometry.selectionCloseSize - ) - } - .buttonStyle(.plain) - .accessibilityLabel(model.localized("关闭")) - } - .padding(.horizontal, 16) - .frame(height: MobileDesignGeometry.sheetHeaderHeight) - } - - private var headerTitle: String { - switch page { - case .overview: "普通对话模型" - case .account: "选择账号模型" - case .local: "本机自定义模型" - } - } + ZStack(alignment: .topTrailing) { + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 0) { + Text(model.localized("设置")) + .font(.system(size: 28, weight: .bold)) + .foregroundStyle(BitFunTheme.ink) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.bottom, 30) - private var overview: some View { - ScrollView(showsIndicators: false) { - VStack(alignment: .leading, spacing: MobileDesignGeometry.modelSectionGap) { - VStack(alignment: .leading, spacing: 8) { - sectionTitle("当前使用") - modelOverviewRow( - icon: "checkmark.circle.fill", - title: selectedModel?.primaryLabel ?? model.localized("未配置"), - subtitle: selectedModel.map { sourceLabel($0.source) } ?? "", - height: MobileDesignGeometry.modelCurrentRowHeight - ) - } - VStack(alignment: .leading, spacing: 8) { - sectionTitle("模型来源") - VStack(spacing: 0) { - Button { page = .account } label: { - sourceRow( - icon: "cloud", - title: "云端账号模型", - subtitle: accountModels.isEmpty - ? model.localized("暂无可用的账号模型") - : model.localizedFormat("已同步 %d 个", accountModels.count), - chevronAction: nil + Button { accountOpen = true } label: { + SettingsCard { + SettingsProfileRow( + subtitle: model.accountUser ?? model.localized("未登录") ) } - .buttonStyle(.plain) - Divider().overlay(BitFunTheme.line).padding(.leading, 56) - HStack(spacing: 0) { - Button { - if localComplete, let localModel { model.selectModel(localModel.id) } - else { page = .local } - } label: { - sourceRow( - icon: "wrench.and.screwdriver", - title: localComplete ? model.generalConfigModel : model.localized("未配置"), - subtitle: localComplete ? model.localized("本机") : "", - chevronAction: nil + } + .buttonStyle(.plain) + .padding(.bottom, 24) + + SettingsGroup(title: "通用") { + VStack(spacing: 0) { + Button { model.languagePickerOpen = true } label: { + SettingsValueRow( + icon: "textformat", + title: "语言", + value: model.appLanguage.nativeName, + showsChevron: true ) } .buttonStyle(.plain) - Button { page = .local } label: { - Image(systemName: "chevron.right") - .font(.system(size: 14, weight: .medium)) - .foregroundStyle(BitFunTheme.muted) - .frame(width: 44, height: MobileDesignGeometry.modelSourceRowHeight) + Divider().overlay(BitFunTheme.line).padding(.horizontal, 26) + Button { model.generalConfigOpen = true } label: { + SettingsValueRow( + icon: "square.grid.2x2", + title: "模型", + value: selectedModelName, + showsChevron: true + ) } .buttonStyle(.plain) } } - .background(BitFunTheme.soft) - .clipShape(RoundedRectangle(cornerRadius: MobileDesignGeometry.settingsCompactCardRadius)) - } - } - .padding(.horizontal, 16) - .padding(.top, MobileDesignGeometry.modelOverviewTopPadding) - .padding(.bottom, MobileDesignGeometry.modelOverviewBottomPadding) - } - } - - private var accountSelection: some View { - Group { - if accountModels.isEmpty { - Text(model.localized("暂无可用的账号模型")) - .font(MobileDesignTypography.bodyMedium.font) - .foregroundStyle(BitFunTheme.muted) - .frame(maxWidth: .infinity, minHeight: MobileDesignGeometry.modelEmptyAccountHeight, alignment: .leading) - .padding(.horizontal, 16) - } else { - ScrollView(showsIndicators: true) { - LazyVStack(spacing: MobileDesignGeometry.modelAccountRowGap) { - ForEach(accountModels) { option in - Button { - model.selectModel(option.id) - page = .overview - } label: { - HStack(spacing: 10) { - Image(systemName: option.selected ? "checkmark.circle" : "circle") - .foregroundStyle(option.selected ? BitFunTheme.ink : Color.clear) - .frame(width: 20, height: 20) - VStack(alignment: .leading, spacing: 2) { - Text(option.primaryLabel) - .font(MobileDesignTypography.titleSmall.font) - .foregroundStyle(BitFunTheme.ink) - .lineLimit(1) - Text(model.localized("云端账号")) - .font(MobileDesignTypography.labelSmall.font) - .foregroundStyle(BitFunTheme.muted) - } - Spacer() - } - .padding(.horizontal, 10) - .frame(height: MobileDesignGeometry.modelAccountRowHeight) - .background(option.selected ? BitFunTheme.soft : Color.clear) - .clipShape(RoundedRectangle(cornerRadius: 9)) - } - .buttonStyle(.plain) + SettingsGroup(title: "关于") { + VStack(spacing: 0) { + SettingsValueRow( + icon: nil, + title: "产品", + value: "BitFun iOS版" + ) + Divider().overlay(BitFunTheme.line).padding(.horizontal, 26) + SettingsValueRow(icon: nil, title: "版本", value: appVersion) } } - .padding(.horizontal, 10) - .padding(.top, MobileDesignGeometry.modelListTopPadding) - .padding(.bottom, MobileDesignGeometry.modelListBottomPadding) } + .padding(.horizontal, 16) + .padding(.top, 64) + .padding(.bottom, 34) + } + + Button { dismiss() } label: { + Image(systemName: "xmark") + .font(.system(size: 18, weight: .regular)) + .foregroundStyle(BitFunTheme.ink) + .frame(width: 40, height: 40) + .background(BitFunTheme.card) + .clipShape(Circle()) + } + .buttonStyle(.plain) + .accessibilityLabel(model.localized("关闭")) + .padding(.top, 22) + .padding(.trailing, 18) + + if model.languagePickerOpen { + LanguagePickerSheet(model: model) + .transition(.move(edge: .trailing).combined(with: .opacity)) + } else if model.generalConfigOpen { + GeneralChatConfigSheet(model: model) + .transition(.move(edge: .trailing).combined(with: .opacity)) + } else if accountOpen { + AccountSettingsView(model: model, onClose: { accountOpen = false }) + .transition(.move(edge: .trailing).combined(with: .opacity)) } } + .background(BitFunTheme.page) + .animation(.easeInOut(duration: 0.2), value: model.languagePickerOpen) + .animation(.easeInOut(duration: 0.2), value: model.generalConfigOpen) + .animation(.easeInOut(duration: 0.2), value: accountOpen) } +} - private var localEditor: some View { - ScrollView(showsIndicators: false) { - VStack(alignment: .leading, spacing: 20) { - labeledField("API URL", placeholder: "https://api.example.com", text: $baseURL, secure: false) - labeledField( - "API Key", - placeholder: model.generalConfigHasAPIKey ? "API Key(留空则保留)" : "请输入 API Key", - text: $apiKey, - secure: true - ) - if model.generalConfigHasAPIKey { +private struct LanguagePickerSheet: View { + @ObservedObject var model: MobileAppModel + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + BitFunSelectionHeader(title: "选择语言", onClose: { model.languagePickerOpen = false }) + Divider().overlay(BitFunTheme.line) + + VStack(spacing: 0) { + ForEach(MobileLanguage.allCases) { language in Button { - clearAPIKey.toggle() - apiKey = "" + model.setLanguage(language) + model.languagePickerOpen = false } label: { - Text(model.localized(clearAPIKey ? "保留已保存的 Key" : "清除已保存的 API Key")) - .font(MobileDesignTypography.bodySmall.font) - .foregroundStyle(clearAPIKey ? BitFunTheme.ink : BitFunTheme.red) + HStack { + Text(language.nativeName) + .font(.system(size: 16, weight: .medium)) + .foregroundStyle(BitFunTheme.ink) + Spacer() + if model.appLanguage == language { + Image(systemName: "checkmark") + .font(.system(size: 18, weight: .medium)) + .foregroundStyle(BitFunTheme.ink) + } + } + .padding(.horizontal, 16) + .frame(height: MobileDesignGeometry.selectionRowHeight) + .contentShape(Rectangle()) } .buttonStyle(.plain) } - labeledField("模型名称", placeholder: "例如 chat-model", text: $modelName, secure: false) - HStack(spacing: 12) { - editorAction(title: model.generalConnectionTestRunning ? "测试中…" : "测试连接", primary: false) { - model.testGeneralConnection( - baseURL: baseURL, model: modelName, apiKey: apiKey, clearAPIKey: clearAPIKey - ) - } - .disabled(model.generalConnectionTestRunning || (apiKey.isEmpty && (!model.generalConfigHasAPIKey || clearAPIKey))) - editorAction(title: "保存", primary: true) { - model.saveGeneralConfig( - baseURL: baseURL, model: modelName, apiKey: apiKey, clearAPIKey: clearAPIKey - ) - } - } - if apiKey.isEmpty && (!model.generalConfigHasAPIKey || clearAPIKey) { - Text(model.localized("保留或输入 API Key 后可测试连接。")) - .font(MobileDesignTypography.labelSmall.font) - .foregroundStyle(MobileDesignColors.subtle) - } - if let failure = model.generalConfigFailure { - Text(configFailureText(failure)) - .font(MobileDesignTypography.bodySmall.font).foregroundStyle(BitFunTheme.red) - } - if let message = model.generalConnectionTestMessage { - Text(message).font(MobileDesignTypography.bodySmall.font) - .foregroundStyle(message == model.localized("连接成功") ? BitFunTheme.green : BitFunTheme.red) - } - } - .padding(.horizontal, 16) - .padding(.top, 18) - .padding(.bottom, 30) - } - } - - private func sectionTitle(_ title: String) -> some View { - Text(model.localized(title)) - .font(MobileDesignTypography.labelMedium.font) - .foregroundStyle(BitFunTheme.muted) - } - - private func modelOverviewRow(icon: String, title: String, subtitle: String, height: CGFloat) -> some View { - HStack(spacing: 12) { - Image(systemName: icon).font(.system(size: 23)).frame(width: 28, height: 28) - VStack(alignment: .leading, spacing: 3) { - Text(title).font(MobileDesignTypography.bodyLarge.font.weight(.medium)).lineLimit(1) - if !subtitle.isEmpty { - Text(subtitle).font(MobileDesignTypography.labelSmall.font).foregroundStyle(BitFunTheme.muted) - } } - Spacer() - } - .foregroundStyle(BitFunTheme.ink) - .padding(.horizontal, 16) - .frame(maxWidth: .infinity, minHeight: height) - .background(BitFunTheme.soft) - .clipShape(RoundedRectangle(cornerRadius: MobileDesignGeometry.settingsCompactCardRadius)) - } + .padding(.top, 8) + .padding(.bottom, 28) - private func sourceRow(icon: String, title: String, subtitle: String, chevronAction: (() -> Void)?) -> some View { - HStack(spacing: 12) { - Image(systemName: icon).font(.system(size: 21)).foregroundStyle(BitFunTheme.muted).frame(width: 28, height: 28) - VStack(alignment: .leading, spacing: 3) { - Text(model.localized(title)).font(MobileDesignTypography.titleSmall.font).foregroundStyle(BitFunTheme.ink).lineLimit(1) - if !subtitle.isEmpty { - Text(subtitle).font(MobileDesignTypography.labelSmall.font).foregroundStyle(BitFunTheme.muted).lineLimit(1) - } - } - Spacer() - if chevronAction != nil { - Image(systemName: "chevron.right").font(.system(size: 14, weight: .medium)).foregroundStyle(BitFunTheme.muted) - } + Spacer(minLength: 0) } - .padding(.horizontal, 16) - .frame(maxWidth: .infinity, minHeight: MobileDesignGeometry.modelSourceRowHeight) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .background(BitFunTheme.card) + .clipShape(RoundedRectangle(cornerRadius: MobileDesignGeometry.selectionTopRadius)) } +} - private func sourceLabel(_ source: String) -> String { - model.localized(source == "LOCAL" ? "本机" : "云端账号") - } - @ViewBuilder - private func labeledField(_ label: String, placeholder: String, text: Binding, secure: Bool) -> some View { - VStack(alignment: .leading, spacing: 8) { - Text(model.localized(label)) - .font(MobileDesignTypography.labelMedium.font) - .foregroundStyle(BitFunTheme.ink) - Group { - if secure { SecureField(model.localized(placeholder), text: text) } - else { TextField(model.localized(placeholder), text: text) } - } - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - .font(MobileDesignTypography.bodyMedium.font) - .padding(.horizontal, 14) - .frame(height: 52) - .background(BitFunTheme.soft) - .clipShape(RoundedRectangle(cornerRadius: MobileDesignGeometry.settingsCompactCardRadius)) - } - } - private func editorAction(title: String, primary: Bool, action: @escaping () -> Void) -> some View { - Button(action: action) { - Text(model.localized(title)) - .font(MobileDesignTypography.bodyLarge.font.weight(.medium)) - .foregroundStyle(primary ? Color.white : BitFunTheme.ink) - .frame(maxWidth: .infinity, minHeight: 50) - .background(primary ? BitFunTheme.accent : BitFunTheme.soft) - .clipShape(Capsule()) - } - .buttonStyle(.plain) - } - private func configFailureText(_ failure: String) -> String { - switch failure { - case "INVALID_URL": model.localized("请输入有效的服务地址") - case "MODEL_REQUIRED": model.localized("请输入模型名称") - case "API_KEY_REQUIRED": model.localized("请输入 API Key") - default: model.localized("配置无法保存,请稍后重试") - } - } -} private struct PermissionModeRow: View { @ObservedObject var model: MobileAppModel @@ -2631,7 +722,7 @@ private struct SettingsGroup: View { } } -private struct SettingsCard: View { +struct SettingsCard: View { @ViewBuilder let content: () -> Content var body: some View { @@ -2703,37 +794,3 @@ private struct SettingsValueRow: View { .frame(height: 52) } } - -private struct SettingsDeviceRow: View { - let device: MobileAccountDevice - - var body: some View { - HStack(spacing: 14) { - Image(systemName: "desktopcomputer") - .font(.system(size: 21, weight: .regular)) - .foregroundStyle(BitFunTheme.muted) - .frame(width: 42, height: 42) - VStack(alignment: .leading, spacing: 3) { - Text(device.name) - .font(.system(size: 16, weight: .medium)) - .foregroundStyle(BitFunTheme.ink) - .lineLimit(1) - Text(MobileLocalization.text(device.online ? "在线" : "离线")) - .font(.system(size: 12)) - .foregroundStyle(device.online ? BitFunTheme.green : BitFunTheme.muted) - } - Spacer(minLength: 12) - if device.selected { - Image(systemName: "checkmark.circle.fill") - .font(.system(size: 18)) - .foregroundStyle(BitFunTheme.green) - } else { - Image(systemName: "chevron.right") - .font(.system(size: 14, weight: .medium)) - .foregroundStyle(BitFunTheme.muted) - } - } - .padding(.horizontal, 20) - .frame(minHeight: 76) - } -} diff --git a/src/apps/mobile/ios/BitFun/Features/Shell/RemoteCreateSessionView.swift b/src/apps/mobile/ios/BitFun/Features/Shell/RemoteCreateSessionView.swift new file mode 100644 index 0000000000..b957a76ed8 --- /dev/null +++ b/src/apps/mobile/ios/BitFun/Features/Shell/RemoteCreateSessionView.swift @@ -0,0 +1,495 @@ +import SwiftUI +import OSLog + +struct RemoteCreateSessionView: View { + @ObservedObject var model: MobileAppModel + let onBack: () -> Void + @Environment(\.horizontalSizeClass) private var horizontalSizeClass + @StateObject private var speech = SpeechInputController() + @State private var instruction = "" + @State private var selectedWorkspacePath = "" + @State private var selectedModelID: String? + @State private var pickerKind: RemoteCreateSelectionKind? = ProcessInfo.processInfo.arguments.contains( + "--remote-create-workspace-picker" + ) ? .workspace : nil + private let log = Logger(subsystem: "com.bitfun.mobile.ios", category: "remote-create-ui") + + var body: some View { + VStack(spacing: 0) { + HStack { + Button(action: onBack) { + Image(systemName: "chevron.left") + .font(.system(size: 19, weight: .medium)) + .foregroundStyle(BitFunTheme.ink) + .frame(width: 44, height: 44) + .background(BitFunTheme.card) + .clipShape(Circle()) + } + .buttonStyle(.plain) + .accessibilityLabel(model.localized("返回")) + Spacer() + } + .frame(height: 78, alignment: .top) + .padding(.leading, 18) + .padding(.top, 14) + + Spacer(minLength: 12) + + if !model.remoteConnected { + createStatus(message: model.localized("连接不可用,请重新连接"), retryTitle: model.localized("重试"), action: model.verifyRemoteConnection) + } else if let error = model.remoteCreateError ?? model.remoteCreateDeviceError ?? + (model.workspaceLoadFailed ? (model.coreErrorMessage ?? model.localized("工作区加载失败,请重试")) : nil) { + createStatus(message: error, retryTitle: model.localized("重试"), action: retryCreate) + } + + contextButton( + kind: .device, + icon: "desktopcomputer", + label: deviceLabel, + automationIdentifier: selectedDeviceAutomationIdentifier + ) + contextButton( + kind: .workspace, + icon: selectedWorkspacePath.isEmpty ? "message" : "folder", + label: model.workspaceLoading ? model.localized("正在加载工作区") : selectedWorkspaceName, + automationIdentifier: selectedWorkspaceAutomationIdentifier + ) + createComposer + } + .background(BitFunTheme.page) + .overlayPreferenceValue(RemoteCreateSelectionAnchorKey.self) { anchors in + GeometryReader { proxy in + if horizontalSizeClass == .regular, + let kind = pickerKind, + let anchor = anchors[kind] { + let frame = proxy[anchor] + ZStack(alignment: .topLeading) { + Color.clear + .contentShape(Rectangle()) + .onTapGesture { pickerKind = nil } + selectionContent(kind: kind, includeHeader: false) + .bitFunPopoverSurface() + .fixedSize(horizontal: false, vertical: true) + .position( + x: min( + max(MobileDesignGeometry.popoverWidth / 2 + 8, frame.midX), + proxy.size.width - MobileDesignGeometry.popoverWidth / 2 - 8 + ), + y: max(120, frame.minY - selectionHeight(kind) / 2 - 8) + ) + } + } + } + } + .sheet(item: compactPicker) { kind in + selectionContent(kind: kind, includeHeader: true) + .presentationDetents([.height(selectionHeight(kind))]) + .presentationDragIndicator(.visible) + } + .onAppear { + if let selected = model.remoteWorkspaces.first(where: \.selected) { + selectedWorkspacePath = selected.path + } + selectedModelID = model.modelOptions.first(where: \.selected)?.id ?? model.modelOptions.first?.id + } + } + + private var compactPicker: Binding { + Binding( + get: { horizontalSizeClass == .regular ? nil : pickerKind }, + set: { pickerKind = $0 } + ) + } + + private var deviceLabel: String { + if model.accountRefreshing || model.accountBusy { return model.localized("正在加载") } + return model.accountDeviceName ?? model.localized("选择桌面设备") + } + + private var selectedWorkspaceName: String { + guard !selectedWorkspacePath.isEmpty else { return model.localized("对话") } + return model.remoteWorkspaces.first(where: { $0.path == selectedWorkspacePath })?.name + ?? selectedWorkspacePath + } + + private var selectedModel: ComposerModelOption? { + model.modelOptions.first(where: { $0.id == selectedModelID }) ?? model.modelOptions.first + } + + private var selectedDeviceAutomationIdentifier: String { + guard let deviceID = model.accountSelectedDeviceID, + !deviceID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + return "remoteCreate.device.unselected" + } + return "remoteCreate.device.\(deviceID)" + } + + private var selectedWorkspaceAutomationIdentifier: String { + selectedWorkspacePath.isEmpty + ? "remoteCreate.workspace.chat" + : "remoteCreate.workspace.\(selectedWorkspacePath)" + } + + private func contextButton( + kind: RemoteCreateSelectionKind, + icon: String, + label: String, + automationIdentifier: String + ) -> some View { + Button { pickerKind = kind } label: { + HStack(spacing: 13) { + Image(systemName: icon) + .font(.system(size: 20, weight: .medium)) + .foregroundStyle(BitFunTheme.muted) + .frame(width: 26, height: 26) + Text(label) + .font(.system(size: 16, weight: .medium)) + .foregroundStyle(BitFunTheme.ink) + .lineLimit(1) + Image(systemName: pickerKind == kind ? "chevron.up" : "chevron.down") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(BitFunTheme.muted) + Spacer(minLength: 0) + } + .frame(height: 48) + .padding(.horizontal, 28) + } + .buttonStyle(.plain) + .disabled(model.busy || model.remoteCreateSubmitting || model.accountBusy) + .accessibilityIdentifier(automationIdentifier) + .accessibilityLabel(model.localized(kind.accessibilityLabelKey)) + .accessibilityValue(label) + .accessibilityHint(model.localized(kind.accessibilityHintKey)) + .anchorPreference(key: RemoteCreateSelectionAnchorKey.self, value: .bounds) { + [kind: $0] + } + } + + private var createComposer: some View { + VStack(spacing: 2) { + TextField( + "", + text: $instruction, + prompt: Text(model.localized(speech.isListening ? "正在聆听" : "告诉 BitFun 要做什么")) + .foregroundColor(speech.isListening ? BitFunTheme.green : BitFunTheme.muted), + axis: .vertical + ) + .font(MobileDesignTypography.bodyLarge.font) + .lineLimit(1...4) + .padding(.horizontal, 6) + .frame(minHeight: MobileDesignGeometry.composerExpandedInputRowHeight) + + HStack(spacing: 8) { + if let selectedModel { + Button { pickerKind = .model } label: { + HStack(spacing: 4) { + Text(selectedModel.primaryLabel) + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(BitFunTheme.ink) + .lineLimit(1) + Image(systemName: pickerKind == .model ? "chevron.up" : "chevron.down") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(BitFunTheme.muted) + } + .frame(height: 34) + } + .buttonStyle(.plain) + .anchorPreference(key: RemoteCreateSelectionAnchorKey.self, value: .bounds) { + [.model: $0] + } + .accessibilityLabel(model.localized(RemoteCreateSelectionKind.model.accessibilityLabelKey)) + .accessibilityValue(selectedModel.primaryLabel) + .accessibilityHint(model.localized(RemoteCreateSelectionKind.model.accessibilityHintKey)) + } + Spacer(minLength: 0) + Button(action: primaryAction) { + Group { + if model.remoteCreateSubmitting { + ProgressView() + .tint(Color.white) + } else { + Image(systemName: instruction.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + ? (speech.isListening ? "stop.fill" : "mic.fill") + : "arrow.up") + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(canSubmit ? Color.white : BitFunTheme.ink) + } + } + .frame( + width: MobileDesignGeometry.composerActionSize, + height: MobileDesignGeometry.composerActionSize + ) + .background(canSubmit ? BitFunTheme.accent : BitFunTheme.soft) + .clipShape(Circle()) + } + .buttonStyle(.plain) + // A session-list refresh is not an active turn and must not disable creation here. + .disabled(model.remoteCreateSubmitting || !model.remoteConnected) + .accessibilityLabel(model.remoteCreateSubmitting ? model.localized("正在加载") : model.localized("发送")) + } + .frame(height: MobileDesignGeometry.composerExpandedActionRowHeight) + } + .padding(.horizontal, 8) + .padding(.top, 4) + .padding(.bottom, 2) + .frame(minHeight: MobileDesignGeometry.composerExpandedHeight) + .background(BitFunTheme.card) + .clipShape(RoundedRectangle(cornerRadius: MobileDesignGeometry.composerExpandedRadius)) + .shadow(color: .black.opacity(0.05), radius: 10, y: 2) + .padding(.horizontal, MobileDesignGeometry.contentGutter) + .padding(.top, 8) + .padding(.bottom, 14) + } + + private var canSubmit: Bool { + !instruction.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && + model.remoteConnected && !model.remoteCreateSubmitting + } + + private func createStatus(message: String, retryTitle: String, action: @escaping () -> Void) -> some View { + HStack(alignment: .top, spacing: 8) { + Image(systemName: "exclamationmark.triangle") + .foregroundStyle(BitFunTheme.red) + Text(message) + .font(.system(size: 13)) + .foregroundStyle(BitFunTheme.ink) + .multilineTextAlignment(.leading) + Spacer(minLength: 4) + Button(retryTitle, action: action) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(BitFunTheme.accent) + .disabled(model.remoteCreateSubmitting || model.accountBusy) + .accessibilityLabel(retryTitle) + .accessibilityHint(model.localized("选择")) + } + .padding(.horizontal, 18) + .padding(.vertical, 10) + .background(BitFunTheme.soft) + .accessibilityElement(children: .combine) + .accessibilityLabel("\(model.localized("状态")): \(message)") + } + + private func retryCreate() { + if model.remoteCreateError != nil { + model.createRemoteSession( + agentType: selectedWorkspacePath.isEmpty ? "Claw" : "code", + title: "", + instruction: instruction, + modelID: selectedModelID, + workspacePath: selectedWorkspacePath.isEmpty ? nil : selectedWorkspacePath + ) + } else if model.workspaceLoadFailed { + model.retryRemoteWorkspaces() + } else { + model.refreshRemoteDevices() + } + } + + private func primaryAction() { + let value = instruction.trimmingCharacters(in: .whitespacesAndNewlines) + log.info("Remote create primary action invoked: hasInput=\(!value.isEmpty, privacy: .public) connected=\(model.remoteConnected, privacy: .public) busy=\(model.busy, privacy: .public) submitting=\(model.remoteCreateSubmitting, privacy: .public)") + if !value.isEmpty { + guard canSubmit else { + log.error("Remote create primary action blocked by model state: connected=\(model.remoteConnected, privacy: .public) busy=\(model.busy, privacy: .public) submitting=\(model.remoteCreateSubmitting, privacy: .public)") + return + } + model.createRemoteSession( + agentType: selectedWorkspacePath.isEmpty ? "Claw" : "code", + title: "", + instruction: value, + modelID: selectedModelID, + workspacePath: selectedWorkspacePath.isEmpty ? nil : selectedWorkspacePath + ) + return + } + if speech.isListening { + speech.stop() + return + } + speech.start( + localeIdentifier: model.appLanguage == .simplifiedChinese ? "zh-CN" : "en-US", + onPartial: { instruction = $0 }, + onFailure: { model.showToast(model.localized($0)) } + ) + } + + @ViewBuilder + private func selectionContent(kind: RemoteCreateSelectionKind, includeHeader: Bool) -> some View { + VStack(spacing: 0) { + if includeHeader { + BitFunSelectionHeader(title: model.localized(kind.titleKey), onClose: { pickerKind = nil }) + } + ScrollView(showsIndicators: false) { + VStack(spacing: 0) { + switch kind { + case .device: + ForEach(model.accountDevices) { device in + selectionRow( + kind: .device, + icon: "desktopcomputer", + title: device.name.isEmpty ? device.id : device.name, + subtitle: model.localized(device.online ? "在线" : "离线"), + selected: device.selected, + enabled: device.online || device.selected + ) { + pickerKind = nil + selectedWorkspacePath = "" + model.selectRemoteDevice(device) + } + } + case .workspace: + selectionRow( + kind: .workspace, + icon: "message", + title: model.localized("对话"), + subtitle: "", + selected: selectedWorkspacePath.isEmpty, + enabled: true + ) { + selectedWorkspacePath = "" + pickerKind = nil + if let assistant = model.remoteAssistants.first { + model.selectRemoteAssistant(assistant) + } + } + ForEach(model.remoteWorkspaces) { workspace in + selectionRow( + kind: .workspace, + icon: "folder", + title: workspace.name, + subtitle: workspace.path, + selected: workspace.path == selectedWorkspacePath, + enabled: true + ) { + selectedWorkspacePath = workspace.path + pickerKind = nil + model.selectRemoteWorkspace(workspace) + } + } + case .model: + if model.modelOptions.isEmpty { + Text(model.localized("暂无可用模型")) + .font(.system(size: 13)) + .foregroundStyle(BitFunTheme.muted) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(18) + .accessibilityElement() + .accessibilityLabel(model.localized("暂无可用模型")) + } else { + ForEach(model.modelOptions) { option in + selectionRow( + kind: .model, + icon: option.source == "LOCAL" ? "gearshape" : "cloud", + title: option.primaryLabel, + subtitle: option.secondaryLabel, + selected: option.id == selectedModelID, + enabled: true + ) { + selectedModelID = option.id + pickerKind = nil + } + } + } + } + } + } + } + .background(BitFunTheme.card) + } + + private func selectionRow( + kind: RemoteCreateSelectionKind, + icon: String, + title: String, + subtitle: String, + selected: Bool, + enabled: Bool, + action: @escaping () -> Void + ) -> some View { + Button(action: action) { + HStack(spacing: 12) { + Image(systemName: selected ? "checkmark.circle" : "circle") + .font(.system(size: 19)) + .foregroundStyle(selected ? BitFunTheme.ink : Color.clear) + .frame(width: 20) + Image(systemName: icon) + .font(.system(size: 19, weight: .medium)) + .foregroundStyle(BitFunTheme.muted) + .frame(width: 24) + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(.system(size: 15, weight: .medium)) + .foregroundStyle(BitFunTheme.ink) + .lineLimit(1) + if !subtitle.isEmpty { + Text(subtitle) + .font(.system(size: 11)) + .foregroundStyle(BitFunTheme.muted) + .lineLimit(1) + } + } + Spacer(minLength: 0) + } + .frame(minHeight: 58) + .padding(.horizontal, 12) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(!enabled) + .opacity(enabled ? 1 : 0.55) + .accessibilityLabel("\(model.localized(kind.accessibilityLabelKey)): \(title)") + .accessibilityValue(subtitle) + .accessibilityHint(model.localized(kind.accessibilityHintKey)) + .accessibilityAddTraits(selected ? [.isSelected] : []) + } + + private func selectionHeight(_ kind: RemoteCreateSelectionKind) -> CGFloat { + let count: Int + switch kind { + case .device: count = max(1, model.accountDevices.count) + case .workspace: count = max(1, model.remoteWorkspaces.count + 1) + case .model: count = max(1, model.modelOptions.count) + } + let header: CGFloat = horizontalSizeClass == .regular ? 16 : MobileDesignGeometry.sheetHeaderHeight + return min(440, header + CGFloat(count * 64) + 24) + } +} + +enum RemoteCreateSelectionKind: String, Identifiable, Hashable { + case device + case workspace + case model + + var id: String { rawValue } + + /// Stable localization keys owned by the mobile W3 catalog. The view is + /// responsible for resolving them with the active app language. + var titleKey: String { + switch self { + case .device: return "桌面设备" + case .workspace: return "工作区" + case .model: return "选择模型" + } + } + + var accessibilityLabelKey: String { + switch self { + case .device: return "桌面设备" + case .workspace: return "工作区" + case .model: return "选择模型" + } + } + + var accessibilityHintKey: String { "选择" } +} + +struct RemoteCreateSelectionAnchorKey: PreferenceKey { + static var defaultValue: [RemoteCreateSelectionKind: Anchor] = [:] + + static func reduce( + value: inout [RemoteCreateSelectionKind: Anchor], + nextValue: () -> [RemoteCreateSelectionKind: Anchor] + ) { + value.merge(nextValue(), uniquingKeysWith: { _, next in next }) + } +} diff --git a/src/apps/mobile/ios/BitFun/Features/Shell/RemoteFilePreviewView.swift b/src/apps/mobile/ios/BitFun/Features/Shell/RemoteFilePreviewView.swift new file mode 100644 index 0000000000..abf2a62a31 --- /dev/null +++ b/src/apps/mobile/ios/BitFun/Features/Shell/RemoteFilePreviewView.swift @@ -0,0 +1,303 @@ +import SwiftUI +import UniformTypeIdentifiers + +private var filePreviewScrollAnchorByTarget: [String: Int] = [:] + +private struct FilePreviewVisibleAnchor: Equatable { + let id: Int + let distanceFromTop: CGFloat +} + +private struct MarkdownSourceBlock: Identifiable { + let startLine: Int + let endLine: Int + let text: String + var id: Int { startLine } +} + +private func markdownSourceBlocks(_ content: String) -> [MarkdownSourceBlock] { + let parts = content.components(separatedBy: "\n\n") + var nextStart = 1 + return parts.map { text in + let end = nextStart + text.reduce(0) { $0 + ($1 == "\n" ? 1 : 0) } + defer { nextStart = end + 2 } + return MarkdownSourceBlock(startLine: nextStart, endLine: max(nextStart, end), text: text) + } +} + +private func markdownAnchor(for lineStart: Int32, blocks: [MarkdownSourceBlock]) -> Int { + guard !blocks.isEmpty else { return 1 } + let requested = max(1, Int(lineStart)) + if let containing = blocks.first(where: { $0.startLine <= requested && requested <= $0.endLine }) { + return containing.startLine + } + return blocks.last(where: { $0.startLine <= requested })?.startLine ?? blocks[0].startLine +} + +private struct FilePreviewVisibleLinePreferenceKey: PreferenceKey { + static var defaultValue: FilePreviewVisibleAnchor? + static func reduce(value: inout FilePreviewVisibleAnchor?, nextValue: () -> FilePreviewVisibleAnchor?) { + guard let next = nextValue() else { return } + if value == nil || next.distanceFromTop < value!.distanceFromTop { value = next } + } +} + +struct MobileDownloadDocument: FileDocument { + static var readableContentTypes: [UTType] { [.data] } + let data: Data + + init(data: Data) { + self.data = data + } + + init(configuration: ReadConfiguration) throws { + data = configuration.file.regularFileContents ?? Data() + } + + func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { + FileWrapper(regularFileWithContents: data) + } +} + +struct RemoteFilePreviewSheet: View { + @ObservedObject var model: MobileAppModel + let preview: MobileFilePreview + var embedded = false + @Environment(\.dismiss) private var dismiss + @State private var visibleLine: Int = 0 + + private var scrollTargetKey: String { + "\(preview.sessionID)|\(preview.controlTargetEpoch)|\(preview.id)" + } + + private func formatBytes(_ value: Int64) -> String { + if value < 1024 { return "\(value) B" } + if value < 1024 * 1024 { return "\(Int((Double(value) / 1024).rounded())) KB" } + return "\(Int((Double(value) / (1024 * 1024)).rounded())) MB" + } + + private var metadataText: String { + let type = preview.mimeType + let size = preview.sizeBytes > 0 ? formatBytes(preview.sizeBytes) : "" + if type.isEmpty { return size } + if size.isEmpty { return type } + return "\(type) · \(size)" + } + + var body: some View { + VStack(spacing: 0) { + HStack(spacing: 12) { + Image(systemName: preview.imageData == nil ? "doc.text" : "photo") + .font(.system(size: 16, weight: .medium)) + .foregroundStyle(MobileDesignColors.fileLink) + .frame(width: 34, height: 34) + .background(MobileDesignColors.fileLink.opacity(0.1)) + .clipShape(RoundedRectangle(cornerRadius: 9)) + VStack(alignment: .leading, spacing: 2) { + Text(preview.name) + .font(MobileDesignTypography.titleSmall.font) + .foregroundStyle(BitFunTheme.ink) + .lineLimit(1) + if !preview.mimeType.isEmpty || preview.sizeBytes > 0 { + Text(metadataText) + .font(MobileDesignTypography.labelSmall.font) + .foregroundStyle(BitFunTheme.muted) + .lineLimit(1) + } + } + Spacer() + Button { + model.downloadRemoteFile( + reference: "computer://\(preview.id)", + label: preview.name + ) + } label: { + Image(systemName: "arrow.down.circle") + .font(.system(size: 18, weight: .medium)) + .foregroundStyle(BitFunTheme.ink) + .frame(width: 36, height: 36) + } + .buttonStyle(.plain) + .disabled([.preparing, .downloading, .saving].contains(model.downloadPhase)) + .opacity([.preparing, .downloading, .saving].contains(model.downloadPhase) ? 0.45 : 1) + .accessibilityLabel(Text(model.localizedFormat("下载 %@", preview.name))) + Button { + model.dismissFilePreview() + if !embedded { dismiss() } + } label: { + Image(systemName: "xmark") + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(BitFunTheme.ink) + .frame(width: 36, height: 36) + .background(BitFunTheme.soft) + .clipShape(Circle()) + } + .buttonStyle(.plain) + .accessibilityLabel(Text(model.localized("关闭文件预览"))) + } + .padding(.horizontal, 18) + .padding(.vertical, 12) + + Rectangle().fill(BitFunTheme.line).frame(height: 1) + + if let status = model.downloadStatus(for: preview.id), !status.isEmpty { + HStack(spacing: 8) { + if [.preparing, .downloading, .saving].contains(model.downloadPhase) { + ProgressView().controlSize(.small) + } else if model.downloadPhase == .saved { + Image(systemName: "checkmark.circle") + } else if model.downloadPhase == .failed { + Image(systemName: "exclamationmark.triangle") + .foregroundStyle(BitFunTheme.red) + } + Text(status).font(MobileDesignTypography.labelSmall.font) + .foregroundStyle(model.downloadPhase == .failed ? BitFunTheme.red : BitFunTheme.muted) + .lineLimit(2) + if model.downloadPhase == .failed { + Button(model.localized("重试")) { model.retryRemoteDownload() } + .buttonStyle(.bordered) + .disabled(!model.canRetryRemoteDownload) + .accessibilityLabel(Text(model.localized("重试下载"))) + } + Spacer(minLength: 0) + } + .padding(.horizontal, 18).padding(.vertical, 8) + .background(BitFunTheme.soft) + } + + Group { + if model.filePreviewLoading { + VStack(spacing: 12) { + ProgressView() + Text(model.localized("正在加载文件")) + .font(MobileDesignTypography.bodySmall.font) + .foregroundStyle(BitFunTheme.muted) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if preview.unsupported { + VStack(spacing: 10) { + Image(systemName: "doc.badge.questionmark") + .font(.system(size: 28, weight: .medium)) + Text(model.localized("此文件类型暂不支持预览")) + .font(MobileDesignTypography.titleSmall.font) + Text(preview.mimeType.isEmpty ? model.localized("此文件暂不支持预览") : preview.mimeType) + .font(MobileDesignTypography.bodySmall.font) + .multilineTextAlignment(.center) + } + .foregroundStyle(BitFunTheme.muted).padding(24) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let failure = preview.failure { + VStack(spacing: 10) { + Image(systemName: "exclamationmark.triangle") + .font(.system(size: 28, weight: .medium)) + Text(model.localized("无法预览")) + .font(MobileDesignTypography.titleSmall.font) + Text(failure).font(MobileDesignTypography.bodySmall.font) + .multilineTextAlignment(.center) + if preview.retryable { + Button(model.localized("重试")) { + model.openRemoteFile(reference: preview.id, label: preview.name) + } + .buttonStyle(.borderedProminent) + .tint(MobileDesignColors.fileLink) + .accessibilityLabel(Text(model.localized("重试文件预览"))) + } + } + .foregroundStyle(BitFunTheme.muted).padding(24) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let data = preview.imageData, let image = UIImage(data: data) { + ScrollView([.horizontal, .vertical], showsIndicators: false) { + Image(uiImage: image) + .resizable() + .scaledToFit() + .padding(18) + } + } else { + ScrollView(showsIndicators: false) { + if preview.markdown { + ScrollViewReader { proxy in + LazyVStack(alignment: .leading, spacing: 12) { + let blocks = markdownSourceBlocks(preview.content) + ForEach(blocks) { block in + MarkdownMessageView(text: block.text, model: model) + .id(block.startLine) + .background(GeometryReader { geometry in + Color.clear.preference( + key: FilePreviewVisibleLinePreferenceKey.self, + value: { + let frame = geometry.frame(in: .named("file-preview-scroll")) + guard frame.minY <= 0, frame.maxY >= 0 else { return nil } + return FilePreviewVisibleAnchor(id: block.startLine, distanceFromTop: abs(frame.minY)) + }() + ) + }) + } + } + .padding(18) + .onAppear { + let blocks = markdownSourceBlocks(preview.content) + let anchor = filePreviewScrollAnchorByTarget[scrollTargetKey] + ?? markdownAnchor(for: preview.lineStart, blocks: blocks) + proxy.scrollTo(anchor, anchor: .center) + } + .onDisappear { + if visibleLine > 0 { filePreviewScrollAnchorByTarget[scrollTargetKey] = visibleLine } + } + } + } else { + ScrollViewReader { proxy in + LazyVStack(alignment: .leading, spacing: 0) { + ForEach(Array(preview.content.split(separator: "\n", omittingEmptySubsequences: false).enumerated()), id: \.offset) { index, line in + Text(String(line)) + .font(.system(size: 13, design: .monospaced)) + .foregroundStyle(BitFunTheme.ink) + .frame(maxWidth: .infinity, alignment: .leading) + .background(GeometryReader { geometry in + Color.clear.preference( + key: FilePreviewVisibleLinePreferenceKey.self, + value: { + let frame = geometry.frame(in: .named("file-preview-scroll")) + guard frame.minY <= 0, frame.maxY >= 0 else { return nil } + return FilePreviewVisibleAnchor(id: index + 1, distanceFromTop: abs(frame.minY)) + }() + ) + }) + .id(index + 1) + } + } + .padding(18).textSelection(.enabled) + .onAppear { + let anchor = filePreviewScrollAnchorByTarget[scrollTargetKey] ?? + (preview.lineStart > 1 ? Int(preview.lineStart) : 1) + proxy.scrollTo(anchor, anchor: .center) + } + .onDisappear { + if visibleLine > 0 { filePreviewScrollAnchorByTarget[scrollTargetKey] = visibleLine } + } + } + } + } + .coordinateSpace(name: "file-preview-scroll") + .onPreferenceChange(FilePreviewVisibleLinePreferenceKey.self) { anchor in + guard let anchor else { return } + visibleLine = anchor.id + filePreviewScrollAnchorByTarget[scrollTargetKey] = anchor.id + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + + if preview.truncated { + Text(model.localized("文件较大,当前仅显示部分内容")) + .font(MobileDesignTypography.labelSmall.font) + .foregroundStyle(BitFunTheme.muted) + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + .background(BitFunTheme.soft) + } + } + .background(BitFunTheme.page) + .presentationDetents([.large]) + .presentationDragIndicator(.visible) + } +} diff --git a/src/apps/mobile/ios/BitFun/Features/Shell/RemoteSettingsViews.swift b/src/apps/mobile/ios/BitFun/Features/Shell/RemoteSettingsViews.swift new file mode 100644 index 0000000000..65a8a00f7f --- /dev/null +++ b/src/apps/mobile/ios/BitFun/Features/Shell/RemoteSettingsViews.swift @@ -0,0 +1,825 @@ +import SwiftUI + +struct RemoteViewSettingsView: View { + @ObservedObject var model: MobileAppModel + + private var statuses: [String] { + model.sessionListStatusOptions + } + + private var workspaces: [MobileSessionWorkspaceOption] { + model.sessionListWorkspaceOptions + } + + private var agentGroups: [String] { + model.sessionListAgentGroups + } + + var body: some View { + VStack(spacing: 0) { + BitFunModalHeader( + title: "视图设置", + subtitle: "调整会话列表的分组和信息密度", + onClose: { model.remoteViewSettingsOpen = false } + ) + .padding(.horizontal, 20) + Divider().overlay(BitFunTheme.line) + + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 8) { + sectionTitle("分组方式") + SettingsCard { + choiceRow("按项目", value: "PROJECT", selected: model.remoteGroupMode) + settingsDivider + choiceRow("按时间倒序排列", value: "TIME", selected: model.remoteGroupMode) + settingsDivider + choiceRow("聊天优先", value: "CHAT", selected: model.remoteGroupMode) + } + + sectionTitle("筛选") + filterLabel("工作区") + SettingsCard { + filterRow( + "所有工作区", + selected: model.remoteWorkspaceFilter.isEmpty, + action: { model.remoteWorkspaceFilter = "" } + ) + ForEach(workspaces) { workspace in + settingsDivider + filterRow( + workspace.name, + selected: normalizedPath(model.remoteWorkspaceFilter) == normalizedPath(workspace.path), + action: { model.remoteWorkspaceFilter = workspace.path } + ) + } + } + + filterLabel("Agent 类型") + SettingsCard { + filterRow( + "所有 Agent 类型", + selected: model.remoteViewAgentFilter.isEmpty, + action: { model.remoteViewAgentFilter = "" } + ) + ForEach(agentGroups, id: \.self) { group in + settingsDivider + filterRow( + agentLabel(group), + selected: model.remoteViewAgentFilter == group, + action: { model.remoteViewAgentFilter = group } + ) + } + } + + filterLabel("状态") + SettingsCard { + filterRow( + "所有状态", + selected: model.remoteStatusFilter.isEmpty, + action: { model.remoteStatusFilter = "" } + ) + ForEach(statuses, id: \.self) { status in + settingsDivider + filterRow( + statusLabel(status), + selected: model.remoteStatusFilter == status, + action: { model.remoteStatusFilter = status } + ) + } + } + + sectionTitle("显示信息") + SettingsCard { + metadataToggle("工作区", isOn: $model.remoteShowWorkspaceMetadata) + settingsDivider + metadataToggle("更新时间", isOn: $model.remoteShowUpdatedMetadata) + settingsDivider + metadataToggle("状态", isOn: $model.remoteShowStatusMetadata) + } + } + .padding(.horizontal, 20) + .padding(.top, 8) + .padding(.bottom, 34) + } + } + .background(BitFunTheme.page) + } + + private func sectionTitle(_ title: String) -> some View { + Text(model.localized(title)) + .font(MobileDesignTypography.labelLarge.font) + .foregroundStyle(BitFunTheme.muted) + .padding(.top, 8) + .padding(.leading, 4) + } + + private func filterLabel(_ title: String) -> some View { + Text(model.localized(title)) + .font(MobileDesignTypography.labelSmall.font) + .foregroundStyle(BitFunTheme.muted) + .padding(.top, 2) + .padding(.leading, 10) + } + + private var settingsDivider: some View { + Divider().overlay(BitFunTheme.line).padding(.horizontal, 20) + } + + private func choiceRow(_ title: String, value: String, selected: String) -> some View { + filterRow(title, selected: value == selected) { model.remoteGroupMode = value } + } + + private func filterRow(_ title: String, selected: Bool, action: @escaping () -> Void) -> some View { + Button(action: action) { + HStack(spacing: 12) { + Text(model.localized(title)) + .font(.system(size: 16, weight: .medium)) + .foregroundStyle(BitFunTheme.ink) + .lineLimit(1) + Spacer(minLength: 0) + if selected { + Image(systemName: "checkmark") + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(BitFunTheme.accent) + } + } + .padding(.horizontal, 20) + .frame(minHeight: 52) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + + private func metadataToggle(_ title: String, isOn: Binding) -> some View { + Toggle(isOn: isOn) { + Text(model.localized(title)) + .font(.system(size: 16, weight: .medium)) + .foregroundStyle(BitFunTheme.ink) + } + .tint(BitFunTheme.accent) + .padding(.horizontal, 20) + .frame(minHeight: 56) + } + + private func agentLabel(_ group: String) -> String { + switch group { + case "CHAT": return "聊天" + case "COWORK": return "Cowork" + default: return "Code" + } + } + + private func statusLabel(_ status: String) -> String { + switch status { + case "active", "running": return "运行中" + case "ready", "idle": return "就绪" + case "archived": return "已归档" + default: return status + } + } + + private func normalizedPath(_ path: String) -> String { + var result = path.trimmingCharacters(in: .whitespacesAndNewlines) + while result.count > 1 && (result.hasSuffix("/") || result.hasSuffix("\\")) { + result.removeLast() + } + return result + } +} + +/// The desktop-wide control page mirrors HarmonyOS' RemoteControlSettingsSheet. +/// Account navigation and full-access confirmation stay inside this adaptive +/// modal so a settings action never creates a second sheet or scrim. +struct RemoteControlSettingsView: View { + private enum Page { case control, account } + + @ObservedObject var model: MobileAppModel + @State private var page: Page = .control + @State private var confirmingFullAccess = false + + var body: some View { + Group { + if page == .account { + AccountSettingsView(model: model, onClose: { page = .control }) + } else { + controlPage + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(BitFunTheme.page) + .animation(.easeInOut(duration: 0.2), value: page) + .onAppear { + if model.remoteConnected { model.refreshRemotePermissionMode() } + } + } + + private var controlPage: some View { + ZStack(alignment: .topTrailing) { + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 0) { + Text(model.localized("远程控制")) + .font(.system(size: 20, weight: .bold)) + .foregroundStyle(BitFunTheme.ink) + .frame(maxWidth: .infinity, minHeight: 56, alignment: .center) + .padding(.bottom, 30) + + Button { page = .account } label: { + SettingsCard { + HStack(spacing: 12) { + Image(systemName: "person.crop.circle") + .font(.system(size: 28, weight: .regular)) + .foregroundStyle(BitFunTheme.muted) + .frame(width: 34, height: 34) + Text(model.localized(model.accountUser == nil ? "登录 BitFun 账号" : "个人资料")) + .font(.system(size: 18, weight: .medium)) + .foregroundStyle(BitFunTheme.ink) + Spacer() + Image(systemName: "chevron.right") + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(BitFunTheme.muted.opacity(0.72)) + } + .padding(.horizontal, 18) + .frame(height: 64) + } + } + .buttonStyle(.plain) + .padding(.bottom, 28) + + remoteSectionTitle("当前远程控制") + currentControlCard + + remoteSectionTitle("其他连接方式") + .padding(.top, 16) + Button { + model.remoteControlSettingsOpen = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.22) { + model.connectRemote() + } + } label: { + SettingsCard { + HStack(spacing: 12) { + Image(systemName: "link") + .font(.system(size: 20, weight: .regular)) + .foregroundStyle(BitFunTheme.muted) + .frame(width: 24, height: 24) + VStack(alignment: .leading, spacing: 2) { + Text(model.localized("扫描二维码连接")) + .font(.system(size: 16, weight: .medium)) + .foregroundStyle(BitFunTheme.ink) + Text(model.localized("适用于临时配对或未登录账号的桌面端。")) + .font(.system(size: 13)) + .foregroundStyle(BitFunTheme.muted) + .lineLimit(2) + } + Spacer(minLength: 8) + Image(systemName: "chevron.right") + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(BitFunTheme.muted.opacity(0.72)) + } + .padding(.horizontal, 18) + .frame(minHeight: 78) + } + } + .buttonStyle(.plain) + + permissionSection + .padding(.top, 20) + } + .padding(.horizontal, 18) + .padding(.top, 20) + .padding(.bottom, 42) + } + + Button { model.remoteControlSettingsOpen = false } label: { + Image(systemName: "xmark") + .font(.system(size: 17, weight: .regular)) + .foregroundStyle(BitFunTheme.ink) + .frame(width: 40, height: 40) + .background(BitFunTheme.card) + .clipShape(Circle()) + } + .buttonStyle(.plain) + .accessibilityLabel(model.localized("关闭")) + .padding(.top, 16).padding(.trailing, 16) + } + } + + private var currentControlCard: some View { + SettingsCard { + HStack(spacing: 14) { + Image(systemName: "desktopcomputer") + .font(.system(size: 23, weight: .regular)) + .foregroundStyle(BitFunTheme.muted) + .frame(width: 40, height: 40) + VStack(alignment: .leading, spacing: 2) { + Text(model.localized("BitFun 桌面版")) + .font(.system(size: 14)).foregroundStyle(BitFunTheme.muted) + Text(model.accountDeviceName ?? model.localized("尚未连接桌面端")) + .font(.system(size: 18, weight: .medium)).foregroundStyle(BitFunTheme.ink) + .lineLimit(1) + Text(connectionStatus) + .font(.system(size: 14)).foregroundStyle(BitFunTheme.muted) + } + Spacer(minLength: 6) + if model.remoteConnected { + remoteChip("断开", action: model.disconnectRemote) + } else if model.connectionPhase == .disconnected { + remoteChip("重新连接", action: model.verifyRemoteConnection) + } + } + .padding(.horizontal, 18) + .frame(minHeight: 92) + + Divider().overlay(BitFunTheme.line).padding(.horizontal, 18) + + HStack(spacing: 10) { + Image(systemName: "link") + .font(.system(size: 18)).foregroundStyle(BitFunTheme.muted) + .frame(width: 20, height: 20) + Text(model.localized("连接来源")) + .font(.system(size: 14)).foregroundStyle(BitFunTheme.muted) + Spacer() + Text(connectionSource) + .font(.system(size: 13)).foregroundStyle(BitFunTheme.ink) + .padding(.horizontal, 10).padding(.vertical, 5) + .background(BitFunTheme.soft).clipShape(Capsule()) + } + .padding(.horizontal, 18) + .frame(height: 52) + } + } + + private var permissionSection: some View { + VStack(alignment: .leading, spacing: 10) { + HStack { + remoteSectionTitle("远程权限") + Spacer() + if model.remoteConnected { + Button(model.localized("刷新")) { model.refreshRemotePermissionMode() } + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(BitFunTheme.ink) + .buttonStyle(.plain) + .disabled(model.busy) + } + } + SettingsCard { + Text(model.localized("控制桌面端执行工具时采用的确认方式。")) + .font(.system(size: 13)).foregroundStyle(BitFunTheme.muted) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 18).padding(.top, 16).padding(.bottom, 4) + permissionRow("ASK", title: "每次询问", detail: "执行需要授权的操作前先询问。") + Divider().overlay(BitFunTheme.line).padding(.horizontal, 18) + permissionRow("AUTO", title: "自动允许", detail: "自动允许常规操作,高风险操作仍会询问。") + Divider().overlay(BitFunTheme.line).padding(.horizontal, 18) + permissionRow("FULL_ACCESS", title: "完全访问", detail: "不再询问,允许桌面端执行所有操作。") + + if let failure = model.remotePermissionFailure, !failure.isEmpty { + Text(failure) + .font(.system(size: 12)).foregroundStyle(BitFunTheme.red) + .padding(.horizontal, 18).padding(.bottom, 10) + } + + if confirmingFullAccess { + fullAccessConfirmation + } + } + } + } + + private var fullAccessConfirmation: some View { + VStack(alignment: .leading, spacing: 10) { + Text(model.localized("确认完全访问")) + .font(.system(size: 15, weight: .bold)).foregroundStyle(BitFunTheme.red) + Text(model.localized("完全访问会取消所有操作确认。仅在你信任当前桌面端时启用。")) + .font(.system(size: 13)).foregroundStyle(BitFunTheme.ink).lineSpacing(4) + HStack(spacing: 10) { + confirmationButton("取消", destructive: false) { confirmingFullAccess = false } + confirmationButton("启用完全访问", destructive: true) { + model.setRemotePermissionMode("FULL_ACCESS") + confirmingFullAccess = false + } + } + } + .padding(16) + .overlay(RoundedRectangle(cornerRadius: 18).stroke(BitFunTheme.red, lineWidth: 1)) + .padding(.horizontal, 12).padding(.bottom, 14) + } + + private func permissionRow(_ mode: String, title: String, detail: String) -> some View { + Button { + if mode == "FULL_ACCESS" { confirmingFullAccess = true } + else { + confirmingFullAccess = false + model.setRemotePermissionMode(mode) + } + } label: { + HStack(spacing: 12) { + ZStack { + if model.remotePermissionMode == mode { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 20)).foregroundStyle(BitFunTheme.ink) + } + } + .frame(width: 22, height: 24) + VStack(alignment: .leading, spacing: 3) { + Text(model.localized(title)) + .font(.system(size: 16, weight: .medium)).foregroundStyle(BitFunTheme.ink) + Text(model.localized(detail)) + .font(.system(size: 12)).foregroundStyle(BitFunTheme.muted) + .lineLimit(2) + } + Spacer(minLength: 0) + } + .padding(.horizontal, 18) + .frame(minHeight: 72) + .contentShape(Rectangle()) + .opacity(model.remoteConnected && !model.busy ? 1 : 0.54) + } + .buttonStyle(.plain) + .disabled(!model.remoteConnected || model.busy) + } + + private func remoteSectionTitle(_ title: String) -> some View { + Text(model.localized(title)) + .font(.system(size: 18, weight: .bold)) + .foregroundStyle(BitFunTheme.muted) + .frame(maxWidth: .infinity, minHeight: 42, alignment: .leading) + .padding(.horizontal, 18) + } + + private func remoteChip(_ title: String, action: @escaping () -> Void) -> some View { + Button(action: action) { + Text(model.localized(title)) + .font(.system(size: 14)).foregroundStyle(BitFunTheme.ink) + .padding(.horizontal, 10).padding(.vertical, 7) + .background(BitFunTheme.soft).clipShape(Capsule()) + } + .buttonStyle(.plain) + } + + private func confirmationButton( + _ title: String, + destructive: Bool, + action: @escaping () -> Void + ) -> some View { + Button(action: action) { + Text(model.localized(title)) + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(destructive ? Color.white : BitFunTheme.ink) + .frame(maxWidth: .infinity, minHeight: 42) + .background(destructive ? BitFunTheme.red : BitFunTheme.soft) + .clipShape(Capsule()) + } + .buttonStyle(.plain) + } + + private var connectionStatus: String { + switch model.connectionPhase { + case .connected: model.localized(model.remoteConnected ? "已连接" : "未连接") + case .reconnecting: model.localized("正在重新连接") + case .disconnected: model.localized("连接已断开") + } + } + + private var connectionSource: String { + if model.accountSelectedDeviceID != nil { return model.localized("账号设备") } + if model.remoteConnected { return model.localized("扫码配对") } + return model.localized("未连接") + } +} + +struct GeneralChatConfigSheet: View { + private enum Page { case overview, account, local } + + @ObservedObject var model: MobileAppModel + @State private var page: Page = .overview + @State private var baseURL = "" + @State private var modelName = "" + @State private var apiKey = "" + @State private var clearAPIKey = false + + private var selectedModel: ComposerModelOption? { + model.modelOptions.first(where: \.selected) + } + + private var accountModels: [ComposerModelOption] { + model.modelOptions.filter { $0.source == "ACCOUNT" } + } + + private var localModel: ComposerModelOption? { + model.modelOptions.first { $0.source == "LOCAL" } + } + + private var localComplete: Bool { + !model.generalConfigBaseURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && + !model.generalConfigModel.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && + model.generalConfigHasAPIKey + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + modelHeader + Divider().overlay(BitFunTheme.line) + switch page { + case .overview: overview + case .account: accountSelection + case .local: localEditor + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .background(BitFunTheme.card) + .onAppear { + baseURL = model.generalConfigBaseURL + modelName = model.generalConfigModel + } + } + + private var modelHeader: some View { + HStack(spacing: 8) { + if page != .overview { + Button { page = .overview } label: { + Image(systemName: "chevron.left") + .font(.system(size: 18, weight: .medium)) + .frame(width: 42, height: 42) + } + .buttonStyle(.plain) + .foregroundStyle(BitFunTheme.ink) + .accessibilityLabel(model.localized("返回")) + } + Text(model.localized(headerTitle)) + .font(MobileDesignTypography.headlineSmall.font) + .foregroundStyle(BitFunTheme.ink) + .lineLimit(1) + Spacer(minLength: 8) + Button { model.generalConfigOpen = false } label: { + Image(systemName: "xmark") + .font(.system(size: 18, weight: .regular)) + .foregroundStyle(BitFunTheme.muted) + .frame( + width: MobileDesignGeometry.selectionCloseSize, + height: MobileDesignGeometry.selectionCloseSize + ) + } + .buttonStyle(.plain) + .accessibilityLabel(model.localized("关闭")) + } + .padding(.horizontal, 16) + .frame(height: MobileDesignGeometry.sheetHeaderHeight) + } + + private var headerTitle: String { + switch page { + case .overview: "普通对话模型" + case .account: "选择账号模型" + case .local: "本机自定义模型" + } + } + + private var overview: some View { + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: MobileDesignGeometry.modelSectionGap) { + VStack(alignment: .leading, spacing: 8) { + sectionTitle("当前使用") + modelOverviewRow( + icon: "checkmark.circle.fill", + title: selectedModel?.primaryLabel ?? model.localized("未配置"), + subtitle: selectedModel.map { sourceLabel($0.source) } ?? "", + height: MobileDesignGeometry.modelCurrentRowHeight + ) + } + VStack(alignment: .leading, spacing: 8) { + sectionTitle("模型来源") + VStack(spacing: 0) { + Button { page = .account } label: { + sourceRow( + icon: "cloud", + title: "云端账号模型", + subtitle: accountModels.isEmpty + ? model.localized("暂无可用的账号模型") + : model.localizedFormat("已同步 %d 个", accountModels.count), + chevronAction: nil + ) + } + .buttonStyle(.plain) + Divider().overlay(BitFunTheme.line).padding(.leading, 56) + HStack(spacing: 0) { + Button { + if localComplete, let localModel { model.selectModel(localModel.id) } + else { page = .local } + } label: { + sourceRow( + icon: "wrench.and.screwdriver", + title: localComplete ? model.generalConfigModel : model.localized("未配置"), + subtitle: localComplete ? model.localized("本机") : "", + chevronAction: nil + ) + } + .buttonStyle(.plain) + Button { page = .local } label: { + Image(systemName: "chevron.right") + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(BitFunTheme.muted) + .frame(width: 44, height: MobileDesignGeometry.modelSourceRowHeight) + } + .buttonStyle(.plain) + } + } + .background(BitFunTheme.soft) + .clipShape(RoundedRectangle(cornerRadius: MobileDesignGeometry.settingsCompactCardRadius)) + } + } + .padding(.horizontal, 16) + .padding(.top, MobileDesignGeometry.modelOverviewTopPadding) + .padding(.bottom, MobileDesignGeometry.modelOverviewBottomPadding) + } + } + + private var accountSelection: some View { + Group { + if accountModels.isEmpty { + Text(model.localized("暂无可用的账号模型")) + .font(MobileDesignTypography.bodyMedium.font) + .foregroundStyle(BitFunTheme.muted) + .frame(maxWidth: .infinity, minHeight: MobileDesignGeometry.modelEmptyAccountHeight, alignment: .leading) + .padding(.horizontal, 16) + } else { + ScrollView(showsIndicators: true) { + LazyVStack(spacing: MobileDesignGeometry.modelAccountRowGap) { + ForEach(accountModels) { option in + Button { + model.selectModel(option.id) + page = .overview + } label: { + HStack(spacing: 10) { + Image(systemName: option.selected ? "checkmark.circle" : "circle") + .foregroundStyle(option.selected ? BitFunTheme.ink : Color.clear) + .frame(width: 20, height: 20) + VStack(alignment: .leading, spacing: 2) { + Text(option.primaryLabel) + .font(MobileDesignTypography.titleSmall.font) + .foregroundStyle(BitFunTheme.ink) + .lineLimit(1) + Text(model.localized("云端账号")) + .font(MobileDesignTypography.labelSmall.font) + .foregroundStyle(BitFunTheme.muted) + } + Spacer() + } + .padding(.horizontal, 10) + .frame(height: MobileDesignGeometry.modelAccountRowHeight) + .background(option.selected ? BitFunTheme.soft : Color.clear) + .clipShape(RoundedRectangle(cornerRadius: 9)) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 10) + .padding(.top, MobileDesignGeometry.modelListTopPadding) + .padding(.bottom, MobileDesignGeometry.modelListBottomPadding) + } + } + } + } + + private var localEditor: some View { + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 20) { + labeledField("API URL", placeholder: "https://api.example.com", text: $baseURL, secure: false) + labeledField( + "API Key", + placeholder: model.generalConfigHasAPIKey ? "API Key(留空则保留)" : "请输入 API Key", + text: $apiKey, + secure: true + ) + if model.generalConfigHasAPIKey { + Button { + clearAPIKey.toggle() + apiKey = "" + } label: { + Text(model.localized(clearAPIKey ? "保留已保存的 Key" : "清除已保存的 API Key")) + .font(MobileDesignTypography.bodySmall.font) + .foregroundStyle(clearAPIKey ? BitFunTheme.ink : BitFunTheme.red) + } + .buttonStyle(.plain) + } + labeledField("模型名称", placeholder: "例如 chat-model", text: $modelName, secure: false) + HStack(spacing: 12) { + editorAction(title: model.generalConnectionTestRunning ? "测试中…" : "测试连接", primary: false) { + model.testGeneralConnection( + baseURL: baseURL, model: modelName, apiKey: apiKey, clearAPIKey: clearAPIKey + ) + } + .disabled(model.generalConnectionTestRunning || (apiKey.isEmpty && (!model.generalConfigHasAPIKey || clearAPIKey))) + editorAction(title: "保存", primary: true) { + model.saveGeneralConfig( + baseURL: baseURL, model: modelName, apiKey: apiKey, clearAPIKey: clearAPIKey + ) + } + } + if apiKey.isEmpty && (!model.generalConfigHasAPIKey || clearAPIKey) { + Text(model.localized("保留或输入 API Key 后可测试连接。")) + .font(MobileDesignTypography.labelSmall.font) + .foregroundStyle(MobileDesignColors.subtle) + } + if let failure = model.generalConfigFailure { + Text(configFailureText(failure)) + .font(MobileDesignTypography.bodySmall.font).foregroundStyle(BitFunTheme.red) + } + if let message = model.generalConnectionTestMessage { + Text(message).font(MobileDesignTypography.bodySmall.font) + .foregroundStyle(message == model.localized("连接成功") ? BitFunTheme.green : BitFunTheme.red) + } + } + .padding(.horizontal, 16) + .padding(.top, 18) + .padding(.bottom, 30) + } + } + + private func sectionTitle(_ title: String) -> some View { + Text(model.localized(title)) + .font(MobileDesignTypography.labelMedium.font) + .foregroundStyle(BitFunTheme.muted) + } + + private func modelOverviewRow(icon: String, title: String, subtitle: String, height: CGFloat) -> some View { + HStack(spacing: 12) { + Image(systemName: icon).font(.system(size: 23)).frame(width: 28, height: 28) + VStack(alignment: .leading, spacing: 3) { + Text(title).font(MobileDesignTypography.bodyLarge.font.weight(.medium)).lineLimit(1) + if !subtitle.isEmpty { + Text(subtitle).font(MobileDesignTypography.labelSmall.font).foregroundStyle(BitFunTheme.muted) + } + } + Spacer() + } + .foregroundStyle(BitFunTheme.ink) + .padding(.horizontal, 16) + .frame(maxWidth: .infinity, minHeight: height) + .background(BitFunTheme.soft) + .clipShape(RoundedRectangle(cornerRadius: MobileDesignGeometry.settingsCompactCardRadius)) + } + + private func sourceRow(icon: String, title: String, subtitle: String, chevronAction: (() -> Void)?) -> some View { + HStack(spacing: 12) { + Image(systemName: icon).font(.system(size: 21)).foregroundStyle(BitFunTheme.muted).frame(width: 28, height: 28) + VStack(alignment: .leading, spacing: 3) { + Text(model.localized(title)).font(MobileDesignTypography.titleSmall.font).foregroundStyle(BitFunTheme.ink).lineLimit(1) + if !subtitle.isEmpty { + Text(subtitle).font(MobileDesignTypography.labelSmall.font).foregroundStyle(BitFunTheme.muted).lineLimit(1) + } + } + Spacer() + if chevronAction != nil { + Image(systemName: "chevron.right").font(.system(size: 14, weight: .medium)).foregroundStyle(BitFunTheme.muted) + } + } + .padding(.horizontal, 16) + .frame(maxWidth: .infinity, minHeight: MobileDesignGeometry.modelSourceRowHeight) + } + + private func sourceLabel(_ source: String) -> String { + model.localized(source == "LOCAL" ? "本机" : "云端账号") + } + + @ViewBuilder + private func labeledField(_ label: String, placeholder: String, text: Binding, secure: Bool) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text(model.localized(label)) + .font(MobileDesignTypography.labelMedium.font) + .foregroundStyle(BitFunTheme.ink) + Group { + if secure { SecureField(model.localized(placeholder), text: text) } + else { TextField(model.localized(placeholder), text: text) } + } + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .font(MobileDesignTypography.bodyMedium.font) + .padding(.horizontal, 14) + .frame(height: 52) + .background(BitFunTheme.soft) + .clipShape(RoundedRectangle(cornerRadius: MobileDesignGeometry.settingsCompactCardRadius)) + } + } + + private func editorAction(title: String, primary: Bool, action: @escaping () -> Void) -> some View { + Button(action: action) { + Text(model.localized(title)) + .font(MobileDesignTypography.bodyLarge.font.weight(.medium)) + .foregroundStyle(primary ? Color.white : BitFunTheme.ink) + .frame(maxWidth: .infinity, minHeight: 50) + .background(primary ? BitFunTheme.accent : BitFunTheme.soft) + .clipShape(Capsule()) + } + .buttonStyle(.plain) + } + + private func configFailureText(_ failure: String) -> String { + switch failure { + case "INVALID_URL": model.localized("请输入有效的服务地址") + case "MODEL_REQUIRED": model.localized("请输入模型名称") + case "API_KEY_REQUIRED": model.localized("请输入 API Key") + default: model.localized("配置无法保存,请稍后重试") + } + } +} diff --git a/src/apps/mobile/ios/BitFun/Features/Shell/SidebarView.swift b/src/apps/mobile/ios/BitFun/Features/Shell/SidebarView.swift index 22dbe25a64..3a512c339e 100644 --- a/src/apps/mobile/ios/BitFun/Features/Shell/SidebarView.swift +++ b/src/apps/mobile/ios/BitFun/Features/Shell/SidebarView.swift @@ -1,5 +1,11 @@ import SwiftUI +private func normalizedDeviceKey(_ key: String?) -> String? { + guard let key else { return nil } + if key == "pairing" { return key } + return key.hasPrefix("account:") ? String(key.dropFirst("account:".count)) : key +} + struct SidebarSessionActionsAnchorKey: PreferenceKey { static var defaultValue: [String: Anchor] = [:] @@ -31,6 +37,7 @@ struct SidebarView: View { @State private var searchVisible = false @State private var visibleRecentCount = 6 @State private var expandedWorkspacePaths: Set = [] + @State private var expandedDeviceWorkspaceLists: Set = [] @State private var compactActionSession: ChatSession? @State private var workspaceCreatePath: String? @State private var remoteChatsCollapsed = false @@ -51,32 +58,13 @@ struct SidebarView: View { !model.remoteStatusFilter.isEmpty } - private var sidebarDevices: [MobileAccountDevice] { - var devices = model.accountDevices.map { device in - MobileAccountDevice( - id: device.id, - name: device.name, - online: device.online, - selected: model.usesDirectPairing - ? device.name == model.directPairingDeviceName - : device.selected - ) + private var directoryEntries: [MobileDeviceDirectoryEntry] { + var entries = model.deviceDirectory + if let direct = model.directPairingDirectoryEntry, + !entries.contains(where: { $0.id == direct.id }) { + entries.insert(direct, at: 0) } - if model.usesDirectPairing, - let name = model.directPairingDeviceName, - !name.isEmpty, - !devices.contains(where: { $0.name == name }) { - devices.insert( - MobileAccountDevice( - id: model.directPairingSidebarDeviceID, - name: name, - online: true, - selected: true - ), - at: 0 - ) - } - return devices + return entries } var body: some View { @@ -355,7 +343,7 @@ struct SidebarView: View { .frame(height: 38) .padding(.top, 18) - if sidebarDevices.isEmpty { + if directoryEntries.isEmpty { Text(model.localized("尚未连接桌面设备")) .font(.system(size: 13)) .foregroundStyle(BitFunTheme.muted) @@ -363,39 +351,96 @@ struct SidebarView: View { .frame(height: 42, alignment: .leading) } - ForEach(sidebarDevices) { device in - Button { - if model.usesDirectPairing && device.name == model.directPairingDeviceName { - model.openRemoteSurface() - } else { - model.selectRemoteDevice(device) - } - } label: { - HStack(spacing: 10) { - ReferenceImage(assetName: "SidebarDeviceGlyph", width: 22, height: 18) - Text(device.name) - .font(.system(size: 15)) - .foregroundStyle(BitFunTheme.ink) - .lineLimit(1) - Spacer(minLength: 0) - Circle() - .fill(device.online ? BitFunTheme.green : BitFunTheme.muted) - .frame(width: 7, height: 7) - ReferenceImage( - assetName: device.selected ? "SidebarDownGlyph" : "SidebarChevronGlyph", - width: 14, - height: 14 - ) - } - .padding(.horizontal, 10) - .frame(height: 46) - .contentShape(Rectangle()) + ForEach(directoryEntries) { device in + directoryDevice(device) + } + + } + } + + @ViewBuilder + private func directoryDevice(_ device: MobileDeviceDirectoryEntry) -> some View { + let current = model.accountSelectedDeviceID == device.id || device.id == model.directPairingSidebarDeviceID + VStack(alignment: .leading, spacing: 0) { + Button { model.toggleDeviceDirectory(device) } label: { + HStack(spacing: 10) { + ReferenceImage(assetName: "SidebarDeviceGlyph", width: 22, height: 18) + Text(device.name).font(.system(size: 15, weight: current ? .medium : .regular)) + .foregroundStyle(BitFunTheme.ink).lineLimit(1) + Spacer(minLength: 0) + Circle().fill(device.online ? BitFunTheme.green : BitFunTheme.muted).frame(width: 7, height: 7) + if current { Text(model.localized("当前控制")).font(.system(size: 11)).foregroundStyle(BitFunTheme.green) } + if device.status == "LOADING" { ProgressView().controlSize(.small) } + Image(systemName: device.expanded ? "chevron.down" : "chevron.right") + .font(.system(size: 12, weight: .medium)).foregroundStyle(BitFunTheme.muted) } - .buttonStyle(.plain) - .disabled(!device.online && !device.selected) - if device.selected { activeRemoteDeviceBody } + .padding(.horizontal, 10).frame(minHeight: 46).contentShape(Rectangle()) } + .buttonStyle(.plain) + .accessibilityIdentifier("sidebar.device.\(device.id)") + .accessibilityLabel(Text(device.name)) + .accessibilityValue(Text(device.online ? model.localized("在线") : model.localized("离线"))) + if device.expanded { directoryDeviceBody(device) } + } + } + @ViewBuilder + private func directoryDeviceBody(_ device: MobileDeviceDirectoryEntry) -> some View { + if device.status == "LOADING" && device.workspaces.isEmpty && device.sessions.isEmpty { + HStack(spacing: 8) { ProgressView().controlSize(.small); Text(model.localized("正在加载工作区")).font(.system(size: 13)).foregroundStyle(BitFunTheme.muted) } + .padding(.horizontal, 18).frame(height: 42) + } else if device.status == "FAILED" { + Button { model.retryDeviceDirectory(device) } label: { + Text(model.localized("工作区加载失败,点按重试")).font(.system(size: 13)).foregroundStyle(BitFunTheme.red) + .frame(maxWidth: .infinity, minHeight: 42, alignment: .leading).padding(.leading, 18) + }.buttonStyle(.plain) + } else if device.status == "READY" && device.online && device.workspaces.isEmpty && device.sessions.isEmpty { + Text(model.localized("这台电脑还没有工作区")) + .font(.system(size: 13)) + .foregroundStyle(BitFunTheme.muted) + .padding(.horizontal, 18) + .frame(maxWidth: .infinity, minHeight: 42, alignment: .leading) + .accessibilityIdentifier("sidebar.emptyWorkspaces") + } + ForEach((expandedDeviceWorkspaceLists.contains(device.id) ? device.workspaces : Array(device.workspaces.prefix(3)))) { workspace in + let scopedWorkspace = MobileWorkspaceGroup( + path: workspace.path, + name: workspace.name, + selected: workspace.selected, + sessions: workspace.sessions.map { session in + var scopedSession = session + scopedSession.deviceKey = device.id + return scopedSession + }, + deviceKey: device.id + ) + SidebarWorkspaceRow( + workspace: scopedWorkspace, + expanded: expandedWorkspacePaths.contains(workspace.id), + selectedSessionID: model.surface == .remote ? model.selectedSessionID : nil, + metadata: { _ in nil }, + onToggle: { if expandedWorkspacePaths.contains(workspace.id) { expandedWorkspacePaths.remove(workspace.id) } else { expandedWorkspacePaths.insert(workspace.id) } }, + onToggleCreate: { + model.openDirectoryRemoteDraft(device: device, workspace: scopedWorkspace) + }, + onOpenWorkspace: { model.selectDirectoryWorkspace(scopedWorkspace) }, + onOpenSession: { model.selectDirectorySession($0) }, onActions: { session in + if permanent { onPermanentActions?(session) } else { compactActionSession = session } + }, + sessionLimit: expandedWorkspacePaths.contains(workspace.id) ? workspace.sessions.count : 3, + selectedDeviceKey: model.accountSelectedDeviceID, + selectedWorkspacePath: model.workspaceCatalog.first(where: { $0.selected })?.path, + onShowMore: { expandedWorkspacePaths.insert(workspace.id) } + ) + .padding(.leading, 20) + } + if device.workspaces.count > 3 { + Button { + expandedDeviceWorkspaceLists.insert(device.id) + } label: { + Text(model.localizedFormat("还有 %lld 个工作区", Int64(device.workspaces.count - 3))) + .font(.system(size: 13)).foregroundStyle(BitFunTheme.muted).padding(.leading, 42).frame(height: 36, alignment: .leading) + }.buttonStyle(.plain) } } @@ -472,7 +517,8 @@ struct SidebarView: View { path: section.path, name: section.name, selected: source?.selected ?? false, - sessions: section.sessions + sessions: section.sessions, + deviceKey: normalizedDeviceKey(model.remoteExpectedDeviceKey) ) } return ForEach(workspaces) { workspace in @@ -497,7 +543,9 @@ struct SidebarView: View { model.surface = .remote if permanent { onPermanentActions?(session) } else { compactActionSession = session } - } + }, + selectedDeviceKey: normalizedDeviceKey(model.remoteExpectedDeviceKey), + selectedWorkspacePath: model.workspaceCatalog.first(where: { $0.selected })?.path ) } } @@ -507,9 +555,9 @@ struct SidebarView: View { ) -> some View { let buckets = sections.compactMap { section -> RemoteTimeBucket? in switch section.kind { - case .today: return RemoteTimeBucket(id: section.id, title: "今天", sessions: section.sessions) - case .yesterday: return RemoteTimeBucket(id: section.id, title: "昨天", sessions: section.sessions) - case .earlier: return RemoteTimeBucket(id: section.id, title: "更早", sessions: section.sessions) + case .today: return RemoteTimeBucket(id: section.id, title: "sidebar.time.today", sessions: section.sessions) + case .yesterday: return RemoteTimeBucket(id: section.id, title: "sidebar.time.yesterday", sessions: section.sessions) + case .earlier: return RemoteTimeBucket(id: section.id, title: "sidebar.time.older", sessions: section.sessions) default: return nil } } @@ -789,6 +837,7 @@ private struct SidebarRecentRow: View { .contentShape(Rectangle()) } .buttonStyle(.plain) + .accessibilityIdentifier("sidebar.recentSession.\(session.id)") Button { onActions() @@ -827,6 +876,21 @@ private struct SidebarWorkspaceRow: View { let onOpenWorkspace: () -> Void let onOpenSession: (ChatSession) -> Void let onActions: (ChatSession) -> Void + var sessionLimit: Int = 3 + var selectedDeviceKey: String? = nil + var selectedWorkspacePath: String? = nil + var onShowMore: (() -> Void)? = nil + + private func isSelected(_ session: ChatSession) -> Bool { + guard selectedSessionID == session.id, + normalizedDeviceKey(selectedDeviceKey) == normalizedDeviceKey(workspace.deviceKey) else { return false } + func normalized(_ path: String?) -> String { + var value = (path ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + while value.count > 1 && value.hasSuffix("/") { value.removeLast() } + return value + } + return normalized(selectedWorkspacePath) == normalized(workspace.path) + } var body: some View { VStack(alignment: .leading, spacing: 0) { @@ -842,6 +906,8 @@ private struct SidebarWorkspaceRow: View { .contentShape(Rectangle()) } .buttonStyle(.plain) + .accessibilityIdentifier("sidebar.workspace.\(workspace.deviceKey ?? "unknown").\(workspace.path)") + .accessibilityValue(Text(workspace.path)) Spacer(minLength: 0) Button(action: onToggleCreate) { Image(systemName: "square.and.pencil") @@ -851,6 +917,7 @@ private struct SidebarWorkspaceRow: View { } .buttonStyle(.plain) .accessibilityLabel(MobileLocalization.text("新建远程会话")) + .accessibilityIdentifier("sidebar.newSession.\(workspace.deviceKey ?? "unknown").\(workspace.path)") .anchorPreference( key: SidebarWorkspaceCreateAnchorKey.self, value: .bounds, @@ -883,10 +950,13 @@ private struct SidebarWorkspaceRow: View { .padding(.leading, 42) .frame(height: 38, alignment: .leading) } - ForEach(workspace.sessions.prefix(4)) { session in + ForEach(workspace.sessions.prefix(sessionLimit)) { session in HStack(spacing: 0) { Button { onOpenSession(session) } label: { HStack(spacing: 10) { + if ["running", "active", "in_progress"].contains(session.status.lowercased()) { + Circle().fill(BitFunTheme.green).frame(width: 7, height: 7) + } Image(systemName: "doc") .font(.system(size: 18, weight: .regular)) .foregroundStyle(BitFunTheme.muted) @@ -895,7 +965,7 @@ private struct SidebarWorkspaceRow: View { Text(session.title) .font(.system( size: 15, - weight: selectedSessionID == session.id ? .medium : .regular + weight: isSelected(session) ? .medium : .regular )) .foregroundStyle(BitFunTheme.ink) .lineLimit(1) @@ -911,6 +981,8 @@ private struct SidebarWorkspaceRow: View { .contentShape(Rectangle()) } .buttonStyle(.plain) + .accessibilityIdentifier("sidebar.session.\(workspace.deviceKey ?? session.deviceKey ?? "unknown").\(session.id)") + .accessibilityAddTraits(isSelected(session) ? .isSelected : []) Button { onActions(session) } label: { Image(systemName: "ellipsis") .font(.system(size: 13, weight: .medium)).foregroundStyle(BitFunTheme.muted) @@ -927,21 +999,24 @@ private struct SidebarWorkspaceRow: View { .padding(.leading, 32) .padding(.trailing, 4) .frame(minHeight: metadata(session) == nil ? 44 : 56) - .background(selectedSessionID == session.id ? BitFunTheme.soft : Color.clear) + .background(isSelected(session) ? BitFunTheme.soft : Color.clear) .clipShape(RoundedRectangle(cornerRadius: 9)) } - if workspace.sessions.count > 4 { - Text( - MobileLocalization.format( - "还有 %lld 个会话", - language: MobileLocalization.restoredLanguage(), - Int64(workspace.sessions.count - 4) + if workspace.sessions.count > sessionLimit { + Button(action: { onShowMore?() }) { + Text( + MobileLocalization.format( + "还有 %lld 个会话", + language: MobileLocalization.restoredLanguage(), + Int64(workspace.sessions.count - sessionLimit) + ) ) - ) .font(.system(size: 13)) .foregroundStyle(BitFunTheme.muted) .padding(.leading, 42) .frame(height: 36, alignment: .leading) + } + .buttonStyle(.plain) } } } diff --git a/src/apps/mobile/ios/BitFun/Infrastructure/AccountFailureCopy.swift b/src/apps/mobile/ios/BitFun/Infrastructure/AccountFailureCopy.swift new file mode 100644 index 0000000000..46c0b06da2 --- /dev/null +++ b/src/apps/mobile/ios/BitFun/Infrastructure/AccountFailureCopy.swift @@ -0,0 +1,29 @@ +import Foundation + +enum AccountFailureCopy { + static func localizationKey(reason: String, stage: String?) -> String { + if stage == "DEVICE_LIST" { + return "登录已完成,但设备列表加载失败。请重试。" + } + switch reason { + case "INVALID_CREDENTIALS": + return "账号或密码错误" + case "AUTHENTICATION": + return "登录状态无效,请重新输入账号和密码" + case "RATE_LIMITED": + return "登录请求过多,请稍后重试" + case "RELAY_UNAVAILABLE": + return "Relay 服务暂时不可用,请稍后重试" + case "NETWORK": + return "网络连接失败,请检查网络和 relay 地址后重试" + case "TIMEOUT": + return "登录超时,请稍后重试" + case "MALFORMED_RESPONSE": + return "Relay 响应异常,请稍后重试或升级应用" + case "SECURE_STORAGE": + return "无法访问系统安全存储,请稍后重试。" + default: + return "登录服务暂时不可用,请稍后重试" + } + } +} diff --git a/src/apps/mobile/ios/BitFun/Infrastructure/MobileAppModel+Account.swift b/src/apps/mobile/ios/BitFun/Infrastructure/MobileAppModel+Account.swift new file mode 100644 index 0000000000..92ba76f712 --- /dev/null +++ b/src/apps/mobile/ios/BitFun/Infrastructure/MobileAppModel+Account.swift @@ -0,0 +1,301 @@ +import Foundation +import BitFunMobileCore + +extension MobileAppModel { + private func invalidateTerminalAccountAuthority() { + invalidateTargetScopedFileTransfers() + if let targetKey = coreAdapter?.currentRemoteTargetKey, + targetKey.hasPrefix("account:") { + let epoch = coreAdapter?.currentRemoteTargetEpoch ?? remoteTargetEpoch + _ = coreAdapter?.invalidateRemoteAuthority(ifTargetKey: targetKey, epoch: epoch) + } + clearInvalidatedRemoteAuthorityProjection( + adapterEpoch: coreAdapter?.currentRemoteTargetEpoch ?? remoteTargetEpoch + ) + remoteConnected = false + connectionPhase = .disconnected + } + + private func invalidateRemoteTarget(for operation: (accountGeneration: UInt64, remoteTargetEpoch: UInt64, preservePairing: Bool)) { + committedRemoteCreate = nil + remoteLastAppliedAuthority = nil + accountGeneration = operation.accountGeneration + pendingAccountOperationPreservesPairing = ( + generation: operation.accountGeneration, + preserve: operation.preservePairing + ) + remoteTargetEpoch = operation.remoteTargetEpoch + if operation.preservePairing && directPairingConnected { + remoteExpectedDeviceKey = "pairing" + remoteConnected = true + pendingDirectorySession = nil + pendingDirectoryWorkspace = nil + pendingDirectoryRemoteDraft = nil + return + } + remoteExpectedDeviceKey = nil + remoteConnected = false + pendingDirectorySession = nil + pendingDirectoryWorkspace = nil + pendingDirectoryRemoteDraft = nil + remoteCreateSubmitting = false + remoteCreateRequestID = nil + remoteCreateRequestEpoch = remoteTargetEpoch + remoteCreateRequestDeviceKey = nil + pendingRemoteWorkspaceCreate = nil + pendingRemoteAssistantCreate = false + remoteSessionSelected = false + } + + func selectRemoteDevice(_ device: MobileAccountDevice) { + guard device.online else { + showToast(localized("这台桌面设备当前离线")) + return + } + surface = .remote + drawerOpen = false + let targetKey = "account:\(device.id)" + guard remoteExpectedDeviceKey != targetKey else { return } + invalidateTargetScopedFileTransfers() + directPairingConnected = false + directPairingDeviceName = nil + directPairingDirectoryEntry = nil + pairingIntentInFlight = false + remoteTargetEpoch &+= 1 + remoteExpectedDeviceKey = "account:\(device.id)" + remoteInitialSessionReady = false + remoteInitialWorkspaceReady = false + pendingDirectorySession = pendingDirectorySession.map { ($0.deviceKey, $0.sessionID, remoteTargetEpoch) } + remoteCreateSubmitting = false + remoteCreateRequestID = nil + remoteCreateError = nil + committedRemoteCreate = nil + remoteLastAppliedAuthority = nil + accountBusy = true + remoteSessionSelected = false + remoteConnected = directPairingConnected + remoteSessions = [] + remoteWorkspaces = [] + workspaceCatalog = [] + pendingRemoteWorkspaceCreate = nil + pendingRemoteAssistantCreate = false + selectedRemoteWorkspaceKind = "" + messages = [] + timelineRows = [] + coreAdapter?.selectAccountDevice(id: device.id) + } + + func refreshRemoteDevices() { + guard accountUser != nil else { return } + coreAdapter?.refreshAccountDevices() + } + + func logoutAccount() { + var preservePairing = directPairingConnected && remoteExpectedDeviceKey == "pairing" + if coreAdapter?.currentRemoteTargetKey != "pairing" { + invalidateTargetScopedFileTransfers() + } + if let operation = coreAdapter?.beginAccountOperation() { + preservePairing = operation.preservePairing + invalidateRemoteTarget(for: operation) + } else { + accountGeneration &+= 1 + remoteTargetEpoch &+= 1 + committedRemoteCreate = nil + remoteLastAppliedAuthority = nil + remoteExpectedDeviceKey = nil + remoteConnected = false + pendingDirectorySession = nil + pendingDirectoryWorkspace = nil + pendingDirectoryRemoteDraft = nil + remoteCreateSubmitting = false + remoteCreateRequestID = nil + remoteCreateRequestEpoch = remoteTargetEpoch + remoteCreateRequestDeviceKey = nil + pendingRemoteWorkspaceCreate = nil + pendingRemoteAssistantCreate = false + remoteSessionSelected = false + } + if !preservePairing { + remoteExpectedDeviceKey = nil + remoteConnected = false + pendingDirectorySession = nil + pendingDirectoryWorkspace = nil + pendingDirectoryRemoteDraft = nil + } + accountDirectoryGeneration &+= 1 + coreAdapter?.logoutAccount(preservePairing: preservePairing) + accountUser = nil + accountUserID = nil + accountDeviceName = nil + accountDeviceCount = 0 + accountDevices = [] + accountSelectedDeviceID = nil + coreAdapter?.syncDeviceDirectory([]) + if !preservePairing { + remoteConnected = false + } + if !directPairingConnected { + remoteSessionSelected = false + remoteSessions = [] + remoteWorkspaces = [] + workspaceCatalog = [] + pendingRemoteWorkspaceCreate = nil + pendingRemoteAssistantCreate = false + selectedRemoteWorkspaceKind = "" + surface = .local + } + } + + func loginAccount(relayURL: String, username: String, password: String) { + if coreAdapter?.currentRemoteTargetKey != "pairing" { + invalidateTargetScopedFileTransfers() + } + if let operation = coreAdapter?.beginAccountOperation() { + invalidateRemoteTarget(for: operation) + } else { + accountGeneration &+= 1 + remoteTargetEpoch &+= 1 + committedRemoteCreate = nil + remoteLastAppliedAuthority = nil + remoteExpectedDeviceKey = nil + remoteConnected = false + pendingDirectorySession = nil + pendingDirectoryWorkspace = nil + pendingDirectoryRemoteDraft = nil + remoteCreateSubmitting = false + remoteCreateRequestID = nil + remoteCreateRequestEpoch = remoteTargetEpoch + remoteCreateRequestDeviceKey = nil + pendingRemoteWorkspaceCreate = nil + pendingRemoteAssistantCreate = false + remoteSessionSelected = false + } + accountBusy = true + accountFailureStage = nil + accountFailureCanRetry = false + coreErrorMessage = nil + coreAdapter?.loginAccount(relayURL: relayURL, username: username, password: password) + } + + func retryAccountFailure() { + guard accountFailureStage == "DEVICE_LIST", accountFailureCanRetry, !accountBusy else { return } + accountBusy = true + coreAdapter?.retryAccountFailure() + } + + func apply(accountState state: AccountUiState, generation: UInt64) { + guard !accountLoginPreview, !localActionPreview, !remoteCreatePreview, + generation == accountGeneration else { return } + let preserveDirectPairing = + (directPairingConnected && remoteExpectedDeviceKey == "pairing") || + (pendingAccountOperationPreservesPairing?.generation == generation && + pendingAccountOperationPreservesPairing?.preserve == true) + let preservePendingPairing = preserveDirectPairing && + directPairingConnected && + remoteExpectedDeviceKey == "pairing" && + pendingDirectoryRemoteDraft?.targetKey == "pairing" && + pendingDirectoryRemoteDraft?.epoch == remoteTargetEpoch + pendingAccountOperationPreservesPairing = nil + accountGeneration = generation + accountBusy = state is AccountUiStateSigningIn + if let ready = state as? AccountUiStateReady { + let readyTargetKey = ready.selectedDeviceId.map { "account:\($0)" } + if let adapterTargetKey = coreAdapter?.currentRemoteTargetKey, + adapterTargetKey.hasPrefix("account:"), + adapterTargetKey != readyTargetKey { + invalidateTargetScopedFileTransfers() + } + accountBusy = false + accountFailureStage = nil + accountFailureCanRetry = false + coreErrorMessage = nil + accountUser = ready.username + accountUserID = ready.userId + accountDeviceName = ready.selectedDeviceName + accountDeviceCount = ready.devices.count + accountSelectedDeviceID = ready.selectedDeviceId + accountRefreshing = ready.refreshing + remoteCreateDeviceError = ready.refreshFailure != nil + ? localized("设备列表加载失败,请稍后重试。") : nil + accountDevices = ready.devices.map { device in + MobileAccountDevice( + id: device.id, + name: device.name, + online: device.online, + selected: device.id == ready.selectedDeviceId + ) + } + accountDirectoryGeneration = coreAdapter?.syncDeviceDirectory(accountDevices) ?? (accountDirectoryGeneration &+ 1) + if !directPairingConnected, + ready.selectedDeviceId == nil, + let target = ready.devices.first(where: { $0.online }) { + accountBusy = true + coreAdapter?.selectAccountDevice(id: target.id) + return + } + remoteConnected = directPairingConnected || ready.selectedDeviceId != nil + surface = .remote + connectionPhase = .connected + if ready.refreshFailure != nil { + showToast(localized("设备列表刷新失败,仍显示上次结果")) + } + } else if let failed = state as? AccountUiStateFailed { + accountBusy = false + accountFailureStage = failed.stage.name + accountFailureCanRetry = failed.canRetry + coreErrorMessage = accountErrorMessage(failed.reason.name, stage: failed.stage.name) + if pendingDirectoryRemoteDraft != nil, !preservePendingPairing { + pendingDirectoryRemoteDraft = nil + showToast(localized("远程会话连接已失效,请重新选择设备后重试")) + } + if remoteCreateOpen { + remoteCreateDeviceError = coreErrorMessage + } + if !preserveDirectPairing { connectionPhase = .disconnected } + if failed.reason.name == "AUTHENTICATION" { + accountUser = nil + accountUserID = nil + accountDevices = [] + accountSelectedDeviceID = nil + accountDeviceName = nil + accountDeviceCount = 0 + accountRefreshing = false + pendingDirectorySession = nil + pendingDirectoryWorkspace = nil + if !preservePendingPairing { + pendingDirectoryRemoteDraft = nil + } + accountDirectoryGeneration = coreAdapter?.syncDeviceDirectory([]) ?? (accountDirectoryGeneration &+ 1) + if !preserveDirectPairing { + invalidateTerminalAccountAuthority() + } + } + } else if state is AccountUiStateSignedOut { + accountBusy = false + accountFailureStage = nil + accountFailureCanRetry = false + coreErrorMessage = nil + accountUser = nil + accountUserID = nil + accountDevices = [] + accountSelectedDeviceID = nil + accountDeviceName = nil + accountDeviceCount = 0 + accountRefreshing = false + pendingDirectorySession = nil + pendingDirectoryWorkspace = nil + if !preservePendingPairing { + pendingDirectoryRemoteDraft = nil + } + accountDirectoryGeneration = coreAdapter?.syncDeviceDirectory([]) ?? (accountDirectoryGeneration &+ 1) + if !preserveDirectPairing { + invalidateTerminalAccountAuthority() + } + } + } + + func accountErrorMessage(_ reason: String, stage: String? = nil) -> String { + localized(AccountFailureCopy.localizationKey(reason: reason, stage: stage)) + } +} diff --git a/src/apps/mobile/ios/BitFun/Infrastructure/MobileAppModel+FilePreview.swift b/src/apps/mobile/ios/BitFun/Infrastructure/MobileAppModel+FilePreview.swift new file mode 100644 index 0000000000..f144d98b7c --- /dev/null +++ b/src/apps/mobile/ios/BitFun/Infrastructure/MobileAppModel+FilePreview.swift @@ -0,0 +1,346 @@ +import Foundation +import BitFunMobileCore + +private struct MobilePreviewRequestExpectation { + let requestID: String + let deviceKey: String? + let adapterEpoch: UInt64 + let sessionID: String + let path: String + var controlTargetEpoch: Int32? +} + +private var filePreviewRequestByModel: [ObjectIdentifier: MobilePreviewRequestExpectation] = [:] +private var downloadRetryableByModel: [ObjectIdentifier: Bool] = [:] +private var downloadRetrySessionByModel: [ObjectIdentifier: String] = [:] +private var downloadRetryEpochByModel: [ObjectIdentifier: Int32] = [:] +private var downloadRetryDeviceByModel: [ObjectIdentifier: String] = [:] +private var downloadRetryCallbackEpochByModel: [ObjectIdentifier: UInt64] = [:] +private var downloadRetryPathByModel: [ObjectIdentifier: String] = [:] + +private extension MobileFilePreviewFailureKind { + init(kind: FilePreviewFailureKind) { + switch kind.name { + case "NOT_FOUND": self = .notFound + case "UNAVAILABLE": self = .unavailable + case "ACCESS_DENIED": self = .accessDenied + case "TOO_LARGE": self = .tooLarge + case "CONNECTION": self = .connection + default: self = .loadFailed + } + } +} + +private func localizedFailureReason(_ localized: (String) -> String, kindName: String, operation: String) -> String { + switch kindName { + case "ACCESS_DENIED": return localized("没有权限访问此文件") + case "NOT_FOUND": return localized("找不到此文件") + case "TOO_LARGE": return localized(operation == "download" ? "文件过大,无法下载" : "文件过大,无法预览") + case "CONNECTION", "UNAVAILABLE", "OFFLINE": return localized("远程设备当前离线") + case "UNSUPPORTED": return localized("此文件类型暂不支持") + default: return localized(operation == "download" ? "下载失败" : "无法预览") + } +} + +extension MobileAppModel { + func invalidateTargetScopedFileTransfers() { + let modelID = ObjectIdentifier(self) + filePreviewRequestByModel[modelID] = nil + filePreview = nil + filePreviewLoading = false + + pendingDownload = nil + downloadExporterOpen = false + downloadTargetPath = nil + downloadStatusText = nil + downloadPhase = .idle + downloadRetryableByModel[modelID] = nil + downloadRetrySessionByModel[modelID] = nil + downloadRetryEpochByModel[modelID] = nil + downloadRetryDeviceByModel[modelID] = nil + downloadRetryCallbackEpochByModel[modelID] = nil + downloadRetryPathByModel[modelID] = nil + } + + func openRemoteFile(reference: String, label: String) { + guard surface == .remote, remoteSessionSelected else { + showToast(localized("仅远程工作区文件支持预览")) + return + } + filePreviewLoading = true + let key = ObjectIdentifier(self) + let requestID = UUID().uuidString + let path = normalizedRemotePath(reference) + let deviceKey = coreAdapter?.currentRemoteTargetKey + let adapterEpoch = coreAdapter?.currentRemoteTargetEpoch ?? 0 + _ = coreAdapter?.openRemoteFile( + reference: reference, + label: label, + sessionID: selectedSessionID, + requestID: requestID + ) + filePreviewRequestByModel[key] = MobilePreviewRequestExpectation( + requestID: requestID, + deviceKey: deviceKey, + adapterEpoch: adapterEpoch, + sessionID: selectedSessionID, + path: path, + controlTargetEpoch: nil + ) + } + + func downloadRemoteFile(reference: String, label: String) { + guard surface == .remote, remoteSessionSelected else { return } + downloadExporterOpen = false + pendingDownload = nil + let modelID = ObjectIdentifier(self) + downloadRetryableByModel[modelID] = false + downloadRetryDeviceByModel[modelID] = coreAdapter?.currentRemoteTargetKey + downloadRetryCallbackEpochByModel[modelID] = coreAdapter?.currentRemoteTargetEpoch ?? 0 + let path = normalizedRemotePath(reference) + downloadRetryPathByModel[modelID] = path + downloadTargetPath = path + downloadPhase = .preparing + downloadStatusText = localized("正在准备下载") + coreAdapter?.downloadRemoteFile( + reference: reference, + label: label, + sessionID: selectedSessionID + ) + } + + func finishDownloadExport(success: Bool) { + guard let download = pendingDownload else { return } + if success { + coreAdapter?.remoteDownloadSaved(reference: download.reference) + downloadPhase = .saved + downloadStatusText = localized("已下载") + showToast(localizedFormat("已保存 %@", download.name)) + } else { + coreAdapter?.remoteDownloadSaveFailed(reference: download.reference) + downloadPhase = .failed + downloadStatusText = localized("保存失败") + showToast(localized("文件保存失败")) + } + pendingDownload = nil + downloadExporterOpen = false + } + + func downloadStatus(for remotePath: String) -> String? { + guard downloadTargetPath == remotePath else { return nil } + return downloadStatusText + } + + func dismissFilePreview() { + let key = ObjectIdentifier(self) + filePreviewRequestByModel[key] = nil + filePreview = nil + filePreviewLoading = false + coreAdapter?.dismissRemoteFilePreview() + } + + func apply(downloadState state: RemoteFileDownloadUiState) { + if state is RemoteFileDownloadUiStateNone { return } + let modelID = ObjectIdentifier(self) + guard RemoteAuthorityGate.fileTransferCallbackMatchesAuthority( + requestTargetKey: downloadRetryDeviceByModel[modelID], + requestEpoch: downloadRetryCallbackEpochByModel[modelID], + adapterTargetKey: coreAdapter?.currentRemoteTargetKey, + adapterEpoch: coreAdapter?.currentRemoteTargetEpoch ?? 0 + ) else { return } + if let loading = state as? RemoteFileDownloadUiStateLoading { + downloadTargetPath = loading.target.remotePath + downloadPhase = .downloading + if loading.totalBytes > 0 { + downloadStatusText = localizedFormat( + "正在下载 %@ / %@", + FilePreviewFormat.shared.bytes(value: loading.downloadedBytes), + FilePreviewFormat.shared.bytes(value: loading.totalBytes) + ) + } else { + downloadStatusText = localized("正在下载") + } + } else if let awaiting = state as? RemoteFileDownloadUiStateAwaitingSave { + let reference = awaiting.target.path + downloadTargetPath = awaiting.target.remotePath + downloadPhase = .saving + downloadStatusText = localized("正在保存") + if pendingDownload?.reference != reference { + pendingDownload = MobilePendingDownload( + reference: reference, + remotePath: awaiting.target.remotePath, + name: awaiting.name, + mimeType: awaiting.mimeType, + data: Self.data(from: awaiting.bytes), + sessionID: awaiting.target.sessionId, + controlTargetEpoch: awaiting.target.controlTargetEpoch + ) + downloadExporterOpen = true + } + } else if let saved = state as? RemoteFileDownloadUiStateSaved { + downloadTargetPath = saved.target.remotePath + downloadPhase = .saved + downloadStatusText = localized("已下载") + } else if let failed = state as? RemoteFileDownloadUiStateFailed { + downloadTargetPath = failed.target.remotePath + downloadPhase = .failed + let modelID = ObjectIdentifier(self) + downloadRetryableByModel[modelID] = failed.retryable + downloadRetrySessionByModel[modelID] = failed.target.sessionId + downloadRetryEpochByModel[modelID] = failed.target.controlTargetEpoch + downloadRetryDeviceByModel[modelID] = coreAdapter?.currentRemoteTargetKey + downloadRetryCallbackEpochByModel[modelID] = coreAdapter?.currentRemoteTargetEpoch ?? 0 + downloadRetryPathByModel[modelID] = failed.target.remotePath + downloadStatusText = localizedFailureReason(localized, kindName: failed.kind.name, operation: "download") + pendingDownload = nil + downloadExporterOpen = false + } + } + + var canRetryRemoteDownload: Bool { + downloadRetryableByModel[ObjectIdentifier(self)] == true + } + + func retryRemoteDownload() { + let modelID = ObjectIdentifier(self) + guard downloadRetryableByModel[modelID] == true, + let path = downloadRetryPathByModel[modelID], + downloadTargetPath == path, + downloadRetrySessionByModel[modelID] == selectedSessionID, + downloadRetryDeviceByModel[modelID] == coreAdapter?.currentRemoteTargetKey, + downloadRetryCallbackEpochByModel[modelID] == coreAdapter?.currentRemoteTargetEpoch else { + downloadPhase = .failed + downloadStatusText = localized("下载目标已变化,请重新打开文件") + return + } + if let preview = filePreview, preview.id == path, + preview.sessionID == downloadRetrySessionByModel[modelID], + preview.controlTargetEpoch != downloadRetryEpochByModel[modelID] { + downloadPhase = .failed + downloadStatusText = localized("下载目标已变化,请重新打开文件") + return + } + downloadRemoteFile(reference: "computer://\(path)", label: path.split(separator: "/").last.map(String.init) ?? path) + } + + func apply(filePreviewState state: RemoteFilePreviewUiState) { + let key = ObjectIdentifier(self) + if !(state is RemoteFilePreviewUiStateNone) { + guard var expected = filePreviewRequestByModel[key], + RemoteAuthorityGate.fileTransferCallbackMatchesAuthority( + requestTargetKey: expected.deviceKey, + requestEpoch: expected.adapterEpoch, + adapterTargetKey: coreAdapter?.currentRemoteTargetKey, + adapterEpoch: coreAdapter?.currentRemoteTargetEpoch ?? 0 + ), + let identity = stateRequestIdentity(state), + let target = stateTarget(state), + identity.requestId == expected.requestID, + identity.deviceKey == expected.deviceKey, + identity.sessionId == expected.sessionID, + normalizedRemotePath(identity.path) == expected.path, + target.sessionId == expected.sessionID, + normalizedRemotePath(target.remotePath) == expected.path else { return } + if let epoch = expected.controlTargetEpoch { + guard target.controlTargetEpoch == epoch else { return } + } else { + expected.controlTargetEpoch = target.controlTargetEpoch + filePreviewRequestByModel[key] = expected + } + } + if let loading = state as? RemoteFilePreviewUiStateLoading { + filePreviewLoading = true + filePreview = MobileFilePreview(id: loading.target.remotePath, sessionID: loading.target.sessionId, + controlTargetEpoch: loading.target.controlTargetEpoch, name: loading.target.displayName, + content: "", mimeType: "", imageData: nil, truncated: false, loadedBytes: 0, + sizeBytes: 0, markdown: false, lineStart: loading.target.lineStart, failure: nil, + failureKind: nil, retryable: false, unsupported: false) + return + } + filePreviewLoading = false + if state is RemoteFilePreviewUiStateNone { + filePreview = nil + } else if let text = state as? RemoteFilePreviewUiStateText { + filePreview = MobileFilePreview(id: text.target.remotePath, sessionID: text.target.sessionId, + controlTargetEpoch: text.target.controlTargetEpoch, name: text.name, + content: text.content, mimeType: text.mimeType, imageData: nil, truncated: text.truncated, + loadedBytes: text.loadedBytes, sizeBytes: text.sizeBytes, markdown: text.markdown, + lineStart: text.target.lineStart, failure: nil, failureKind: nil, retryable: false, unsupported: false) + } else if let image = state as? RemoteFilePreviewUiStateImage { + filePreview = MobileFilePreview(id: image.target.remotePath, sessionID: image.target.sessionId, + controlTargetEpoch: image.target.controlTargetEpoch, name: image.name, + content: "", mimeType: image.mimeType, imageData: Self.data(from: image.bytes), truncated: false, + loadedBytes: image.sizeBytes, sizeBytes: image.sizeBytes, markdown: false, + lineStart: image.target.lineStart, failure: nil, failureKind: nil, retryable: false, unsupported: false) + } else if let unsupported = state as? RemoteFilePreviewUiStateUnsupported { + filePreview = MobileFilePreview(id: unsupported.target.remotePath, sessionID: unsupported.target.sessionId, + controlTargetEpoch: unsupported.target.controlTargetEpoch, name: unsupported.target.displayName, + content: "", mimeType: unsupported.mimeType, imageData: nil, truncated: false, + loadedBytes: 0, sizeBytes: unsupported.sizeBytes, markdown: false, + lineStart: unsupported.target.lineStart, failure: localized("此文件类型暂不支持预览"), + failureKind: nil, retryable: false, unsupported: true) + } else if let failed = state as? RemoteFilePreviewUiStateFailed { + let kind = MobileFilePreviewFailureKind(kind: failed.kind) + filePreview = MobileFilePreview(id: failed.target.remotePath, sessionID: failed.target.sessionId, + controlTargetEpoch: failed.target.controlTargetEpoch, name: failed.target.displayName, + content: "", mimeType: failed.mimeType, imageData: nil, truncated: false, + loadedBytes: 0, sizeBytes: failed.sizeBytes, markdown: false, lineStart: failed.target.lineStart, + failure: localizedFailureReason(localized, kindName: failed.kind.name, operation: "preview"), failureKind: kind, + retryable: failed.retryable, unsupported: false) + } + } + + /// Mirrors FileTargetResolver: remove the optional source range before the URI scheme. + /// This is intentionally POSIX-only; the result is never joined with local paths. + private func normalizedRemotePath(_ reference: String) -> String { + var raw = reference.trimmingCharacters(in: .whitespacesAndNewlines) + while let last = raw.last, ".,;:)".contains(last) { raw.removeLast() } + if let hash = raw.lastIndex(of: "#"), hash > raw.startIndex { + let marker = String(raw[raw.index(after: hash)...]) + if isLineMarker(marker) { raw = String(raw[.. raw.startIndex { + let marker = String(raw[raw.index(after: colon)...]) + if isLineRange(marker) { raw = String(raw[.. Bool { + guard marker.hasPrefix("L") else { return false } + let parts = marker.dropFirst().split(separator: "-", omittingEmptySubsequences: false) + return (parts.count == 1 || parts.count == 2) + && parts.allSatisfy { $0.hasPrefix("L") ? $0.dropFirst().allSatisfy(\.isNumber) : $0.allSatisfy(\.isNumber) } + && parts.allSatisfy { !$0.isEmpty && ($0 == "L" ? false : true) } + } + + private func isLineRange(_ marker: String) -> Bool { + let parts = marker.split(separator: "-", omittingEmptySubsequences: false) + return (parts.count == 1 || parts.count == 2) && parts.allSatisfy { $0.allSatisfy(\.isNumber) && !$0.isEmpty } + } + + private func stateTarget(_ state: RemoteFilePreviewUiState) -> FilePreviewTarget? { + if let value = state as? RemoteFilePreviewUiStateLoading { return value.target } + if let value = state as? RemoteFilePreviewUiStateText { return value.target } + if let value = state as? RemoteFilePreviewUiStateImage { return value.target } + if let value = state as? RemoteFilePreviewUiStateUnsupported { return value.target } + if let value = state as? RemoteFilePreviewUiStateFailed { return value.target } + return nil + } + + private func stateRequestIdentity(_ state: RemoteFilePreviewUiState) -> PreviewRequestIdentity? { + if let value = state as? RemoteFilePreviewUiStateLoading { return value.identity } + if let value = state as? RemoteFilePreviewUiStateText { return value.identity } + if let value = state as? RemoteFilePreviewUiStateImage { return value.identity } + if let value = state as? RemoteFilePreviewUiStateUnsupported { return value.identity } + if let value = state as? RemoteFilePreviewUiStateFailed { return value.identity } + return nil + } + + static func data(from bytes: KotlinByteArray) -> Data { + Data((0.. MobileConversationRow { + simpleTimelineRow(message, images: []) + } + + static func simpleTimelineRow( + _ message: ChatMessage, + images: [ComposerAttachment] + ) -> MobileConversationRow { + MobileConversationRow( + id: message.id.uuidString, + kind: message.role == .user ? "USER" : "ASSISTANT", + text: message.text, + thinking: nil, + images: images.map { + MobileTimelineImage(name: "image", dataURL: $0.dataURL) + }, + tools: [], + blocks: [], + streaming: false, + typing: false, + pending: false, + showRetry: false + ) + } + + static func mapConversationRow(_ row: ConversationRow) -> MobileConversationRow { + MobileConversationRow( + id: row.id, + kind: row.kind.name, + text: row.text, + thinking: row.thinking, + images: row.images.map { + MobileTimelineImage(name: $0.name, dataURL: $0.dataUrl) + }, + tools: row.tools.map(mapTool), + blocks: row.blocks.map(mapBlock), + streaming: row.streaming, + typing: row.typing, + pending: row.pending, + showRetry: row.showRetry + ) + } + + static func mapTool(_ tool: ToolCard) -> MobileTimelineTool { + MobileTimelineTool( + id: tool.id, + name: tool.name, + phase: tool.phase.name, + kind: tool.kind.name, + operation: tool.operation.name, + target: tool.target, + filePath: tool.filePath, + fileLabel: tool.fileLabel, + input: tool.input, + output: tool.output, + question: tool.question, + questions: tool.questions.map { question in + MobileTimelineQuestion( + index: Int(question.index), + header: question.header, + question: question.question, + options: question.options.map { + MobileTimelineOption(label: $0.label, description: $0.description_) + }, + multiSelect: question.multiSelect + ) + }, + actions: Set(tool.actions.map(\.name)) + ) + } + + static func mapBlock(_ block: MessageBlock) -> MobileTimelineBlock { + if let text = block as? MessageBlockText { + return .text(id: text.id, text: text.text, streaming: text.streaming) + } + if let thinking = block as? MessageBlockThinking { + return .thinking(id: thinking.id, text: thinking.text, streaming: thinking.streaming) + } + if let tools = block as? MessageBlockTools { + return .tools(id: tools.id, tools: tools.tools.map(mapTool)) + } + if let subagent = block as? MessageBlockSubagent { + return .subagent( + id: subagent.id, + title: subagent.title, + running: subagent.running, + text: subagent.text, + children: subagent.children.map(mapBlock) + ) + } + return .text(id: block.id, text: "", streaming: false) + } +} diff --git a/src/apps/mobile/ios/BitFun/Infrastructure/MobileAppModel+RemoteSession.swift b/src/apps/mobile/ios/BitFun/Infrastructure/MobileAppModel+RemoteSession.swift new file mode 100644 index 0000000000..3f31fbd3cf --- /dev/null +++ b/src/apps/mobile/ios/BitFun/Infrastructure/MobileAppModel+RemoteSession.swift @@ -0,0 +1,1061 @@ +import Foundation +import BitFunMobileCore + +extension MobileAppModel { + func apply(remoteTargetBound targetKey: String, epoch: UInt64, accountGeneration generation: UInt64) { + guard generation == accountGeneration, + !accountLoginPreview, !localActionPreview, !remoteCreatePreview else { return } + let projection = RemoteTargetProjectionState( + hasSessionRows: !remoteSessions.isEmpty, + hasWorkspaceRows: !remoteWorkspaces.isEmpty || !workspaceCatalog.isEmpty, + hasSelection: remoteSessionSelected, + hasTimeline: (surface == .remote || remoteSessionSelected) && + (!timelineRows.isEmpty || !messages.isEmpty), + hasActiveTurn: (surface == .remote || remoteSessionSelected) && + (activeTurnID != nil || isSending || busy), + hasPendingNavigation: pendingDirectorySession != nil || pendingDirectoryWorkspace != nil || + pendingDirectoryRemoteDraft != nil, + hasReadyAuthority: remoteInitialSessionReady || remoteInitialWorkspaceReady || + remoteLastAppliedAuthority != nil, + hasCreateState: remoteCreateOpen || remoteCreateSubmitting || remoteCreateRequestID != nil || + committedRemoteCreate != nil + ) + let transition = RemoteAuthorityGate.targetBoundTransition( + currentTargetKey: remoteBoundTargetKey, + currentEpoch: remoteBoundTargetEpoch, + boundTargetKey: targetKey, + boundEpoch: epoch, + projection: projection + ) + remoteBoundTargetKey = targetKey + remoteBoundTargetEpoch = epoch + remoteExpectedDeviceKey = targetKey + remoteTargetEpoch = epoch + guard transition.scopeChanged else { return } + + pairingRetainedAccountAuthority = nil + clearTargetScopedRemoteProjection(boundTargetKey: targetKey, epoch: epoch) + } + + func clearInvalidatedRemoteAuthorityProjection(adapterEpoch: UInt64) { + clearTargetScopedRemoteProjection(boundTargetKey: "", epoch: adapterEpoch) + remoteExpectedDeviceKey = nil + remoteBoundTargetKey = nil + remoteBoundTargetEpoch = nil + remoteTargetEpoch = adapterEpoch + pairingRetainedAccountAuthority = nil + } + + private func clearTargetScopedRemoteProjection(boundTargetKey targetKey: String, epoch: UInt64) { + invalidateTargetScopedFileTransfers() + remoteInitialSessionReady = false + remoteInitialWorkspaceReady = false + remoteLastAppliedAuthority = nil + remoteSessions = [] + remoteWorkspaces = [] + remoteAssistants = [] + workspaceCatalog = [] + selectedRemoteWorkspaceKind = "" + workspaceLoading = false + workspaceLoadFailed = false + let clearingVisibleRemoteConversation = surface == .remote || remoteSessionSelected + remoteSessionSelected = false + sessionDetails = nil + if clearingVisibleRemoteConversation { + selectedSessionID = "" + activeTurnID = nil + isSending = false + busy = false + timelineRows = [] + messages = [] + composerImages = [] + } + + if let pending = pendingDirectorySession, + pending.epoch != epoch || directoryTargetKey(forRawDeviceKey: pending.deviceKey) != targetKey { + pendingDirectorySession = nil + } + if let pending = pendingDirectoryWorkspace, + pending.epoch != epoch || directoryTargetKey(forRawDeviceKey: pending.deviceKey) != targetKey { + pendingDirectoryWorkspace = nil + } + if pendingDirectoryRemoteDraft?.targetKey != targetKey || pendingDirectoryRemoteDraft?.epoch != epoch { + pendingDirectoryRemoteDraft = nil + } + pendingRemoteWorkspaceCreate = nil + pendingRemoteAssistantCreate = false + + committedRemoteCreate = nil + remoteCreateOpen = false + remoteCreateSubmitting = false + remoteCreateRequestID = nil + remoteCreateRequestEpoch = 0 + remoteCreateRequestDeviceKey = nil + remoteCreateError = nil + remoteCreateDeviceError = nil + if directPairingConnected && targetKey == "pairing" { + updateDirectPairingDirectoryEntry() + } + } + + func apply(directoryState state: DeviceDirectoryUiState, generation: UInt64) { + guard !accountLoginPreview, !localActionPreview, !remoteCreatePreview, !directoryFixturePreview, + generation == accountDirectoryGeneration else { return } + deviceDirectory = state.devices.map { entry in + let deviceKey = entry.deviceId + let sessions = entry.sessions.map { session in + ChatSession( + id: session.id, + title: session.title.isEmpty ? localized("未命名会话") : session.title, + updatedLabel: session.updatedAt, + status: session.status, + agentType: session.agentType, + workspacePath: session.workspacePath, + workspaceName: session.workspaceName, + deviceKey: deviceKey, + createdAt: session.createdAt, + messageCount: Int(session.messageCount) + ) + } + let workspaces = entry.workspaces.map { workspace in + MobileWorkspaceGroup( + path: workspace.path, + name: workspace.name.isEmpty ? workspace.path : workspace.name, + selected: remoteExpectedDeviceKey == deviceKey && + normalizedSessionWorkspacePath(workspace.path) == normalizedSessionWorkspacePath(workspaceCatalog.first(where: { $0.selected })?.path ?? ""), + sessions: sessions.filter { normalizedSessionWorkspacePath($0.workspacePath ?? "") == normalizedSessionWorkspacePath(workspace.path) }, + deviceKey: deviceKey + ) + } + return MobileDeviceDirectoryEntry( + id: deviceKey, + name: entry.deviceName, + online: entry.online, + expanded: entry.expanded, + status: entry.status.name, + error: entry.error?.name, + workspaces: workspaces, + sessions: sessions + ) + } + } + + func toggleDeviceDirectory(_ device: MobileDeviceDirectoryEntry) { + if device.id == directPairingSidebarDeviceID { + directPairingDirectoryEntry = MobileDeviceDirectoryEntry( + id: device.id, name: device.name, online: device.online, + expanded: !device.expanded, status: device.status, error: device.error, + workspaces: device.workspaces, sessions: device.sessions + ) + return + } + coreAdapter?.toggleDeviceDirectory(device.id, expanded: !device.expanded) + } + + func retryDeviceDirectory(_ device: MobileDeviceDirectoryEntry) { + if device.id == directPairingSidebarDeviceID { + guard directPairingConnected else { return } + coreAdapter?.loadRemoteWorkspaces() + coreAdapter?.refreshRemoteSessions() + return + } + coreAdapter?.retryDeviceDirectory(device.id) + } + + private func directoryTargetKey(forRawDeviceKey rawDeviceKey: String) -> String { + rawDeviceKey == directPairingSidebarDeviceID ? "pairing" : "account:\(rawDeviceKey)" + } + + private var authoritativeDirectoryRawDeviceKey: String? { + guard let targetKey = remoteExpectedDeviceKey else { return nil } + if targetKey == "pairing" { + return directPairingConnected ? directPairingSidebarDeviceID : nil + } + let prefix = "account:" + guard targetKey.hasPrefix(prefix) else { return nil } + let rawDeviceKey = String(targetKey.dropFirst(prefix.count)) + return rawDeviceKey.isEmpty ? nil : rawDeviceKey + } + + func openDirectoryRemoteDraft( + device: MobileDeviceDirectoryEntry, + workspace: MobileWorkspaceGroup + ) { + guard !remoteCreateSubmitting, remoteCreateRequestID == nil else { + showToast(localized("远程会话当前不可创建,请重试")) + return + } + let targetKey: String + let accountDevice: MobileAccountDevice? + if device.id == directPairingSidebarDeviceID { + guard device.online, directPairingConnected else { + showToast(localized("这台桌面设备当前离线")) + return + } + targetKey = "pairing" + accountDevice = nil + } else { + guard let matched = accountDevices.first(where: { $0.id == device.id }) else { + showToast(localized("远程会话连接已失效,请重新选择设备后重试")) + return + } + guard matched.online, device.online else { + showToast(localized("这台桌面设备当前离线")) + return + } + targetKey = "account:\(matched.id)" + accountDevice = matched + } + + pendingDirectorySession = nil + pendingDirectoryWorkspace = nil + pendingRemoteWorkspaceCreate = nil + pendingRemoteAssistantCreate = false + remoteCreateOpen = false + + let targetIsCurrent = remoteExpectedDeviceKey == targetKey + let epoch = targetIsCurrent ? remoteTargetEpoch : remoteTargetEpoch &+ 1 + pendingDirectoryRemoteDraft = PendingDirectoryRemoteDraft( + targetKey: targetKey, + rawDeviceKey: device.id, + workspacePath: workspace.path, + normalizedWorkspacePath: normalizedSessionWorkspacePath(workspace.path), + epoch: epoch, + selectionRequested: false + ) + + if targetIsCurrent { + if connectionPhase == .disconnected { + pendingDirectoryRemoteDraft = nil + showToast(localized("远程会话连接已失效,请重新选择设备后重试")) + } else if workspaceLoadFailed { + pendingDirectoryRemoteDraft = nil + showToast(localized("工作区加载失败,点按重试")) + } else { + advancePendingDirectoryRemoteDraftIfReady() + } + return + } + guard let accountDevice else { + pendingDirectoryRemoteDraft = nil + showToast(localized("远程会话连接已失效,请重新选择设备后重试")) + return + } + selectRemoteDevice(accountDevice) + } + + func selectDirectoryWorkspace(_ workspace: MobileWorkspaceGroup) { + pendingDirectoryRemoteDraft = nil + guard let deviceKey = workspace.deviceKey else { return } + let targetKey = directoryTargetKey(forRawDeviceKey: deviceKey) + if remoteExpectedDeviceKey == targetKey { + guard remoteConnected else { + showToast(localized("远程会话连接已失效,请重新选择设备后重试")) + return + } + selectRemoteWorkspace(workspace) + return + } + pendingDirectoryWorkspace = (deviceKey, workspace.path, remoteTargetEpoch &+ 1) + guard targetKey != "pairing", + let device = accountDevices.first(where: { $0.id == deviceKey }) else { + pendingDirectoryWorkspace = nil + showToast(localized("远程会话连接已失效,请重新选择设备后重试")) + return + } + guard device.online else { + pendingDirectoryWorkspace = nil + showToast(localized("这台桌面设备当前离线")) + return + } + selectRemoteDevice(device) + } + + func selectDirectorySession(_ session: ChatSession) { + pendingDirectoryRemoteDraft = nil + guard let deviceKey = session.deviceKey else { return } + let targetKey = directoryTargetKey(forRawDeviceKey: deviceKey) + let targetIsCurrent = remoteExpectedDeviceKey == targetKey + pendingDirectorySession = ( + deviceKey, + session.id, + remoteTargetEpoch &+ (targetIsCurrent ? 0 : 1) + ) + if targetIsCurrent { + guard remoteConnected else { + pendingDirectorySession = nil + showToast(localized("远程会话连接已失效,请重新选择设备后重试")) + return + } + openPendingDirectorySessionIfReady() + return + } + guard targetKey != "pairing", + let device = accountDevices.first(where: { $0.id == deviceKey }) else { + pendingDirectorySession = nil + showToast(localized("远程会话连接已失效,请重新选择设备后重试")) + return + } + guard device.online else { + pendingDirectorySession = nil + showToast(localized("这台桌面设备当前离线")) + return + } + selectRemoteDevice(device) + } + + private func openPendingDirectorySessionIfReady() { + guard let pending = pendingDirectorySession, + pending.epoch == remoteTargetEpoch else { return } + guard remoteExpectedDeviceKey == directoryTargetKey(forRawDeviceKey: pending.deviceKey) else { + pendingDirectorySession = nil + showToast(localized("远程会话连接已失效,请重新选择设备后重试")) + return + } + guard remoteConnected, + remoteInitialSessionReady, + remoteInitialWorkspaceReady, + !workspaceLoadFailed else { return } + pendingDirectorySession = nil + surface = .remote + drawerOpen = false + remoteSessionSelected = true + selectedSessionID = pending.sessionID + coreAdapter?.openRemoteSession(sessionID: pending.sessionID) + } + + private func advancePendingDirectoryRemoteDraftIfReady() { + guard var pending = pendingDirectoryRemoteDraft, + pending.targetKey == remoteExpectedDeviceKey, + pending.epoch == remoteTargetEpoch, + remoteConnected, + remoteInitialSessionReady, + remoteInitialWorkspaceReady, + !workspaceLoadFailed else { return } + + guard authoritativeDirectoryRawDeviceKey == pending.rawDeviceKey else { + pendingDirectoryRemoteDraft = nil + showToast(localized("远程会话连接已失效,请重新选择设备后重试")) + return + } + let selectedPath = workspaceCatalog.first(where: { $0.selected })?.path ?? "" + if selectedPath == pending.workspacePath { + pendingDirectoryRemoteDraft = nil + surface = .remote + drawerOpen = false + remoteCreateOpen = true + return + } + guard !pending.selectionRequested else { return } + guard workspaceCatalog.contains(where: { $0.path == pending.workspacePath }) else { + pendingDirectoryRemoteDraft = nil + showToast(localized("暂无可用工作区")) + return + } + guard authoritativeDirectoryRawDeviceKey == pending.rawDeviceKey else { + pendingDirectoryRemoteDraft = nil + showToast(localized("远程会话连接已失效,请重新选择设备后重试")) + return + } + pending.selectionRequested = true + pendingDirectoryRemoteDraft = pending + coreAdapter?.selectRemoteWorkspace(path: pending.workspacePath) + } + + func selectRemoteWorkspace(_ workspace: MobileWorkspaceGroup) { + pendingDirectoryRemoteDraft = nil + guard remoteConnected else { + showToast(localized("请先连接桌面设备")) + return + } + surface = .remote + drawerOpen = false + coreAdapter?.selectRemoteWorkspace(path: workspace.path) + } + + func createRemoteSession(in workspace: MobileWorkspaceGroup, agentType: String) { + pendingDirectoryRemoteDraft = nil + guard remoteConnected, !busy else { return } + drawerOpen = false + surface = .remote + createRemoteSession( + agentType: agentType, + title: "", + instruction: "", + workspacePath: workspace.path + ) + } + + func createRemoteAssistantSession() { + pendingDirectoryRemoteDraft = nil + guard remoteConnected, !busy else { return } + drawerOpen = false + surface = .remote + if selectedRemoteWorkspaceKind.lowercased() == "assistant" { + createRemoteSession(agentType: "Claw", title: "", instruction: "") + return + } + guard let assistant = remoteAssistants.first else { + showToast(localized("暂无可用工作区")) + return + } + pendingRemoteAssistantCreate = true + coreAdapter?.selectRemoteAssistant(path: assistant.path) + } + + func selectRemoteAssistant(_ assistant: MobileAssistantOption) { + guard remoteConnected else { return } + coreAdapter?.selectRemoteAssistant(path: assistant.path) + } + + func createRemoteSession( + agentType: String, + title: String, + instruction: String, + modelID: String? = nil, + workspacePath: String? = nil + ) { + // `busy` also covers a background session-list refresh. It is not an + // active turn and must not silently discard a new-session request. + guard remoteConnected, !remoteCreateSubmitting else { + remoteCreateError = localized("远程会话当前不可创建,请重试") + return + } + let normalizedTitle = title.trimmingCharacters(in: .whitespacesAndNewlines) + let normalizedInstruction = instruction.trimmingCharacters(in: .whitespacesAndNewlines) + let selectedModel = modelID ?? modelOptions.first(where: \.selected)?.id + let requestID = UUID().uuidString + guard let deviceKey = remoteExpectedDeviceKey else { + remoteCreateError = localized("未选择远程设备") + return + } + guard coreAdapter != nil else { + remoteCreateError = localized("远程连接尚未准备好,请重试") + return + } + remoteCreateSubmitting = true + remoteCreateError = nil + remoteCreateRequestID = requestID + remoteCreateRequestEpoch = remoteTargetEpoch + remoteCreateRequestDeviceKey = deviceKey + if workspacePath == nil { + guard let assistant = remoteAssistants.first else { + remoteCreateSubmitting = false + clearRemoteCreateRequestMetadata() + remoteCreateError = localized("设备不支持助手会话") + return + } + coreAdapter?.createRemoteAssistantSession( + requestID: requestID, + assistantPath: assistant.path, + title: normalizedTitle, + instruction: normalizedInstruction, + modelID: selectedModel + ) + } else { + coreAdapter?.createRemoteSession( + requestID: requestID, + agentType: agentType, + title: normalizedTitle, + instruction: normalizedInstruction, + modelID: selectedModel, + workspacePath: workspacePath + ) + } + surface = .remote + } + + private func clearRemoteCreateRequestMetadata() { + remoteCreateRequestID = nil + remoteCreateRequestEpoch = 0 + remoteCreateRequestDeviceKey = nil + } + + func failRemoteCreate(requestID: String, targetKey: String?) { + guard remoteCreateRequestID == requestID, + remoteCreateRequestEpoch == remoteTargetEpoch, + targetKey == nil || remoteCreateRequestDeviceKey == targetKey else { return } + remoteCreateSubmitting = false + clearRemoteCreateRequestMetadata() + remoteCreateError = localized("远程会话连接已失效,请重新选择设备后重试") + } + + func apply(createOperation state: CreateSessionOperationState, targetKey: String) { + guard !remoteCreatePreview else { return } + let operationRequestID: String? + switch state { + case let value as CreateSessionOperationStateInFlight: operationRequestID = value.requestId + case let value as CreateSessionOperationStateSucceeded: operationRequestID = value.requestId + case let value as CreateSessionOperationStateFailed: operationRequestID = value.requestId + case let value as CreateSessionOperationStateCancelled: operationRequestID = value.requestId + default: operationRequestID = nil + } + guard let requestID = remoteCreateRequestID, + operationRequestID == requestID, + remoteCreateRequestEpoch == remoteTargetEpoch, + remoteCreateRequestDeviceKey == targetKey else { + return + } + switch state { + case is CreateSessionOperationStateInFlight: + remoteCreateSubmitting = true + case let succeeded as CreateSessionOperationStateSucceeded: + guard let confirmed = succeeded.confirmedSession, + !succeeded.createdSessionId.isEmpty, + confirmed.id == succeeded.createdSessionId else { + remoteCreateSubmitting = false + remoteCreateRequestID = nil + remoteCreateError = localized("远程会话创建结果无效,请刷新后重试") + coreAdapter?.refreshRemoteSessions() + return + } + let session = ChatSession( + id: confirmed.id, + title: confirmed.title.isEmpty ? localized("未命名会话") : confirmed.title, + updatedLabel: confirmed.updatedAt, + status: confirmed.status, + agentType: confirmed.agentType, + workspacePath: confirmed.workspacePath, + workspaceName: confirmed.workspaceName, + createdAt: confirmed.createdAt, + messageCount: Int(confirmed.messageCount) + ) + let authorityAlreadyApplied = RemoteAuthorityGate.succeededIsAlreadyAuthoritative( + targetKey: targetKey, + epoch: remoteTargetEpoch, + commitRevision: succeeded.commitRevision, + confirmedSessionVisible: remoteSessions.contains { $0.id == session.id }, + lastApplied: remoteLastAppliedAuthority + ) + committedRemoteCreate = authorityAlreadyApplied ? nil : CommittedRemoteCreate( + targetKey: targetKey, + epoch: remoteTargetEpoch, + session: session, + minimumAuthorityRevision: succeeded.commitRevision + ) + remoteCreateSubmitting = false + remoteCreateError = nil + remoteCreateRequestID = nil + remoteCreateOpen = false + selectedSessionID = session.id + remoteSessionSelected = true + surface = .remote + remoteSessions.removeAll { $0.id == session.id } + remoteSessions.insert(session, at: 0) + rebuildRemoteWorkspaceGroups() + case let failed as CreateSessionOperationStateFailed: + remoteCreateSubmitting = false + remoteCreateError = failed.unsupported + ? localized("桌面端不支持创建此类会话") + : localized("创建远程会话失败,请重试") + remoteCreateRequestID = nil + case is CreateSessionOperationStateCancelled: + remoteCreateSubmitting = false + remoteCreateError = localized("创建远程会话已取消") + remoteCreateRequestID = nil + case is CreateSessionOperationStateIdle: + if remoteCreateSubmitting { + remoteCreateSubmitting = false + remoteCreateError = localized("创建远程会话已结束,请重试") + remoteCreateRequestID = nil + } + default: + break + } + } + + func deleteRemoteSession(_ session: ChatSession) { + guard !busy else { return } + coreAdapter?.deleteRemoteSession(sessionID: session.id) + if selectedSessionID == session.id { + remoteSessionSelected = false + timelineRows = [] + messages = [] + } + } + + func searchRemoteSessions(_ query: String) { + remoteQuery = query + guard remoteConnected else { return } + coreAdapter?.searchRemoteSessions(query: query) + } + + func loadMoreRemoteSessions() { + guard remoteConnected, remoteHasMore, !busy else { return } + coreAdapter?.loadMoreRemoteSessions() + } + + func loadOlderRemoteMessages() { + guard surface == .remote, remoteConnected, remoteHasMoreMessages, !busy else { return } + coreAdapter?.loadOlderRemoteMessages() + } + + func refreshRemoteSessions() { + guard remoteConnected, !busy else { return } + coreAdapter?.refreshRemoteSessions() + } + + func setRemoteAgentFilter(_ name: String) { + let filter: SessionAgentFilter + switch name { + case "CODE": filter = .code + case "COWORK": filter = .cowork + default: filter = .all + } + remoteAgentFilter = name + coreAdapter?.setRemoteAgentFilter(filter) + } + + func refreshRemotePermissionMode() { + guard remoteConnected else { return } + coreAdapter?.refreshRemotePermissionMode() + } + + func setRemotePermissionMode(_ name: String) { + let mode: SessionPermissionMode + switch name { + case "AUTO": mode = .auto + case "FULL_ACCESS": mode = .fullAccess + default: mode = .ask + } + coreAdapter?.setRemotePermissionMode(mode) + } + + func retryRemoteWorkspaces() { + coreAdapter?.loadRemoteWorkspaces() + } + + func sendRemote() { + let value = draft.trimmingCharacters(in: .whitespacesAndNewlines) + guard !value.isEmpty || !composerImages.isEmpty, + !isSending, + connectionPhase != .disconnected, + let sessionID = visibleSessions.first(where: { $0.id == selectedSessionID })?.id else { return } + let images = composerImages + draft = "" + composerImages = [] + isSending = true + busy = true + coreAdapter?.sendRemote(sessionID: sessionID, content: value, images: images) + } + + func approveTool(_ toolID: String) { + guard surface == .remote, remoteSessionSelected, !toolID.isEmpty else { return } + coreAdapter?.approveRemoteTool(sessionID: selectedSessionID, toolID: toolID) + } + + func rejectTool(_ toolID: String) { + guard surface == .remote, remoteSessionSelected, !toolID.isEmpty else { return } + coreAdapter?.rejectRemoteTool( + sessionID: selectedSessionID, + toolID: toolID, + reason: "Rejected from the iOS client" + ) + } + + func cancelTool(_ toolID: String) { + guard surface == .remote, remoteSessionSelected, !toolID.isEmpty else { return } + coreAdapter?.cancelRemoteTool( + sessionID: selectedSessionID, + toolID: toolID, + reason: "Cancelled from the iOS client" + ) + } + + func answerTool(_ toolID: String, answer: String) { + let normalized = answer.trimmingCharacters(in: .whitespacesAndNewlines) + guard surface == .remote, + remoteSessionSelected, + !toolID.isEmpty, + !normalized.isEmpty else { return } + coreAdapter?.answerRemoteTool( + sessionID: selectedSessionID, + toolID: toolID, + answer: normalized + ) + } + + func answerTool(_ toolID: String, answers: [QuestionAnswer]) { + guard surface == .remote, + remoteSessionSelected, + !toolID.isEmpty, + !answers.isEmpty else { return } + coreAdapter?.answerRemoteToolStructured( + sessionID: selectedSessionID, + toolID: toolID, + answers: answers + ) + } + + func apply(remoteState state: RemoteSessionUiState, targetKey: String, epoch: UInt64) { + guard !localActionPreview, !accountLoginPreview, !remoteCreatePreview else { return } + guard RemoteAuthorityGate.callbackMatchesAuthority( + targetKey: targetKey, + epoch: epoch, + expectedTargetKey: remoteExpectedDeviceKey, + expectedEpoch: remoteTargetEpoch + ) else { return } + guard let ready = state as? RemoteSessionUiStateReady else { + remoteInitialSessionReady = false + if let failed = state as? RemoteSessionUiStateFailed { + let detail = failed.remoteMessage ?? failed.reason.name + remoteConnected = false + connectionPhase = .disconnected + let clearingVisibleRemoteConversation = surface == .remote || remoteSessionSelected + remoteSessionSelected = false + if clearingVisibleRemoteConversation { + selectedSessionID = "" + activeTurnID = nil + isSending = false + busy = false + timelineRows = [] + messages = [] + } + pendingDirectorySession = nil + pendingDirectoryWorkspace = nil + if pendingDirectoryRemoteDraft?.targetKey == targetKey, + pendingDirectoryRemoteDraft?.epoch == epoch { + pendingDirectoryRemoteDraft = nil + showToast(localized("远程会话连接已失效,请重新选择设备后重试")) + } + coreErrorMessage = detail + remoteCreateOpen = false + remoteCreateSubmitting = false + clearRemoteCreateRequestMetadata() + remoteCreateError = detail + if directPairingConnected && targetKey == "pairing" { + updateDirectPairingDirectoryEntry() + } + } + return + } + guard RemoteAuthorityGate.acceptsReady( + targetKey: targetKey, + epoch: epoch, + revision: ready.revision, + lastApplied: remoteLastAppliedAuthority + ) else { return } + remoteLastAppliedAuthority = RemoteAuthorityGate.updatedScope( + targetKey: targetKey, + epoch: epoch, + revision: ready.revision, + lastApplied: remoteLastAppliedAuthority + ) + remoteInitialSessionReady = true + remoteConnected = true + surface = .remote + connectionPhase = .connected + let committed = committedRemoteCreate + let projectionDecision = RemoteAuthorityGate.committedProjectionDecision( + readyTargetKey: targetKey, + readyEpoch: epoch, + readyRevision: ready.revision, + committedTargetKey: committed?.targetKey, + committedEpoch: committed?.epoch, + minimumAuthorityRevision: committed?.minimumAuthorityRevision, + confirmedSessionVisible: committed.map { marker in + ready.sessions.contains { $0.id == marker.session.id } + } ?? false + ) + if !projectionDecision.retainMarker { + committedRemoteCreate = nil + } + remoteSessions = ready.sessions.map { session in + ChatSession( + id: session.id, + title: session.title.isEmpty ? localized("未命名会话") : session.title, + updatedLabel: session.updatedAt, + status: session.status, + agentType: session.agentType, + workspacePath: session.workspacePath, + workspaceName: session.workspaceName, + createdAt: session.createdAt, + messageCount: Int(session.messageCount), + ) + } + if let committed, projectionDecision.protectCommittedRowAndSelection { + remoteSessions.removeAll { $0.id == committed.session.id } + remoteSessions.insert(committed.session, at: 0) + } + rebuildRemoteWorkspaceGroups() + if directPairingConnected { + updateDirectPairingDirectoryEntry() + } + if let protected = committedRemoteCreate, + protected.targetKey == targetKey, + protected.epoch == epoch { + selectedSessionID = protected.session.id + remoteSessionSelected = true + } else { + if let selected = ready.selectedSessionId { + selectedSessionID = selected + } + remoteSessionSelected = ready.selectedSessionId != nil + } + busy = ready.busy + remoteQuery = ready.query + remoteAgentFilter = ready.agentFilter.name + remoteHasMore = ready.hasMore + remoteHasMoreMessages = ready.hasMoreMessages + remotePermissionMode = ready.permissionMode?.name ?? remotePermissionMode + remotePermissionFailure = ready.permissionModeFailure?.name + activeTurnID = ready.timeline?.activeTurn?.turnId + isSending = ready.timeline?.activeTurn != nil + modelOptions = ready.createModelOptions(fallbackLabel: localized("模型")).map { option in + ComposerModelOption( + id: option.id, + primaryLabel: option.primaryLabel, + secondaryLabel: option.secondaryLabel, + source: "REMOTE", + selected: option.selected + ) + } + if let timeline = ready.timeline { + timelineRows = timeline.conversationRows().map(Self.mapConversationRow) + messages = timelineRows.compactMap { row in + guard row.kind != "EMPTY" else { return nil } + return ChatMessage( + id: UUID(uuidString: row.id) ?? UUID(), + role: row.kind == "USER" ? .user : .assistant, + text: row.text + ) + } + } else { + timelineRows = [] + messages = [] + } + if let pending = pendingDirectoryWorkspace, + remoteExpectedDeviceKey == directoryTargetKey(forRawDeviceKey: pending.deviceKey), + pending.epoch == remoteTargetEpoch, + remoteConnected, + remoteInitialWorkspaceReady { + pendingDirectoryWorkspace = nil + coreAdapter?.selectRemoteWorkspace(path: pending.path) + } + openPendingDirectorySessionIfReady() + advancePendingDirectoryRemoteDraftIfReady() + } + + func apply(workspaceState state: RemoteWorkspaceUiState, targetKey: String, epoch: UInt64) { + guard !localActionPreview, !accountLoginPreview, !remoteCreatePreview, + RemoteAuthorityGate.callbackMatchesAuthority( + targetKey: targetKey, + epoch: epoch, + expectedTargetKey: remoteExpectedDeviceKey, + expectedEpoch: remoteTargetEpoch + ) else { return } + workspaceLoading = state is RemoteWorkspaceUiStateLoading + workspaceLoadFailed = state is RemoteWorkspaceUiStateFailed + if !(state is RemoteWorkspaceUiStateReady) { + remoteInitialWorkspaceReady = false + } + if state is RemoteWorkspaceUiStateFailed { + if pendingRemoteWorkspaceCreate != nil || pendingRemoteAssistantCreate || + pendingDirectoryRemoteDraft != nil { + pendingRemoteWorkspaceCreate = nil + pendingDirectoryRemoteDraft = nil + pendingRemoteAssistantCreate = false + showToast(localized("工作区加载失败,点按重试")) + } + return + } + guard let ready = state as? RemoteWorkspaceUiStateReady else { return } + + workspaceLoading = false + workspaceLoadFailed = false + remoteInitialWorkspaceReady = true + selectedRemoteWorkspaceKind = ready.selected?.kind ?? "" + var seen = Set() + var catalog: [(path: String, name: String, selected: Bool)] = [] + if let selected = ready.selected, + !selected.path.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + let normalizedPath = normalizedSessionWorkspacePath(selected.path) + seen.insert(normalizedPath) + catalog.append((selected.path, selected.name, true)) + } + for workspace in ready.workspaces { + guard !workspace.path.isEmpty else { continue } + let normalizedPath = normalizedSessionWorkspacePath(workspace.path) + guard seen.insert(normalizedPath).inserted else { continue } + catalog.append((workspace.path, workspace.name, false)) + } + workspaceCatalog = catalog + remoteAssistants = ready.assistants.map { + MobileAssistantOption(path: $0.path, name: $0.name) + } + rebuildRemoteWorkspaceGroups() + if directPairingConnected { + updateDirectPairingDirectoryEntry() + } + apply(filePreviewState: ready.preview) + apply(downloadState: ready.download) + if let pending = pendingRemoteWorkspaceCreate, + ready.selected?.path == pending.path { + pendingRemoteWorkspaceCreate = nil + createRemoteSession(agentType: pending.agentType, title: "", instruction: "") + } + if pendingRemoteAssistantCreate, + ready.selected?.kind.lowercased() == "assistant" { + pendingRemoteAssistantCreate = false + createRemoteSession(agentType: "Claw", title: "", instruction: "") + } + advancePendingDirectoryRemoteDraftIfReady() + } + + private func updateDirectPairingDirectoryEntry() { + guard let name = directPairingDeviceName else { return } + directPairingDirectoryEntry = MobileDeviceDirectoryEntry( + id: directPairingSidebarDeviceID, + name: name, + online: remoteConnected, + expanded: directPairingDirectoryEntry?.expanded ?? true, + status: remoteConnected ? "READY" : "FAILED", + error: remoteConnected ? nil : "DISCONNECTED", + workspaces: remoteWorkspaces, + sessions: remoteSessions + ) + } + + func rebuildRemoteWorkspaceGroups() { + let selectedPath = workspaceCatalog.first(where: { $0.selected })?.path + remoteWorkspaces = workspaceCatalog.map { workspace in + MobileWorkspaceGroup( + path: workspace.path, + name: workspace.name.isEmpty ? workspace.path : workspace.name, + selected: workspace.selected, + sessions: remoteSessions.filter { session in + normalizedSessionWorkspacePath(session.workspacePath ?? selectedPath ?? "") == + normalizedSessionWorkspacePath(workspace.path) + } + ) + } + } +} + +extension MobileAppModel { + var sessionListWorkspaceOptions: [MobileSessionWorkspaceOption] { + SessionListPresentation.shared + .workspaceOptions(sessions: sessionListCoreSessions, workspace: sessionListWorkspaceContext) + .map { MobileSessionWorkspaceOption(path: $0.path, name: $0.name) } + } + + var sessionListAgentGroups: [String] { + SessionListPresentation.shared + .agentGroups(sessions: sessionListCoreSessions, workspace: sessionListWorkspaceContext) + .map(\.name) + } + + var sessionListStatusOptions: [String] { + SessionListPresentation.shared.statusOptions(sessions: sessionListCoreSessions) + } + + var sessionListSections: [MobileSessionListSectionProjection] { + let groupMode: SessionGroupMode = switch remoteGroupMode { + case "TIME": .time + case "CHAT": .chat + default: .project + } + let agentFilter: SessionAgentGroup? = switch remoteViewAgentFilter { + case "CHAT": .chat + case "CODE": .code + case "COWORK": .cowork + default: nil + } + let view = SessionListPresentation.shared.view( + sessions: sessionListCoreSessions, + workspace: sessionListWorkspaceContext, + options: SessionListOptions( + groupMode: groupMode, + query: "", + workspaceFilter: remoteWorkspaceFilter, + agentFilter: agentFilter, + statusFilter: remoteStatusFilter + ), + nowMs: Int64(Date().timeIntervalSince1970 * 1_000) + ) + let byID = Dictionary(uniqueKeysWithValues: remoteSessions.map { ($0.id, $0) }) + return view.sections.compactMap { section in + switch onEnum(of: section) { + case .chat(let value): + return projection(id: "chat", kind: .chat, section: value, byID: byID) + case .project(let value): + return MobileSessionListSectionProjection( + id: "project:\(value.path)", + kind: .project, + path: value.path, + name: value.name, + sessions: value.sessions.compactMap { byID[$0.id] } + ) + case .today(let value): + return projection(id: "today", kind: .today, section: value, byID: byID) + case .yesterday(let value): + return projection(id: "yesterday", kind: .yesterday, section: value, byID: byID) + case .earlier(let value): + return projection(id: "earlier", kind: .earlier, section: value, byID: byID) + } + } + } + + private var sessionListCoreSessions: [RemoteSession] { + remoteSessions.map { session in + RemoteSession( + id: session.id, + title: session.title, + agentType: session.agentType, + status: session.status, + updatedAt: session.updatedLabel, + createdAt: session.createdAt, + messageCount: Int32(session.messageCount), + workspacePath: session.workspacePath, + workspaceName: session.workspaceName + ) + } + } + + private var sessionListWorkspaceContext: SessionWorkspaceContext { + let assistantPaths = Set(remoteAssistants.map { normalizedSessionWorkspacePath($0.path) }) + let selected = remoteWorkspaces.first(where: \.selected) + let recent = remoteWorkspaces.map { workspace in + RecentWorkspace( + path: workspace.path, + name: workspace.name, + lastOpened: "", + kind: assistantPaths.contains(normalizedSessionWorkspacePath(workspace.path)) + ? "assistant" + : "normal" + ) + } + let selectedKind = selected.map { + assistantPaths.contains(normalizedSessionWorkspacePath($0.path)) ? "assistant" : "normal" + } ?? "" + return SessionWorkspaceContext( + selectedPath: selected?.path ?? "", + selectedName: selected?.name ?? "", + selectedKind: selectedKind, + recent: recent + ) + } + + private func projection( + id: String, + kind: MobileSessionListSectionKind, + section: any SessionListSection, + byID: [String: ChatSession] + ) -> MobileSessionListSectionProjection { + MobileSessionListSectionProjection( + id: id, + kind: kind, + path: "", + name: "", + sessions: section.sessions.compactMap { byID[$0.id] } + ) + } + + private func normalizedSessionWorkspacePath(_ path: String) -> String { + var result = path.trimmingCharacters(in: .whitespacesAndNewlines) + while result.count > 1 && (result.hasSuffix("/") || result.hasSuffix("\\")) { + result.removeLast() + } + return result + } +} diff --git a/src/apps/mobile/ios/BitFun/Infrastructure/MobileAppModel.swift b/src/apps/mobile/ios/BitFun/Infrastructure/MobileAppModel.swift index f17948973b..85ddc64eb7 100644 --- a/src/apps/mobile/ios/BitFun/Infrastructure/MobileAppModel.swift +++ b/src/apps/mobile/ios/BitFun/Infrastructure/MobileAppModel.swift @@ -93,14 +93,63 @@ struct MobileConversationRow: Identifiable, Equatable { let showRetry: Bool } +enum MobileFilePreviewFailureKind: String { + case notFound, unavailable, accessDenied, tooLarge, connection, loadFailed +} + struct MobileFilePreview: Identifiable, Equatable { let id: String + let sessionID: String + let controlTargetEpoch: Int32 let name: String let content: String let mimeType: String let imageData: Data? let truncated: Bool + let loadedBytes: Int64 + let sizeBytes: Int64 + let markdown: Bool + let lineStart: Int32 let failure: String? + let failureKind: MobileFilePreviewFailureKind? + let retryable: Bool + let unsupported: Bool + + init( + id: String, + sessionID: String = "", + controlTargetEpoch: Int32 = 0, + name: String, + content: String, + mimeType: String, + imageData: Data?, + truncated: Bool, + loadedBytes: Int64 = 0, + sizeBytes: Int64 = 0, + markdown: Bool = false, + lineStart: Int32 = 0, + failure: String?, + failureKind: MobileFilePreviewFailureKind? = nil, + retryable: Bool = false, + unsupported: Bool = false + ) { + self.id = id + self.sessionID = sessionID + self.controlTargetEpoch = controlTargetEpoch + self.name = name + self.content = content + self.mimeType = mimeType + self.imageData = imageData + self.truncated = truncated + self.loadedBytes = loadedBytes + self.sizeBytes = sizeBytes + self.markdown = markdown + self.lineStart = lineStart + self.failure = failure + self.failureKind = failureKind + self.retryable = retryable + self.unsupported = unsupported + } } struct MobilePendingDownload: Identifiable, Equatable { @@ -110,6 +159,19 @@ struct MobilePendingDownload: Identifiable, Equatable { let name: String let mimeType: String let data: Data + let sessionID: String + let controlTargetEpoch: Int32 + + init(reference: String, remotePath: String, name: String, mimeType: String, data: Data, + sessionID: String = "", controlTargetEpoch: Int32 = 0) { + self.reference = reference + self.remotePath = remotePath + self.name = name + self.mimeType = mimeType + self.data = data + self.sessionID = sessionID + self.controlTargetEpoch = controlTargetEpoch + } } struct ChatSession: Identifiable, Equatable { @@ -121,10 +183,28 @@ struct ChatSession: Identifiable, Equatable { var agentType: String = "general_chat" var workspacePath: String? var workspaceName: String? + var deviceKey: String? = nil var createdAt: String = "" var messageCount: Int = 0 } +struct CommittedRemoteCreate { + let targetKey: String + let epoch: UInt64 + let session: ChatSession + /// First authoritative Ready revision guaranteed to contain this commit. + let minimumAuthorityRevision: Int64 +} + +struct PendingDirectoryRemoteDraft { + let targetKey: String + let rawDeviceKey: String + let workspacePath: String + let normalizedWorkspacePath: String + let epoch: UInt64 + var selectionRequested: Bool +} + struct MobileAccountDevice: Identifiable, Equatable { let id: String let name: String @@ -132,12 +212,24 @@ struct MobileAccountDevice: Identifiable, Equatable { let selected: Bool } +struct MobileDeviceDirectoryEntry: Identifiable, Equatable { + let id: String + let name: String + let online: Bool + let expanded: Bool + let status: String + let error: String? + let workspaces: [MobileWorkspaceGroup] + let sessions: [ChatSession] +} + struct MobileWorkspaceGroup: Identifiable, Equatable { - var id: String { path } + var id: String { (deviceKey ?? "") + ":" + path } let path: String let name: String let selected: Bool let sessions: [ChatSession] + var deviceKey: String? = nil } enum MobileSessionListSectionKind: Equatable { @@ -217,6 +309,9 @@ final class MobileAppModel: ObservableObject { @Published var remotePermissionFailure: String? @Published var remoteAssistants: [MobileAssistantOption] = [] @Published var remoteCreateOpen = false + @Published var remoteCreateSubmitting = false + @Published var remoteCreateError: String? + @Published var remoteCreateDeviceError: String? @Published var generalConfigOpen = false @Published var generalConfigured = false @Published var generalConfigBaseURL = "" @@ -255,12 +350,16 @@ final class MobileAppModel: ObservableObject { @Published var accountUserID: String? @Published var localDeviceID = "" @Published var accountBusy = false + @Published var accountFailureStage: String? + @Published var accountFailureCanRetry = false @Published var accountDeviceName: String? @Published var directPairingDeviceName: String? @Published var accountDeviceCount = 0 @Published var accountDevices: [MobileAccountDevice] = [] @Published var accountSelectedDeviceID: String? @Published var accountRefreshing = false + @Published var deviceDirectory: [MobileDeviceDirectoryEntry] = [] + @Published var directPairingDirectoryEntry: MobileDeviceDirectoryEntry? @Published var remoteWorkspaces: [MobileWorkspaceGroup] = [] @Published var workspaceLoading = false @Published var workspaceLoadFailed = false @@ -272,17 +371,39 @@ final class MobileAppModel: ObservableObject { @Published var downloadTargetPath: String? @Published var downloadStatusText: String? @Published var downloadPhase: MobileDownloadPhase = .idle - private var activeTurnID: String? - private var directPairingConnected = false - private var accountLoginPreview = false - private var localActionPreview = false + var activeTurnID: String? + var directPairingConnected = false + var accountLoginPreview = false + var localActionPreview = false var composerModelPickerPreview = false - private var workspaceCatalog: [(path: String, name: String, selected: Bool)] = [] - private var pendingRemoteWorkspaceCreate: (path: String, agentType: String)? - private var pendingRemoteAssistantCreate = false - private var selectedRemoteWorkspaceKind = "" - - private var coreAdapter: MobileCoreAdapter? + var remoteCreatePreview = false + var directoryFixturePreview = false + var pairingGeneration: UInt64 = 0 + var accountGeneration: UInt64 = 0 + var pendingAccountOperationPreservesPairing: (generation: UInt64, preserve: Bool)? + var pairingIntentInFlight = false + var remoteTargetEpoch: UInt64 = 0 + var remoteExpectedDeviceKey: String? + var remoteBoundTargetKey: String? + var remoteBoundTargetEpoch: UInt64? + var pairingRetainedAccountAuthority: RetainedAccountAuthority? + var accountDirectoryGeneration: UInt64 = 0 + var pendingDirectorySession: (deviceKey: String, sessionID: String, epoch: UInt64)? + var remoteInitialSessionReady = false + var remoteInitialWorkspaceReady = false + var remoteCreateRequestID: String? + var remoteCreateRequestEpoch: UInt64 = 0 + var remoteCreateRequestDeviceKey: String? + var committedRemoteCreate: CommittedRemoteCreate? + var remoteLastAppliedAuthority: RemoteAuthorityScope? + var workspaceCatalog: [(path: String, name: String, selected: Bool)] = [] + var pendingRemoteWorkspaceCreate: (path: String, agentType: String)? + var pendingDirectoryWorkspace: (deviceKey: String, path: String, epoch: UInt64)? + var pendingDirectoryRemoteDraft: PendingDirectoryRemoteDraft? + var pendingRemoteAssistantCreate = false + var selectedRemoteWorkspaceKind = "" + + var coreAdapter: MobileCoreAdapter? init(sessions: [ChatSession], selectedSessionID: String, messages: [ChatMessage]) { self.sessions = sessions @@ -292,10 +413,30 @@ final class MobileAppModel: ObservableObject { self.coreAdapter = nil let adapter = MobileCoreAdapter( onState: { [weak self] state in self?.apply(coreState: state) }, - onPairingState: { [weak self] state in self?.apply(pairingState: state) }, - onAccountState: { [weak self] state in self?.apply(accountState: state) }, - onRemoteState: { [weak self] state in self?.apply(remoteState: state) }, - onWorkspaceState: { [weak self] state in self?.apply(workspaceState: state) }, + onPairingState: { [weak self] state, generation in + self?.apply(pairingState: state, generation: generation) + }, + onAccountState: { [weak self] state, generation in + self?.apply(accountState: state, generation: generation) + }, + onRemoteTargetBound: { [weak self] targetKey, epoch, generation in + self?.apply(remoteTargetBound: targetKey, epoch: epoch, accountGeneration: generation) + }, + onRemoteState: { [weak self] state, targetKey, epoch in + self?.apply(remoteState: state, targetKey: targetKey, epoch: epoch) + }, + onWorkspaceState: { [weak self] state, targetKey, epoch in + self?.apply(workspaceState: state, targetKey: targetKey, epoch: epoch) + }, + onDirectoryState: { [weak self] state, generation in + self?.apply(directoryState: state, generation: generation) + }, + onCreateOperation: { [weak self] state, targetKey in + self?.apply(createOperation: state, targetKey: targetKey) + }, + onCreateUnavailable: { [weak self] requestID, targetKey in + self?.failRemoteCreate(requestID: requestID, targetKey: targetKey) + } ) self.coreAdapter = adapter self.localDeviceID = adapter.deviceID @@ -430,7 +571,9 @@ final class MobileAppModel: ObservableObject { arguments.contains("--pairing-account") { model.pairingSheetOpen = true } - if arguments.contains("--remote-create") { + if arguments.contains("--remote-create") || arguments.contains("--remote-create-workspace-picker") { + model.remoteCreatePreview = true + if !model.remoteConnected { model.configureConnectedPreview() } model.remoteCreateOpen = true } if arguments.contains("--remote-home-preview") { @@ -498,43 +641,7 @@ final class MobileAppModel: ObservableObject { surface == .local ? sessions : remoteSessions } - func send() { - if surface == .remote { - sendRemote() - return - } - let value = draft.trimmingCharacters(in: .whitespacesAndNewlines) - guard !value.isEmpty || !composerImages.isEmpty else { return } - guard !isSending && !busy else { return } - if surface == .local { - localSessionSelected = true - if selectedSession == nil, let first = sessions.first { - selectedSessionID = first.id - } - } - let optimisticMessage = ChatMessage(id: UUID(), role: .user, text: value) - messages.append(optimisticMessage) - timelineRows.append(Self.simpleTimelineRow(optimisticMessage, images: composerImages)) - draft = "" - isSending = true - busy = true - coreAdapter?.updateDraft(value) - coreAdapter?.setGeneralChatImages(composerImages) - composerImages = [] - coreAdapter?.send() - } - func select(_ session: ChatSession) { - selectedSessionID = session.id - if surface == .remote { - remoteSessionSelected = true - coreAdapter?.openRemoteSession(sessionID: session.id) - } else { - localSessionSelected = true - coreAdapter?.selectGeneralSession(sessionID: session.id) - } - drawerOpen = false - } func switchSurface(_ next: MobileSurface) { surface = next @@ -613,15 +720,20 @@ final class MobileAppModel: ObservableObject { } func disconnectRemote() { + invalidateTargetScopedFileTransfers() + committedRemoteCreate = nil + remoteLastAppliedAuthority = nil coreAdapter?.disconnect() directPairingConnected = false directPairingDeviceName = nil + pendingAccountOperationPreservesPairing = nil remoteConnected = false remoteSessionSelected = false remoteSessions = [] remoteWorkspaces = [] workspaceCatalog = [] pendingRemoteWorkspaceCreate = nil + pendingDirectoryRemoteDraft = nil pendingRemoteAssistantCreate = false selectedRemoteWorkspaceKind = "" selectedSessionID = "" @@ -636,228 +748,29 @@ final class MobileAppModel: ObservableObject { drawerOpen = false } - func newLocalChat() { - surface = .local - drawerOpen = false - localSessionSelected = false - selectedSessionID = "" - messages = [] - timelineRows = [] - draft = "" - composerImages = [] - coreAdapter?.newGeneralSession() - } - func selectRemoteDevice(_ device: MobileAccountDevice) { - guard device.online else { - showToast(localized("这台桌面设备当前离线")) - return - } - surface = .remote - drawerOpen = false - guard !device.selected else { return } - directPairingConnected = false - directPairingDeviceName = nil - accountBusy = true - remoteSessionSelected = false - remoteConnected = directPairingConnected - remoteSessions = [] - remoteWorkspaces = [] - workspaceCatalog = [] - pendingRemoteWorkspaceCreate = nil - pendingRemoteAssistantCreate = false - selectedRemoteWorkspaceKind = "" - messages = [] - timelineRows = [] - coreAdapter?.selectAccountDevice(id: device.id) - } - func refreshRemoteDevices() { - guard accountUser != nil else { return } - coreAdapter?.refreshAccountDevices() - } - func logoutAccount() { - coreAdapter?.logoutAccount() - accountUser = nil - accountUserID = nil - accountDeviceName = nil - accountDeviceCount = 0 - accountDevices = [] - accountSelectedDeviceID = nil - remoteConnected = directPairingConnected - if !directPairingConnected { - remoteSessionSelected = false - remoteSessions = [] - remoteWorkspaces = [] - workspaceCatalog = [] - pendingRemoteWorkspaceCreate = nil - pendingRemoteAssistantCreate = false - selectedRemoteWorkspaceKind = "" - surface = .local - } - } - func selectRemoteWorkspace(_ workspace: MobileWorkspaceGroup) { - guard remoteConnected else { - showToast(localized("请先连接桌面设备")) - return - } - surface = .remote - drawerOpen = false - coreAdapter?.selectRemoteWorkspace(path: workspace.path) - } - func createRemoteSession(in workspace: MobileWorkspaceGroup, agentType: String) { - guard remoteConnected, !busy else { return } - drawerOpen = false - surface = .remote - createRemoteSession( - agentType: agentType, - title: "", - instruction: "", - workspacePath: workspace.path - ) - } - func createRemoteAssistantSession() { - guard remoteConnected, !busy else { return } - drawerOpen = false - surface = .remote - if selectedRemoteWorkspaceKind.lowercased() == "assistant" { - createRemoteSession(agentType: "Claw", title: "", instruction: "") - return - } - guard let assistant = remoteAssistants.first else { - showToast(localized("暂无可用工作区")) - return - } - pendingRemoteAssistantCreate = true - coreAdapter?.selectRemoteAssistant(path: assistant.path) - } - func selectRemoteAssistant(_ assistant: MobileAssistantOption) { - guard remoteConnected else { return } - coreAdapter?.selectRemoteAssistant(path: assistant.path) - } - func createRemoteSession( - agentType: String, - title: String, - instruction: String, - modelID: String? = nil, - workspacePath: String? = nil - ) { - guard remoteConnected, !busy else { return } - let normalizedTitle = title.trimmingCharacters(in: .whitespacesAndNewlines) - let normalizedInstruction = instruction.trimmingCharacters(in: .whitespacesAndNewlines) - let selectedModel = modelID ?? modelOptions.first(where: \.selected)?.id - coreAdapter?.createRemoteSession( - agentType: agentType, - title: normalizedTitle, - instruction: normalizedInstruction, - modelID: selectedModel, - workspacePath: workspacePath - ) - remoteCreateOpen = false - surface = .remote - } - func deleteRemoteSession(_ session: ChatSession) { - guard !busy else { return } - coreAdapter?.deleteRemoteSession(sessionID: session.id) - if selectedSessionID == session.id { - remoteSessionSelected = false - timelineRows = [] - messages = [] - } - } - func searchRemoteSessions(_ query: String) { - remoteQuery = query - guard remoteConnected else { return } - coreAdapter?.searchRemoteSessions(query: query) - } - func loadMoreRemoteSessions() { - guard remoteConnected, remoteHasMore, !busy else { return } - coreAdapter?.loadMoreRemoteSessions() - } - func loadOlderRemoteMessages() { - guard surface == .remote, remoteConnected, remoteHasMoreMessages, !busy else { return } - coreAdapter?.loadOlderRemoteMessages() - } - func refreshRemoteSessions() { - guard remoteConnected, !busy else { return } - coreAdapter?.refreshRemoteSessions() - } - func setRemoteAgentFilter(_ name: String) { - let filter: SessionAgentFilter - switch name { - case "CODE": filter = .code - case "COWORK": filter = .cowork - default: filter = .all - } - remoteAgentFilter = name - coreAdapter?.setRemoteAgentFilter(filter) - } - func refreshRemotePermissionMode() { - guard remoteConnected else { return } - coreAdapter?.refreshRemotePermissionMode() - } - func setRemotePermissionMode(_ name: String) { - let mode: SessionPermissionMode - switch name { - case "AUTO": mode = .auto - case "FULL_ACCESS": mode = .fullAccess - default: mode = .ask - } - coreAdapter?.setRemotePermissionMode(mode) - } - func retryRemoteWorkspaces() { - coreAdapter?.loadRemoteWorkspaces() - } - func archiveLocalSession(_ session: ChatSession) { - coreAdapter?.archiveGeneralSession( - sessionID: session.id, - archived: session.status.lowercased() != "archived" - ) - } - func deleteLocalSession(_ session: ChatSession) { - coreAdapter?.deleteGeneralSession(sessionID: session.id) - if selectedSessionID == session.id { - localSessionSelected = false - } - } - func saveGeneralConfig(baseURL: String, model: String, apiKey: String, clearAPIKey: Bool) { - coreAdapter?.saveGeneralConfig( - baseURL: baseURL, model: model, apiKey: apiKey, clearAPIKey: clearAPIKey - ) - } - func testGeneralConnection(baseURL: String, model: String, apiKey: String, clearAPIKey: Bool) { - coreAdapter?.testGeneralConnection( - baseURL: baseURL, model: model, apiKey: apiKey, clearAPIKey: clearAPIKey - ) - } - func exportSelectedSession() { - guard surface == .local, let session = selectedSession else { return } - coreAdapter?.exportGeneralSession(sessionID: session.id) - } - func exportLocalSession(_ session: ChatSession) { - coreAdapter?.exportGeneralSession(sessionID: session.id) - } func showSessionDetails(_ session: ChatSession) { sessionDetails = session @@ -867,11 +780,6 @@ final class MobileAppModel: ObservableObject { sessionDetails = nil } - func finishGeneralExport() { - generalExportOpen = false - generalExportData = Data() - coreAdapter?.clearGeneralExport() - } private func configureConnectedPreview() { directPairingConnected = true @@ -882,8 +790,11 @@ final class MobileAppModel: ObservableObject { accountUser = "preview@bitfun" accountDeviceName = "DESKTOP-KM3L4UI" accountSelectedDeviceID = "preview-desktop" + directoryFixturePreview = true accountDevices = [ - MobileAccountDevice(id: "preview-desktop", name: "DESKTOP-KM3L4UI", online: true, selected: true) + MobileAccountDevice(id: "preview-desktop", name: "DESKTOP-KM3L4UI", online: true, selected: true), + MobileAccountDevice(id: "preview-mac", name: "Studio Mac", online: true, selected: false), + MobileAccountDevice(id: "preview-offline", name: "Office PC", online: false, selected: false) ] accountDeviceCount = accountDevices.count let session = ChatSession( @@ -895,6 +806,31 @@ final class MobileAppModel: ObservableObject { workspaceName: "BitFun" ) remoteSessions = [session] + let extraSessions = (1...5).map { index in + ChatSession( + id: "preview-session-\(index)", title: "Review session \(index)", updatedLabel: "2026-01-01T00:00:00Z", + status: index == 1 ? "running" : "idle", agentType: "code", + workspacePath: "/workspace/BitFun", workspaceName: "BitFun", deviceKey: "preview-desktop" + ) + } + remoteSessions.append(contentsOf: extraSessions) + let cachedSession = ChatSession( + id: "preview-offline-session", title: "Cached offline session", updatedLabel: "2026-01-01T00:00:00Z", + status: "idle", agentType: "code", workspacePath: "/office/project", workspaceName: "Office project", deviceKey: "preview-offline" + ) + let failedSession = ChatSession( + id: "preview-failed-session", title: "Cached failed session", updatedLabel: "2026-01-01T00:00:00Z", + status: "idle", agentType: "code", workspacePath: "/staging/project", workspaceName: "Staging", deviceKey: "preview-mac" + ) + remoteSessions.append(contentsOf: [cachedSession, failedSession]) + let previewWorkspace = MobileWorkspaceGroup(path: "/workspace/BitFun", name: "BitFun", selected: true, sessions: remoteSessions.filter { $0.deviceKey == "preview-desktop" }, deviceKey: "preview-desktop") + let offlineWorkspace = MobileWorkspaceGroup(path: "/office/project", name: "Office project", selected: false, sessions: [cachedSession], deviceKey: "preview-offline") + let failedWorkspace = MobileWorkspaceGroup(path: "/staging/project", name: "Staging", selected: false, sessions: [failedSession], deviceKey: "preview-mac") + deviceDirectory = [ + MobileDeviceDirectoryEntry(id: "preview-desktop", name: "DESKTOP-KM3L4UI", online: true, expanded: true, status: "READY", error: nil, workspaces: [previewWorkspace], sessions: previewWorkspace.sessions), + MobileDeviceDirectoryEntry(id: "preview-mac", name: "Studio Mac", online: true, expanded: true, status: "FAILED", error: "REMOTE_UNAVAILABLE", workspaces: [failedWorkspace], sessions: [failedSession]), + MobileDeviceDirectoryEntry(id: "preview-offline", name: "Office PC", online: false, expanded: false, status: "READY", error: nil, workspaces: [offlineWorkspace], sessions: [cachedSession]) + ] workspaceCatalog = [(path: "/workspace/BitFun", name: "BitFun", selected: true)] remoteAssistants = [ MobileAssistantOption(path: "/workspace/BitFun/.bitfun/assistants/review", name: "代码审查助手") @@ -959,62 +895,98 @@ final class MobileAppModel: ObservableObject { } func submitPairing(url: String) { + prepareProjectionForPairingSubmission() + pairingIntentInFlight = true + pairingGeneration &+= 1 pairingError = nil pairingBusy = true coreAdapter?.submitPairing(url: url) } func submitPairing(url: String, userID: String, password: String) { + prepareProjectionForPairingSubmission() + pairingIntentInFlight = true + pairingGeneration &+= 1 pairingError = nil pairingBusy = true coreAdapter?.submitPairing(url: url, userID: userID, password: password) } - func loginAccount(relayURL: String, username: String, password: String) { - accountBusy = true - coreErrorMessage = nil - coreAdapter?.loginAccount(relayURL: relayURL, username: username, password: password) - } + private func prepareProjectionForPairingSubmission() { + let adapterTargetKey = coreAdapter?.currentRemoteTargetKey + let adapterEpoch = coreAdapter?.currentRemoteTargetEpoch ?? 0 + let healthyConnected: Bool + switch connectionPhase { + case .connected: healthyConnected = remoteConnected + case .reconnecting, .disconnected: healthyConnected = false + } + if let adapterTargetKey, + adapterTargetKey.hasPrefix("account:"), + adapterTargetKey == remoteExpectedDeviceKey, + adapterEpoch == remoteTargetEpoch, + adapterTargetKey == remoteBoundTargetKey, + adapterEpoch == remoteBoundTargetEpoch, + healthyConnected { + pairingRetainedAccountAuthority = RetainedAccountAuthority( + targetKey: adapterTargetKey, + epoch: adapterEpoch + ) + } else { + pairingRetainedAccountAuthority = nil + } + let transition = RemoteAuthorityGate.pairingAttemptTransition( + authoritativeTargetKey: adapterTargetKey, + remoteConnected: remoteConnected + ) + guard transition.clearBoundRemoteProjection else { return } - func sendRemote() { - let value = draft.trimmingCharacters(in: .whitespacesAndNewlines) - guard !value.isEmpty || !composerImages.isEmpty, - !isSending, - connectionPhase != .disconnected, - let sessionID = visibleSessions.first(where: { $0.id == selectedSessionID })?.id else { return } - let images = composerImages - draft = "" + invalidateTargetScopedFileTransfers() + directPairingConnected = false + directPairingDeviceName = nil + directPairingDirectoryEntry = nil + remoteConnected = transition.remoteConnected + remoteExpectedDeviceKey = nil + remoteLastAppliedAuthority = nil + committedRemoteCreate = nil + pendingAccountOperationPreservesPairing = nil + remoteInitialSessionReady = false + remoteInitialWorkspaceReady = false + remoteSessionSelected = false + remoteSessions = [] + remoteWorkspaces = [] + remoteAssistants = [] + remotePermissionFailure = nil + sessionDetails = nil + workspaceCatalog = [] + workspaceLoading = false + workspaceLoadFailed = false + pendingDirectorySession = nil + pendingDirectoryWorkspace = nil + pendingDirectoryRemoteDraft = nil + pendingRemoteWorkspaceCreate = nil + pendingRemoteAssistantCreate = false + selectedRemoteWorkspaceKind = "" + selectedSessionID = "" + remoteCreateOpen = false + remoteCreateSubmitting = false + remoteCreateRequestID = nil + remoteCreateRequestEpoch = remoteTargetEpoch + remoteCreateRequestDeviceKey = nil + remoteCreateError = nil + remoteCreateDeviceError = nil + activeTurnID = nil + isSending = false + busy = false composerImages = [] - isSending = true - busy = true - coreAdapter?.sendRemote(sessionID: sessionID, content: value, images: images) + timelineRows = [] + messages = [] + connectionPhase = .reconnecting } - func syncDraftToCore() { - if surface == .local { - coreAdapter?.updateDraft(draft) - } - } - func addComposerImage(data: Data, mimeType: String) { - guard composerImages.count < 4, data.count <= 10 * 1024 * 1024 else { - showToast(localized("最多添加 4 张且每张不超过 10 MB 的图片")) - return - } - composerImages.append( - ComposerAttachment(id: UUID().uuidString, data: data, mimeType: mimeType) - ) - if surface == .local { - coreAdapter?.setGeneralChatImages(composerImages) - } - } - func removeComposerImage(id: String) { - composerImages.removeAll { $0.id == id } - if surface == .local { - coreAdapter?.setGeneralChatImages(composerImages) - } - } + + func stopSending() { if surface == .remote { @@ -1025,53 +997,10 @@ final class MobileAppModel: ObservableObject { } } - func approveTool(_ toolID: String) { - guard surface == .remote, remoteSessionSelected, !toolID.isEmpty else { return } - coreAdapter?.approveRemoteTool(sessionID: selectedSessionID, toolID: toolID) - } - func rejectTool(_ toolID: String) { - guard surface == .remote, remoteSessionSelected, !toolID.isEmpty else { return } - coreAdapter?.rejectRemoteTool( - sessionID: selectedSessionID, - toolID: toolID, - reason: "Rejected from the iOS client" - ) - } - func cancelTool(_ toolID: String) { - guard surface == .remote, remoteSessionSelected, !toolID.isEmpty else { return } - coreAdapter?.cancelRemoteTool( - sessionID: selectedSessionID, - toolID: toolID, - reason: "Cancelled from the iOS client" - ) - } - func answerTool(_ toolID: String, answer: String) { - let normalized = answer.trimmingCharacters(in: .whitespacesAndNewlines) - guard surface == .remote, - remoteSessionSelected, - !toolID.isEmpty, - !normalized.isEmpty else { return } - coreAdapter?.answerRemoteTool( - sessionID: selectedSessionID, - toolID: toolID, - answer: normalized - ) - } - func answerTool(_ toolID: String, answers: [QuestionAnswer]) { - guard surface == .remote, - remoteSessionSelected, - !toolID.isEmpty, - !answers.isEmpty else { return } - coreAdapter?.answerRemoteToolStructured( - sessionID: selectedSessionID, - toolID: toolID, - answers: answers - ) - } func retryMessage(_ text: String) { let normalized = text.trimmingCharacters(in: .whitespacesAndNewlines) @@ -1087,59 +1016,10 @@ final class MobileAppModel: ObservableObject { } } - func openRemoteFile(reference: String, label: String) { - guard surface == .remote, remoteSessionSelected else { - showToast(localized("仅远程工作区文件支持预览")) - return - } - filePreviewLoading = true - coreAdapter?.openRemoteFile( - reference: reference, - label: label, - sessionID: selectedSessionID - ) - } - func downloadRemoteFile(reference: String, label: String) { - guard surface == .remote, remoteSessionSelected else { return } - downloadTargetPath = reference - .replacingOccurrences(of: "computer://", with: "", options: [.caseInsensitive]) - downloadPhase = .preparing - downloadStatusText = localized("正在准备下载") - coreAdapter?.downloadRemoteFile( - reference: reference, - label: label, - sessionID: selectedSessionID - ) - } - func finishDownloadExport(success: Bool) { - guard let download = pendingDownload else { return } - if success { - coreAdapter?.remoteDownloadSaved(reference: download.reference) - downloadPhase = .saved - downloadStatusText = localized("已下载") - showToast(localizedFormat("已保存 %@", download.name)) - } else { - coreAdapter?.remoteDownloadSaveFailed(reference: download.reference) - downloadPhase = .failed - downloadStatusText = localized("保存失败") - showToast(localized("文件保存失败")) - } - pendingDownload = nil - downloadExporterOpen = false - } - func downloadStatus(for remotePath: String) -> String? { - guard downloadTargetPath == remotePath else { return nil } - return downloadStatusText - } - func dismissFilePreview() { - filePreview = nil - filePreviewLoading = false - coreAdapter?.dismissRemoteFilePreview() - } func renameSelectedSession(_ title: String) { let normalized = title.trimmingCharacters(in: .whitespacesAndNewlines) @@ -1170,14 +1050,6 @@ final class MobileAppModel: ObservableObject { localSessionSelected = false } - func selectModel(_ modelID: String) { - guard selectedSession != nil else { return } - if surface == .remote { - coreAdapter?.selectRemoteModel(sessionID: selectedSessionID, modelID: modelID) - } else { - coreAdapter?.selectGeneralModel(modelID: modelID) - } - } func showUploadedFiles() { let count = composerImages.count @@ -1197,87 +1069,79 @@ final class MobileAppModel: ObservableObject { } } - private func apply(coreState state: GeneralChatUiState) { - generalConfigured = state.configured - generalConfigBaseURL = state.config.baseUrl - generalConfigModel = state.config.model - generalConfigHasAPIKey = state.config.hasApiKey - generalConfigFailure = state.configFailure?.name - generalConnectionTestRunning = state.connectionTest.running - if state.connectionTest.passed { - generalConnectionTestMessage = localized("连接成功") - } else if let failure = state.connectionTest.failure { - generalConnectionTestMessage = localizedFormat("连接失败:%@", failure.name) - } else { - generalConnectionTestMessage = nil - } - if let exported = state.export { - let safeTitle = exported.title - .replacingOccurrences(of: "/", with: "-") - .replacingOccurrences(of: "\\", with: "-") - .replacingOccurrences(of: ":", with: "-") - .trimmingCharacters(in: .whitespacesAndNewlines) - generalExportName = safeTitle.isEmpty ? "conversation.md" : "\(safeTitle).md" - generalExportData = Data(exported.markdown.utf8) - generalExportOpen = true - } - if !state.sessions.isEmpty { - sessions = state.sessions.map { session in - ChatSession( - id: session.id, - title: session.title.isEmpty ? localized("未命名会话") : session.title, - updatedLabel: session.updatedAt, - pinned: session.pinned, - status: session.status, - ) - } - } - if !state.messages.isEmpty { - messages = state.messages.map { message in - let text = message.blocks.map(\.text).joined(separator: "\n") - return ChatMessage( - id: UUID(uuidString: message.id) ?? UUID(), - role: message.role.lowercased() == "user" ? .user : .assistant, - text: text, - ) - } - timelineRows = messages.map(Self.simpleTimelineRow) - } - if !composerModelPickerPreview, draft != state.draft { draft = state.draft } - isSending = state.busy - busy = state.busy - if !composerModelPickerPreview { - modelOptions = state.models.map { model in - ComposerModelOption( - id: model.id, - primaryLabel: model.label, - secondaryLabel: model.source.name, - source: model.source.name, - selected: model.id == state.activeModelId - ) - } - } - if !accountLoginPreview { - if let failure = state.failure { - coreErrorMessage = failure.name - } else { - coreErrorMessage = nil - } - } - } - private func apply(pairingState state: PairingUiState) { - guard !localActionPreview else { return } + private func apply(pairingState state: PairingUiState, generation: UInt64) { + guard !localActionPreview, generation == pairingGeneration, + remoteExpectedDeviceKey == nil || remoteExpectedDeviceKey == "pairing" || pairingIntentInFlight else { return } pairingBusy = state is PairingUiStateConnecting if let failed = state as? PairingUiStateFailed { pairingBusy = false + pairingIntentInFlight = false pairingError = pairingErrorMessage(failed.failure) + let healthyConnected: Bool + switch connectionPhase { + case .connected: healthyConnected = remoteConnected + case .reconnecting, .disconnected: healthyConnected = false + } + let retainAccount = RemoteAuthorityGate.shouldRetainAccountAfterPairingFailure( + captured: pairingRetainedAccountAuthority, + adapterTargetKey: coreAdapter?.currentRemoteTargetKey, + adapterEpoch: coreAdapter?.currentRemoteTargetEpoch ?? 0, + modelTargetKey: remoteExpectedDeviceKey, + modelEpoch: remoteTargetEpoch, + healthyConnected: healthyConnected + ) + let invalidatedAccountAuthority = !retainAccount && + (remoteExpectedDeviceKey?.hasPrefix("account:") == true) + if invalidatedAccountAuthority, let targetKey = remoteExpectedDeviceKey { + invalidateTargetScopedFileTransfers() + _ = coreAdapter?.invalidateRemoteAuthority( + ifTargetKey: targetKey, + epoch: remoteTargetEpoch + ) + clearInvalidatedRemoteAuthorityProjection( + adapterEpoch: coreAdapter?.currentRemoteTargetEpoch ?? remoteTargetEpoch + ) + } else { + pairingRetainedAccountAuthority = nil + } + remoteConnected = retainAccount + if !retainAccount { + let clearingVisibleRemoteConversation = surface == .remote || remoteSessionSelected + remoteSessionSelected = false + if clearingVisibleRemoteConversation { + selectedSessionID = "" + activeTurnID = nil + isSending = false + busy = false + timelineRows = [] + messages = [] + } + connectionPhase = .disconnected + } } else if let paired = state as? PairingUiStatePaired { pairingBusy = false pairingError = nil directPairingConnected = true - directPairingDeviceName = paired.workspace.roomLabel + pairingIntentInFlight = false + pairingRetainedAccountAuthority = nil remoteConnected = true + directPairingDeviceName = paired.workspace.roomLabel + if pendingDirectoryRemoteDraft?.targetKey == "pairing", + pendingDirectoryRemoteDraft?.rawDeviceKey != directPairingSidebarDeviceID { + pendingDirectoryRemoteDraft = nil + showToast(localized("远程会话连接已失效,请重新选择设备后重试")) + } + directPairingDirectoryEntry = MobileDeviceDirectoryEntry( + id: directPairingSidebarDeviceID, + name: paired.workspace.roomLabel, + online: true, + expanded: true, + status: "READY", + error: nil, + workspaces: remoteWorkspaces, + sessions: remoteSessions + ) surface = .remote switch paired.liveness { case .checking: connectionPhase = .reconnecting @@ -1288,393 +1152,16 @@ final class MobileAppModel: ObservableObject { } } - private func apply(accountState state: AccountUiState) { - guard !accountLoginPreview, !localActionPreview else { return } - accountBusy = state is AccountUiStateSigningIn - if let ready = state as? AccountUiStateReady { - accountBusy = false - accountUser = ready.username - accountUserID = ready.userId - accountDeviceName = ready.selectedDeviceName - accountDeviceCount = ready.devices.count - accountSelectedDeviceID = ready.selectedDeviceId - accountRefreshing = ready.refreshing - accountDevices = ready.devices.map { device in - MobileAccountDevice( - id: device.id, - name: device.name, - online: device.online, - selected: device.id == ready.selectedDeviceId - ) - } - if !directPairingConnected, - ready.selectedDeviceId == nil, - let target = ready.devices.first(where: { $0.online }) { - accountBusy = true - coreAdapter?.selectAccountDevice(id: target.id) - return - } - remoteConnected = directPairingConnected || ready.selectedDeviceId != nil - surface = .remote - connectionPhase = .connected - if ready.refreshFailure != nil { - showToast(localized("设备列表刷新失败,仍显示上次结果")) - } - } else if let failed = state as? AccountUiStateFailed { - accountBusy = false - coreErrorMessage = accountErrorMessage(failed.reason.name) - if !directPairingConnected { connectionPhase = .disconnected } - if failed.reason.name == "AUTHENTICATION" { - accountUser = nil - accountUserID = nil - accountDevices = [] - accountSelectedDeviceID = nil - accountDeviceName = nil - accountDeviceCount = 0 - accountRefreshing = false - remoteConnected = directPairingConnected - } - } else if state is AccountUiStateSignedOut { - accountBusy = false - accountUser = nil - accountUserID = nil - accountDevices = [] - accountSelectedDeviceID = nil - accountDeviceName = nil - accountDeviceCount = 0 - accountRefreshing = false - if !directPairingConnected { - remoteConnected = false - remoteSessionSelected = false - remoteSessions = [] - remoteWorkspaces = [] - workspaceCatalog = [] - } - } - } - private func apply(remoteState state: RemoteSessionUiState) { - guard !localActionPreview, !accountLoginPreview else { return } - guard let ready = state as? RemoteSessionUiStateReady else { - if let failed = state as? RemoteSessionUiStateFailed { - connectionPhase = .disconnected - coreErrorMessage = failed.remoteMessage ?? failed.reason.name - } - return - } - remoteConnected = true - surface = .remote - connectionPhase = .connected - remoteSessions = ready.sessions.map { session in - ChatSession( - id: session.id, - title: session.title.isEmpty ? localized("未命名会话") : session.title, - updatedLabel: session.updatedAt, - status: session.status, - agentType: session.agentType, - workspacePath: session.workspacePath, - workspaceName: session.workspaceName, - createdAt: session.createdAt, - messageCount: Int(session.messageCount), - ) - } - rebuildRemoteWorkspaceGroups() - if let selected = ready.selectedSessionId { - selectedSessionID = selected - } - remoteSessionSelected = ready.selectedSessionId != nil - busy = ready.busy - remoteQuery = ready.query - remoteAgentFilter = ready.agentFilter.name - remoteHasMore = ready.hasMore - remoteHasMoreMessages = ready.hasMoreMessages - remotePermissionMode = ready.permissionMode?.name ?? remotePermissionMode - remotePermissionFailure = ready.permissionModeFailure?.name - activeTurnID = ready.timeline?.activeTurn?.turnId - isSending = ready.timeline?.activeTurn != nil - modelOptions = ready.createModelOptions(fallbackLabel: localized("模型")).map { option in - ComposerModelOption( - id: option.id, - primaryLabel: option.primaryLabel, - secondaryLabel: option.secondaryLabel, - source: "REMOTE", - selected: option.selected - ) - } - if let timeline = ready.timeline { - timelineRows = timeline.conversationRows().map(Self.mapConversationRow) - messages = timelineRows.compactMap { row in - guard row.kind != "EMPTY" else { return nil } - return ChatMessage( - id: UUID(uuidString: row.id) ?? UUID(), - role: row.kind == "USER" ? .user : .assistant, - text: row.text - ) - } - } else { - timelineRows = [] - messages = [] - } - } - private func apply(workspaceState state: RemoteWorkspaceUiState) { - workspaceLoading = state is RemoteWorkspaceUiStateLoading - workspaceLoadFailed = state is RemoteWorkspaceUiStateFailed - if state is RemoteWorkspaceUiStateFailed { - if pendingRemoteWorkspaceCreate != nil || pendingRemoteAssistantCreate { - pendingRemoteWorkspaceCreate = nil - pendingRemoteAssistantCreate = false - showToast(localized("工作区加载失败,点按重试")) - } - return - } - guard let ready = state as? RemoteWorkspaceUiStateReady else { return } - workspaceLoading = false - workspaceLoadFailed = false - selectedRemoteWorkspaceKind = ready.selected?.kind ?? "" - var seen = Set() - var catalog: [(path: String, name: String, selected: Bool)] = [] - if let selected = ready.selected, - !selected.path.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - seen.insert(selected.path) - catalog.append((selected.path, selected.name, true)) - } - for workspace in ready.workspaces where !workspace.path.isEmpty && !seen.contains(workspace.path) { - seen.insert(workspace.path) - catalog.append((workspace.path, workspace.name, false)) - } - workspaceCatalog = catalog - remoteAssistants = ready.assistants.map { - MobileAssistantOption(path: $0.path, name: $0.name) - } - rebuildRemoteWorkspaceGroups() - apply(filePreviewState: ready.preview) - apply(downloadState: ready.download) - if let pending = pendingRemoteWorkspaceCreate, - ready.selected?.path == pending.path { - pendingRemoteWorkspaceCreate = nil - createRemoteSession(agentType: pending.agentType, title: "", instruction: "") - } - if pendingRemoteAssistantCreate, - ready.selected?.kind.lowercased() == "assistant" { - pendingRemoteAssistantCreate = false - createRemoteSession(agentType: "Claw", title: "", instruction: "") - } - } - private func apply(downloadState state: RemoteFileDownloadUiState) { - if state is RemoteFileDownloadUiStateNone { return } - if let loading = state as? RemoteFileDownloadUiStateLoading { - downloadTargetPath = loading.target.remotePath - downloadPhase = .downloading - if loading.totalBytes > 0 { - downloadStatusText = localizedFormat( - "正在下载 %@ / %@", - FilePreviewFormat.shared.bytes(value: loading.downloadedBytes), - FilePreviewFormat.shared.bytes(value: loading.totalBytes) - ) - } else { - downloadStatusText = localized("正在下载") - } - } else if let awaiting = state as? RemoteFileDownloadUiStateAwaitingSave { - let reference = awaiting.target.path - downloadTargetPath = awaiting.target.remotePath - downloadPhase = .saving - downloadStatusText = localized("正在保存") - if pendingDownload?.reference != reference { - pendingDownload = MobilePendingDownload( - reference: reference, - remotePath: awaiting.target.remotePath, - name: awaiting.name, - mimeType: awaiting.mimeType, - data: Self.data(from: awaiting.bytes) - ) - downloadExporterOpen = true - } - } else if let saved = state as? RemoteFileDownloadUiStateSaved { - downloadTargetPath = saved.target.remotePath - downloadPhase = .saved - downloadStatusText = localized("已下载") - } else if let failed = state as? RemoteFileDownloadUiStateFailed { - downloadTargetPath = failed.target.remotePath - downloadPhase = .failed - downloadStatusText = localized("下载失败") - } - } - private func apply(filePreviewState state: RemoteFilePreviewUiState) { - if let loading = state as? RemoteFilePreviewUiStateLoading { - filePreviewLoading = true - filePreview = MobileFilePreview( - id: loading.target.remotePath, - name: loading.target.displayName, - content: "", - mimeType: "", - imageData: nil, - truncated: false, - failure: nil - ) - return - } - filePreviewLoading = false - if state is RemoteFilePreviewUiStateNone { - filePreview = nil - } else if let text = state as? RemoteFilePreviewUiStateText { - filePreview = MobileFilePreview( - id: text.target.remotePath, - name: text.name, - content: text.content, - mimeType: text.mimeType, - imageData: nil, - truncated: text.truncated, - failure: nil - ) - } else if let image = state as? RemoteFilePreviewUiStateImage { - filePreview = MobileFilePreview( - id: image.target.remotePath, - name: image.name, - content: "", - mimeType: image.mimeType, - imageData: Self.data(from: image.bytes), - truncated: false, - failure: nil - ) - } else if let unsupported = state as? RemoteFilePreviewUiStateUnsupported { - filePreview = MobileFilePreview( - id: unsupported.target.remotePath, - name: unsupported.target.displayName, - content: "", - mimeType: unsupported.mimeType, - imageData: nil, - truncated: false, - failure: localized("此文件类型暂不支持预览") - ) - } else if let failed = state as? RemoteFilePreviewUiStateFailed { - filePreview = MobileFilePreview( - id: failed.target.remotePath, - name: failed.target.displayName, - content: "", - mimeType: failed.mimeType, - imageData: nil, - truncated: false, - failure: localizedFormat("文件预览失败:%@", failed.kind.name) - ) - } - } - private func rebuildRemoteWorkspaceGroups() { - let selectedPath = workspaceCatalog.first(where: { $0.selected })?.path - remoteWorkspaces = workspaceCatalog.map { workspace in - MobileWorkspaceGroup( - path: workspace.path, - name: workspace.name.isEmpty ? workspace.path : workspace.name, - selected: workspace.selected, - sessions: remoteSessions.filter { session in - (session.workspacePath ?? selectedPath) == workspace.path - } - ) - } - } - private static func simpleTimelineRow(_ message: ChatMessage) -> MobileConversationRow { - simpleTimelineRow(message, images: []) - } - private static func simpleTimelineRow( - _ message: ChatMessage, - images: [ComposerAttachment] - ) -> MobileConversationRow { - MobileConversationRow( - id: message.id.uuidString, - kind: message.role == .user ? "USER" : "ASSISTANT", - text: message.text, - thinking: nil, - images: images.map { - MobileTimelineImage(name: "image", dataURL: $0.dataURL) - }, - tools: [], - blocks: [], - streaming: false, - typing: false, - pending: false, - showRetry: false - ) - } - private static func mapConversationRow(_ row: ConversationRow) -> MobileConversationRow { - MobileConversationRow( - id: row.id, - kind: row.kind.name, - text: row.text, - thinking: row.thinking, - images: row.images.map { - MobileTimelineImage(name: $0.name, dataURL: $0.dataUrl) - }, - tools: row.tools.map(mapTool), - blocks: row.blocks.map(mapBlock), - streaming: row.streaming, - typing: row.typing, - pending: row.pending, - showRetry: row.showRetry - ) - } - private static func mapTool(_ tool: ToolCard) -> MobileTimelineTool { - MobileTimelineTool( - id: tool.id, - name: tool.name, - phase: tool.phase.name, - kind: tool.kind.name, - operation: tool.operation.name, - target: tool.target, - filePath: tool.filePath, - fileLabel: tool.fileLabel, - input: tool.input, - output: tool.output, - question: tool.question, - questions: tool.questions.map { question in - MobileTimelineQuestion( - index: Int(question.index), - header: question.header, - question: question.question, - options: question.options.map { - MobileTimelineOption(label: $0.label, description: $0.description_) - }, - multiSelect: question.multiSelect - ) - }, - actions: Set(tool.actions.map(\.name)) - ) - } - - private static func mapBlock(_ block: MessageBlock) -> MobileTimelineBlock { - if let text = block as? MessageBlockText { - return .text(id: text.id, text: text.text, streaming: text.streaming) - } - if let thinking = block as? MessageBlockThinking { - return .thinking(id: thinking.id, text: thinking.text, streaming: thinking.streaming) - } - if let tools = block as? MessageBlockTools { - return .tools(id: tools.id, tools: tools.tools.map(mapTool)) - } - if let subagent = block as? MessageBlockSubagent { - return .subagent( - id: subagent.id, - title: subagent.title, - running: subagent.running, - text: subagent.text, - children: subagent.children.map(mapBlock) - ) - } - return .text(id: block.id, text: "", streaming: false) - } - - private static func data(from bytes: KotlinByteArray) -> Data { - Data((0.. String { if let remote = failure.remoteMessage?.trimmingCharacters(in: .whitespacesAndNewlines), !remote.isEmpty { @@ -1704,147 +1191,8 @@ final class MobileAppModel: ObservableObject { } } - private func accountErrorMessage(_ reason: String) -> String { - switch reason { - case "INVALID_CREDENTIALS", "UNAUTHORIZED": - return localized("账号或密码错误") - case "NETWORK": - return localized("网络不可用,请检查 relay 地址") - case "TIMEOUT": - return localized("登录超时,请稍后重试") - default: - return localized("登录失败,请检查账号、密码和 relay 地址") - } - } } -extension MobileAppModel { - var sessionListWorkspaceOptions: [MobileSessionWorkspaceOption] { - SessionListPresentation.shared - .workspaceOptions(sessions: sessionListCoreSessions, workspace: sessionListWorkspaceContext) - .map { MobileSessionWorkspaceOption(path: $0.path, name: $0.name) } - } - - var sessionListAgentGroups: [String] { - SessionListPresentation.shared - .agentGroups(sessions: sessionListCoreSessions, workspace: sessionListWorkspaceContext) - .map(\.name) - } - - var sessionListStatusOptions: [String] { - SessionListPresentation.shared.statusOptions(sessions: sessionListCoreSessions) - } - - var sessionListSections: [MobileSessionListSectionProjection] { - let groupMode: SessionGroupMode = switch remoteGroupMode { - case "TIME": .time - case "CHAT": .chat - default: .project - } - let agentFilter: SessionAgentGroup? = switch remoteViewAgentFilter { - case "CHAT": .chat - case "CODE": .code - case "COWORK": .cowork - default: nil - } - let view = SessionListPresentation.shared.view( - sessions: sessionListCoreSessions, - workspace: sessionListWorkspaceContext, - options: SessionListOptions( - groupMode: groupMode, - query: "", - workspaceFilter: remoteWorkspaceFilter, - agentFilter: agentFilter, - statusFilter: remoteStatusFilter - ), - nowMs: Int64(Date().timeIntervalSince1970 * 1_000) - ) - let byID = Dictionary(uniqueKeysWithValues: remoteSessions.map { ($0.id, $0) }) - return view.sections.compactMap { section in - switch onEnum(of: section) { - case .chat(let value): - return projection(id: "chat", kind: .chat, section: value, byID: byID) - case .project(let value): - return MobileSessionListSectionProjection( - id: "project:\(value.path)", - kind: .project, - path: value.path, - name: value.name, - sessions: value.sessions.compactMap { byID[$0.id] } - ) - case .today(let value): - return projection(id: "today", kind: .today, section: value, byID: byID) - case .yesterday(let value): - return projection(id: "yesterday", kind: .yesterday, section: value, byID: byID) - case .earlier(let value): - return projection(id: "earlier", kind: .earlier, section: value, byID: byID) - } - } - } - - private var sessionListCoreSessions: [RemoteSession] { - remoteSessions.map { session in - RemoteSession( - id: session.id, - title: session.title, - agentType: session.agentType, - status: session.status, - updatedAt: session.updatedLabel, - createdAt: session.createdAt, - messageCount: Int32(session.messageCount), - workspacePath: session.workspacePath, - workspaceName: session.workspaceName - ) - } - } - - private var sessionListWorkspaceContext: SessionWorkspaceContext { - let assistantPaths = Set(remoteAssistants.map { normalizedSessionWorkspacePath($0.path) }) - let selected = remoteWorkspaces.first(where: \.selected) - let recent = remoteWorkspaces.map { workspace in - RecentWorkspace( - path: workspace.path, - name: workspace.name, - lastOpened: "", - kind: assistantPaths.contains(normalizedSessionWorkspacePath(workspace.path)) - ? "assistant" - : "normal" - ) - } - let selectedKind = selected.map { - assistantPaths.contains(normalizedSessionWorkspacePath($0.path)) ? "assistant" : "normal" - } ?? "" - return SessionWorkspaceContext( - selectedPath: selected?.path ?? "", - selectedName: selected?.name ?? "", - selectedKind: selectedKind, - recent: recent - ) - } - - private func projection( - id: String, - kind: MobileSessionListSectionKind, - section: any SessionListSection, - byID: [String: ChatSession] - ) -> MobileSessionListSectionProjection { - MobileSessionListSectionProjection( - id: id, - kind: kind, - path: "", - name: "", - sessions: section.sessions.compactMap { byID[$0.id] } - ) - } - - private func normalizedSessionWorkspacePath(_ path: String) -> String { - var result = path.trimmingCharacters(in: .whitespacesAndNewlines) - while result.count > 1 && (result.hasSuffix("/") || result.hasSuffix("\\")) { - result.removeLast() - } - return result - } -} private extension Array where Element == String { func value(after flag: String) -> String? { diff --git a/src/apps/mobile/ios/BitFun/Infrastructure/MobileCoreAdapter.swift b/src/apps/mobile/ios/BitFun/Infrastructure/MobileCoreAdapter.swift index 4dab9fdf95..f2c4bc317c 100644 --- a/src/apps/mobile/ios/BitFun/Infrastructure/MobileCoreAdapter.swift +++ b/src/apps/mobile/ios/BitFun/Infrastructure/MobileCoreAdapter.swift @@ -1,32 +1,66 @@ import BitFunMobileCore import Foundation +import OSLog /// Swift owns presentation state; this adapter owns the shared KMP feature seam. @MainActor final class MobileCoreAdapter { + private let log = Logger(subsystem: "com.bitfun.mobile.ios", category: "remote-create") + private let accountLoginLog = Logger(subsystem: "com.bitfun.mobile.ios", category: "account-login") let deviceID: String private let scope: any CoroutineScope private let generalChat: GeneralChatStore private let pairing: PairingStore private let account: AccountStore + private let deviceDirectory: DeviceDirectoryStore private var remoteSession: RemoteSessionStore? private var remoteWorkspace: RemoteWorkspaceStore? private var remoteTargetKey: String? + private var remoteTargetEpoch: UInt64 = 0 + private var desiredRemoteTarget: DesiredRemoteTarget? + private var initialRemoteTargetSelectionOpen = true + private var directoryGeneration: UInt64 = 0 + private var accountGeneration: UInt64 = 0 + private var pairingGeneration: UInt64 = 0 private var observations: [Task] = [] + private var accountObservation: Task? + private var directoryObservation: Task? + private var pairingObservation: Task? private var remoteObservations: [Task] = [] + private var pendingDirectoryReconciles: [String: PendingDirectoryReconcile] = [:] + + private struct PendingDirectoryReconcile { + let targetKey: String + let remoteTargetEpoch: UInt64 + let key: DeviceDirectoryReconcileKey + } + + private enum DesiredRemoteTarget: Equatable { + case pairing + case account(deviceID: String) + case accountRestore + } var onState: ((GeneralChatUiState) -> Void)? - var onPairingState: ((PairingUiState) -> Void)? - var onAccountState: ((AccountUiState) -> Void)? - var onRemoteState: ((RemoteSessionUiState) -> Void)? - var onWorkspaceState: ((RemoteWorkspaceUiState) -> Void)? + var onPairingState: ((PairingUiState, UInt64) -> Void)? + var onAccountState: ((AccountUiState, UInt64) -> Void)? + var onRemoteTargetBound: ((String, UInt64, UInt64) -> Void)? + var onRemoteState: ((RemoteSessionUiState, String, UInt64) -> Void)? + var onWorkspaceState: ((RemoteWorkspaceUiState, String, UInt64) -> Void)? + var onDirectoryState: ((DeviceDirectoryUiState, UInt64) -> Void)? + var onCreateOperation: ((CreateSessionOperationState, String) -> Void)? + var onCreateUnavailable: ((String, String?) -> Void)? init( onState: ((GeneralChatUiState) -> Void)? = nil, - onPairingState: ((PairingUiState) -> Void)? = nil, - onAccountState: ((AccountUiState) -> Void)? = nil, - onRemoteState: ((RemoteSessionUiState) -> Void)? = nil, - onWorkspaceState: ((RemoteWorkspaceUiState) -> Void)? = nil, + onPairingState: ((PairingUiState, UInt64) -> Void)? = nil, + onAccountState: ((AccountUiState, UInt64) -> Void)? = nil, + onRemoteTargetBound: ((String, UInt64, UInt64) -> Void)? = nil, + onRemoteState: ((RemoteSessionUiState, String, UInt64) -> Void)? = nil, + onWorkspaceState: ((RemoteWorkspaceUiState, String, UInt64) -> Void)? = nil, + onDirectoryState: ((DeviceDirectoryUiState, UInt64) -> Void)? = nil, + onCreateOperation: ((CreateSessionOperationState, String) -> Void)? = nil, + onCreateUnavailable: ((String, String?) -> Void)? = nil, ) { self.scope = MainScope() self.generalChat = GeneralChatStore.companion.create(scope: scope) @@ -51,11 +85,16 @@ final class MobileCoreAdapter { deviceName: "BitFun iPhone", log: CoreLogNone.shared, ) + self.deviceDirectory = DeviceDirectoryStore.companion.create(scope: scope, accountStore: account) self.onState = onState self.onPairingState = onPairingState self.onAccountState = onAccountState + self.onRemoteTargetBound = onRemoteTargetBound self.onRemoteState = onRemoteState self.onWorkspaceState = onWorkspaceState + self.onDirectoryState = onDirectoryState + self.onCreateOperation = onCreateOperation + self.onCreateUnavailable = onCreateUnavailable let flow = SkieSwiftStateFlow(generalChat.state) onState?(flow.value) @@ -66,29 +105,11 @@ final class MobileCoreAdapter { } }) - let accountFlow = SkieSwiftStateFlow(account.state) - onAccountState?(accountFlow.value) - observations.append(Task { [weak self] in - for await state in accountFlow { - guard !Task.isCancelled else { return } - self?.onAccountState?(state) - if let ready = state as? AccountUiStateReady { - self?.startAccountRemoteSessionIfNeeded(ready: ready) - } - } - }) + rebindDirectoryObservation(generation: directoryGeneration) - let pairingFlow = SkieSwiftStateFlow(pairing.state) - onPairingState?(pairingFlow.value) - observations.append(Task { [weak self] in - for await state in pairingFlow { - guard !Task.isCancelled else { return } - self?.onPairingState?(state) - if let paired = state as? PairingUiStatePaired { - self?.startRemoteSessionStoreIfNeeded(paired: paired) - } - } - }) + rebindAccountObservation() + + rebindPairingObserver(capturedGeneration: pairingGeneration) account.dispatch(intent: AccountIntentRestore.shared) pairing.dispatch(intent: PairingIntentForeground.shared) @@ -169,11 +190,63 @@ final class MobileCoreAdapter { generalChat.dispatch(intent: GeneralChatIntentNewSession.shared) } + private func rebindAccountObservation(emitCurrent: Bool = true) { + accountObservation?.cancel() + let flow = SkieSwiftStateFlow(account.state) + let generation = accountGeneration + if emitCurrent { + logAccountFailure(flow.value, generation: generation) + onAccountState?(flow.value, generation) + if let ready = flow.value as? AccountUiStateReady { + startAccountRemoteSessionIfNeeded(ready: ready, generation: generation) + } + } + accountObservation = Task { [weak self] in + for await state in flow { + guard !Task.isCancelled else { return } + self?.logAccountFailure(state, generation: generation) + self?.onAccountState?(state, generation) + if let ready = state as? AccountUiStateReady { + self?.startAccountRemoteSessionIfNeeded(ready: ready, generation: generation) + } + } + } + } + + private func logAccountFailure(_ state: AccountUiState, generation: UInt64) { + guard let failed = state as? AccountUiStateFailed else { return } + accountLoginLog.error( + "Account login failed reason=\(failed.reason.name, privacy: .public) stage=\(failed.stage.name, privacy: .public) target_generation=\(generation, privacy: .public)" + ) + } + + private func rebindPairingObserver(capturedGeneration: UInt64) { + pairingObservation?.cancel() + let flow = SkieSwiftStateFlow(pairing.state) + let generation = capturedGeneration + onPairingState?(flow.value, generation) + if let paired = flow.value as? PairingUiStatePaired { + startRemoteSessionStoreIfNeeded(paired: paired, generation: generation) + } + pairingObservation = Task { [weak self] in + for await state in flow { + guard !Task.isCancelled else { return } + self?.onPairingState?(state, generation) + if let paired = state as? PairingUiStatePaired { + self?.startRemoteSessionStoreIfNeeded(paired: paired, generation: generation) + } + } + } + } + func submitPairing(url: String) { + preparePairingSubmission() pairing.dispatch(intent: PairingIntentSubmit(pairingUrl: url)) + rebindPairingObserver(capturedGeneration: pairingGeneration) } func submitPairing(url: String, userID: String, password: String) { + preparePairingSubmission() pairing.dispatch( intent: PairingIntentSubmit( pairingUrl: url, @@ -181,6 +254,19 @@ final class MobileCoreAdapter { password: password ) ) + rebindPairingObserver(capturedGeneration: pairingGeneration) + } + + private func preparePairingSubmission() { + desiredRemoteTarget = .pairing + initialRemoteTargetSelectionOpen = false + pairingGeneration &+= 1 + pairingObservation?.cancel() + pairingObservation = nil + if remoteTargetKey == "pairing" { + resetRemoteStores() + } + pairing.dispatch(intent: PairingIntentDisconnect.shared) } func dismissPairingFailure() { @@ -199,24 +285,99 @@ final class MobileCoreAdapter { pairing.dispatch(intent: PairingIntentVerify.shared) } + func beginAccountOperation() -> (accountGeneration: UInt64, remoteTargetEpoch: UInt64, preservePairing: Bool) { + accountGeneration &+= 1 + let preservePairing = remoteTargetKey == "pairing" + if preservePairing { + desiredRemoteTarget = .pairing + } else { + desiredRemoteTarget = nil + initialRemoteTargetSelectionOpen = false + resetRemoteStores() + remoteTargetEpoch &+= 1 + } + rebindAccountObservation(emitCurrent: false) + return (accountGeneration, remoteTargetEpoch, preservePairing) + } + func loginAccount(relayURL: String, username: String, password: String) { + if remoteTargetKey != "pairing" { + desiredRemoteTarget = .accountRestore + } + initialRemoteTargetSelectionOpen = false account.dispatch(intent: AccountIntentLogin(relayUrl: relayURL, username: username, password: password)) } func selectAccountDevice(id: String) { + pendingDirectoryReconciles.removeAll() + desiredRemoteTarget = .account(deviceID: id) + initialRemoteTargetSelectionOpen = false account.dispatch(intent: AccountIntentSelectDevice(deviceId: id)) + let state = SkieSwiftStateFlow(account.state).value + guard let ready = state as? AccountUiStateReady, + ready.selectedDeviceId == id else { return } + startAccountRemoteSessionIfNeeded(ready: ready, generation: accountGeneration) + } + + private func rebindDirectoryObservation(generation: UInt64) { + directoryObservation?.cancel() + let flow = SkieSwiftStateFlow(deviceDirectory.state) + let capturedGeneration = generation + onDirectoryState?(flow.value, capturedGeneration) + directoryObservation = Task { [weak self] in + for await state in flow { + guard !Task.isCancelled else { return } + self?.onDirectoryState?(state, capturedGeneration) + } + } + } + + @discardableResult + func syncDeviceDirectory(_ devices: [MobileAccountDevice]) -> UInt64 { + directoryGeneration &+= 1 + let generation = directoryGeneration + deviceDirectory.dispatch(intent: DeviceDirectoryIntentSync(devices: devices.map { + DeviceDirectoryDevice(deviceId: $0.id, deviceName: $0.name, online: $0.online) + })) + rebindDirectoryObservation(generation: generation) + return generation + } + + func toggleDeviceDirectory(_ deviceID: String, expanded: Bool) { + deviceDirectory.dispatch(intent: expanded + ? DeviceDirectoryIntentExpand(deviceId: deviceID) + : DeviceDirectoryIntentCollapse(deviceId: deviceID)) + } + + func retryDeviceDirectory(_ deviceID: String) { + deviceDirectory.dispatch(intent: DeviceDirectoryIntentRetry(deviceId: deviceID)) } func refreshAccountDevices() { account.dispatch(intent: AccountIntentRefreshDevices.shared) } - func logoutAccount() { - resetRemoteStores() + func retryAccountFailure() { + account.dispatch(intent: AccountIntentRetry.shared) + } + + func logoutAccount(preservePairing: Bool) { + pendingDirectoryReconciles.removeAll() + let keepPairing = preservePairing && remoteTargetKey == "pairing" + if keepPairing { + desiredRemoteTarget = .pairing + } else { + desiredRemoteTarget = nil + initialRemoteTargetSelectionOpen = false + resetRemoteStores() + } + deviceDirectory.dispatch(intent: DeviceDirectoryIntentStop.shared) account.dispatch(intent: AccountIntentLogout.shared) } func disconnect() { + desiredRemoteTarget = nil + initialRemoteTargetSelectionOpen = false pairing.dispatch(intent: PairingIntentDisconnect.shared) resetRemoteStores() } @@ -288,14 +449,23 @@ final class MobileCoreAdapter { } func createRemoteSession( + requestID: String, agentType: String, title: String, instruction: String, modelID: String?, workspacePath: String? = nil ) { - remoteSession?.dispatch( - intent: RemoteSessionIntentCreateSession( + guard let remoteSession else { + log.error("Remote create unavailable target_kind=\(self.remoteTargetKind(self.remoteTargetKey), privacy: .public)") + onCreateUnavailable?(requestID, remoteTargetKey) + return + } + log.info("Dispatching remote create target_kind=\(self.remoteTargetKind(self.remoteTargetKey), privacy: .public)") + prepareDirectoryReconcile(requestID: requestID) + remoteSession.dispatch( + intent: RemoteSessionIntentCreateSessionOperation( + requestId: requestID, agentType: agentType, title: title, instruction: instruction, @@ -305,6 +475,34 @@ final class MobileCoreAdapter { ) } + func createRemoteAssistantSession( + requestID: String, + assistantPath: String, + title: String, + instruction: String, + modelID: String? + ) { + guard let remoteSession else { + log.error("Remote assistant create unavailable reason=remote-session-missing target_kind=\(self.remoteTargetKind(self.remoteTargetKey), privacy: .public)") + onCreateUnavailable?(requestID, remoteTargetKey) + return + } + guard let remoteWorkspace else { + log.error("Remote assistant create unavailable reason=workspace-missing target_kind=\(self.remoteTargetKind(self.remoteTargetKey), privacy: .public)") + onCreateUnavailable?(requestID, remoteTargetKey) + return + } + prepareDirectoryReconcile(requestID: requestID) + remoteSession.createAssistantSession( + workspaceStore: remoteWorkspace, + requestId: requestID, + assistantPath: assistantPath, + title: title, + instruction: instruction, + modelId: modelID + ) + } + func deleteRemoteSession(sessionID: String) { remoteSession?.dispatch(intent: RemoteSessionIntentDeleteSession(sessionId: sessionID)) } @@ -349,14 +547,40 @@ final class MobileCoreAdapter { remoteWorkspace?.dispatch(intent: RemoteWorkspaceIntentLoad.shared) } - func openRemoteFile(reference: String, label: String, sessionID: String) { + var currentRemoteTargetKey: String? { remoteTargetKey } + var currentRemoteTargetEpoch: UInt64 { remoteTargetEpoch } + + @discardableResult + func invalidateRemoteAuthority( + ifTargetKey targetKey: String, + epoch: UInt64 + ) -> RemoteAuthorityInvalidationResult { + guard RemoteAuthorityGate.exactInvalidationMatchesAuthority( + expectedTargetKey: targetKey, + expectedEpoch: epoch, + currentTargetKey: remoteTargetKey, + currentEpoch: remoteTargetEpoch + ) else { + return .notMatched(currentTargetKey: remoteTargetKey, currentEpoch: remoteTargetEpoch) + } + desiredRemoteTarget = nil + initialRemoteTargetSelectionOpen = false + resetRemoteStores() + remoteTargetEpoch &+= 1 + return .invalidated(newEpoch: remoteTargetEpoch) + } + + @discardableResult + func openRemoteFile(reference: String, label: String, sessionID: String, requestID: String) -> String? { remoteWorkspace?.dispatch( intent: RemoteWorkspaceIntentOpenFile( reference: reference, label: label, - sessionId: sessionID + sessionId: sessionID, + requestId: requestID ) ) + return remoteTargetKey } func downloadRemoteFile(reference: String, label: String, sessionID: String) { @@ -385,24 +609,29 @@ final class MobileCoreAdapter { remoteWorkspace?.dispatch(intent: RemoteWorkspaceIntentDismissPreview.shared) } - private func startRemoteSessionStoreIfNeeded(paired: PairingUiStatePaired) { - guard remoteTargetKey != "pairing", + private func startRemoteSessionStoreIfNeeded(paired: PairingUiStatePaired, generation: UInt64) { + let targetKey = "pairing" + guard generation == pairingGeneration, + remoteTargetIsDesired(.pairing), + remoteTargetKey != targetKey, let sessionStore = pairing.createSessionStore(scope: scope) else { return } + commitInitialRemoteTargetIfNeeded(.pairing) bindRemoteStores( - targetKey: "pairing", + targetKey: targetKey, sessionStore: sessionStore, workspaceStore: pairing.createWorkspaceStore(scope: scope) ) } - private func startAccountRemoteSessionIfNeeded(ready: AccountUiStateReady) { - guard let deviceID = ready.selectedDeviceId else { - resetRemoteStores() - return - } + private func startAccountRemoteSessionIfNeeded(ready: AccountUiStateReady, generation: UInt64) { + guard generation == accountGeneration, + let deviceID = ready.selectedDeviceId else { return } + let desiredTarget = DesiredRemoteTarget.account(deviceID: deviceID) let targetKey = "account:\(deviceID)" - guard remoteTargetKey != targetKey, + guard remoteTargetIsDesired(desiredTarget), + remoteTargetKey != targetKey, let sessionStore = account.createSessionStore(scope: scope) else { return } + commitInitialRemoteTargetIfNeeded(desiredTarget) bindRemoteStores( targetKey: targetKey, sessionStore: sessionStore, @@ -410,40 +639,130 @@ final class MobileCoreAdapter { ) } + private func remoteTargetIsDesired(_ candidate: DesiredRemoteTarget) -> Bool { + switch desiredRemoteTarget { + case .pairing: + return candidate == .pairing + case let .account(deviceID): + return candidate == .account(deviceID: deviceID) + case .accountRestore: + if case .account = candidate { return true } + return false + case nil: + return initialRemoteTargetSelectionOpen && remoteTargetKey == nil + } + } + + private func commitInitialRemoteTargetIfNeeded(_ target: DesiredRemoteTarget) { + if desiredRemoteTarget == nil || desiredRemoteTarget == .accountRestore { + desiredRemoteTarget = target + } + initialRemoteTargetSelectionOpen = false + } + + private func prepareDirectoryReconcile(requestID: String) { + pendingDirectoryReconciles.removeValue(forKey: requestID) + guard let targetKey = remoteTargetKey else { return } + + if targetKey == "pairing" { + return + } + let prefix = "account:" + guard targetKey.hasPrefix(prefix) else { return } + let deviceID = String(targetKey.dropFirst(prefix.count)) + guard !deviceID.isEmpty, + let key = deviceDirectory.reconcileKey(deviceId: deviceID) else { return } + pendingDirectoryReconciles[requestID] = PendingDirectoryReconcile( + targetKey: targetKey, + remoteTargetEpoch: remoteTargetEpoch, + key: key + ) + } + + private func remoteTargetKind(_ targetKey: String?) -> String { + guard let targetKey else { return "none" } + if targetKey == "pairing" { return "pairing" } + if targetKey.hasPrefix("account:") { return "account" } + return "other" + } + + private func handleCreateOperation( + _ state: CreateSessionOperationState, + targetKey: String, + epoch: UInt64 + ) { + log.info("Remote create state=\(String(describing: type(of: state)), privacy: .public) target_kind=\(self.remoteTargetKind(targetKey), privacy: .public)") + switch state { + case let succeeded as CreateSessionOperationStateSucceeded: + if let pending = pendingDirectoryReconciles.removeValue(forKey: succeeded.requestId), + pending.targetKey == targetKey, + pending.remoteTargetEpoch == epoch, + remoteTargetKey == targetKey, + remoteTargetEpoch == epoch, + let confirmedSession = succeeded.confirmedSession { + _ = deviceDirectory.reconcileCreatedSession(key: pending.key, session: confirmedSession) + } + case let failed as CreateSessionOperationStateFailed: + pendingDirectoryReconciles.removeValue(forKey: failed.requestId) + case let cancelled as CreateSessionOperationStateCancelled: + pendingDirectoryReconciles.removeValue(forKey: cancelled.requestId) + case is CreateSessionOperationStateIdle: + pendingDirectoryReconciles = pendingDirectoryReconciles.filter { + $0.value.targetKey != targetKey || $0.value.remoteTargetEpoch != epoch + } + default: + break + } + onCreateOperation?(state, targetKey) + } + private func bindRemoteStores( targetKey: String, sessionStore: RemoteSessionStore, workspaceStore: RemoteWorkspaceStore? ) { resetRemoteStores() + remoteTargetEpoch &+= 1 + let boundEpoch = remoteTargetEpoch remoteTargetKey = targetKey remoteSession = sessionStore remoteWorkspace = workspaceStore + onRemoteTargetBound?(targetKey, boundEpoch, accountGeneration) let sessionFlow = SkieSwiftStateFlow(sessionStore.state) - onRemoteState?(sessionFlow.value) + onRemoteState?(sessionFlow.value, targetKey, boundEpoch) sessionStore.dispatch(intent: RemoteSessionIntentLoad.shared) remoteObservations.append(Task { [weak self] in for await state in sessionFlow { guard !Task.isCancelled else { return } - self?.onRemoteState?(state) + self?.onRemoteState?(state, targetKey, boundEpoch) + } + }) + + let createFlow = SkieSwiftStateFlow(sessionStore.createOperation) + handleCreateOperation(createFlow.value, targetKey: targetKey, epoch: boundEpoch) + remoteObservations.append(Task { [weak self] in + for await state in createFlow { + guard !Task.isCancelled else { return } + self?.handleCreateOperation(state, targetKey: targetKey, epoch: boundEpoch) } }) if let workspaceStore { let workspaceFlow = SkieSwiftStateFlow(workspaceStore.state) - onWorkspaceState?(workspaceFlow.value) + onWorkspaceState?(workspaceFlow.value, targetKey, boundEpoch) workspaceStore.dispatch(intent: RemoteWorkspaceIntentLoad.shared) remoteObservations.append(Task { [weak self] in for await state in workspaceFlow { guard !Task.isCancelled else { return } - self?.onWorkspaceState?(state) + self?.onWorkspaceState?(state, targetKey, boundEpoch) } }) } } private func resetRemoteStores() { + pendingDirectoryReconciles.removeAll() remoteObservations.forEach { $0.cancel() } remoteObservations.removeAll() remoteSession?.dispatch(intent: RemoteSessionIntentStop.shared) @@ -454,9 +773,18 @@ final class MobileCoreAdapter { } func stop() { + desiredRemoteTarget = nil + initialRemoteTargetSelectionOpen = false + accountObservation?.cancel() + accountObservation = nil observations.forEach { $0.cancel() } observations.removeAll() + directoryObservation?.cancel() + pairingObservation?.cancel() + pairingObservation = nil + directoryObservation = nil resetRemoteStores() + deviceDirectory.dispatch(intent: DeviceDirectoryIntentStop.shared) pairing.dispatch(intent: PairingIntentDisconnect.shared) account.stop() generalChat.stop() diff --git a/src/apps/mobile/ios/BitFun/Infrastructure/RemoteAuthorityGate.swift b/src/apps/mobile/ios/BitFun/Infrastructure/RemoteAuthorityGate.swift new file mode 100644 index 0000000000..05c2d2f2be --- /dev/null +++ b/src/apps/mobile/ios/BitFun/Infrastructure/RemoteAuthorityGate.swift @@ -0,0 +1,213 @@ +import Foundation + +struct RemoteAuthorityScope: Equatable { + let targetKey: String + let epoch: UInt64 + let revision: Int64 +} + +struct RemoteCommittedProjectionDecision: Equatable { + let retainMarker: Bool + let protectCommittedRowAndSelection: Bool +} + +struct PairingAttemptProjectionTransition: Equatable { + let clearBoundRemoteProjection: Bool + let remoteConnected: Bool +} + +struct RemoteTargetProjectionState: Equatable { + var hasSessionRows: Bool + var hasWorkspaceRows: Bool + var hasSelection: Bool + var hasTimeline: Bool + var hasActiveTurn: Bool + var hasPendingNavigation: Bool + var hasReadyAuthority: Bool + var hasCreateState: Bool + + static let cleared = RemoteTargetProjectionState( + hasSessionRows: false, + hasWorkspaceRows: false, + hasSelection: false, + hasTimeline: false, + hasActiveTurn: false, + hasPendingNavigation: false, + hasReadyAuthority: false, + hasCreateState: false + ) +} + +struct RemoteTargetBoundTransition: Equatable { + let scopeChanged: Bool + let projection: RemoteTargetProjectionState +} + +struct RetainedAccountAuthority: Equatable { + let targetKey: String + let epoch: UInt64 +} + +enum RemoteAuthorityInvalidationResult: Equatable { + case invalidated(newEpoch: UInt64) + case notMatched(currentTargetKey: String?, currentEpoch: UInt64) +} + +enum RemoteAuthorityGate { + static func targetBoundTransition( + currentTargetKey: String?, + currentEpoch: UInt64?, + boundTargetKey: String, + boundEpoch: UInt64, + projection: RemoteTargetProjectionState + ) -> RemoteTargetBoundTransition { + let changed = currentTargetKey != boundTargetKey || currentEpoch != boundEpoch + return RemoteTargetBoundTransition( + scopeChanged: changed, + projection: changed ? .cleared : projection + ) + } + + static func callbackMatchesAuthority( + targetKey: String, + epoch: UInt64, + expectedTargetKey: String?, + expectedEpoch: UInt64 + ) -> Bool { + targetKey == expectedTargetKey && epoch == expectedEpoch + } + + static func fileTransferCallbackMatchesAuthority( + requestTargetKey: String?, + requestEpoch: UInt64?, + adapterTargetKey: String?, + adapterEpoch: UInt64 + ) -> Bool { + guard let requestTargetKey, let requestEpoch else { return false } + return requestTargetKey == adapterTargetKey && requestEpoch == adapterEpoch + } + + static func exactInvalidationMatchesAuthority( + expectedTargetKey: String, + expectedEpoch: UInt64, + currentTargetKey: String?, + currentEpoch: UInt64 + ) -> Bool { + expectedTargetKey == currentTargetKey && expectedEpoch == currentEpoch + } + + static func shouldRetainAccountAfterPairingFailure( + captured: RetainedAccountAuthority?, + adapterTargetKey: String?, + adapterEpoch: UInt64, + modelTargetKey: String?, + modelEpoch: UInt64, + healthyConnected: Bool + ) -> Bool { + guard let captured, captured.targetKey.hasPrefix("account:") else { return false } + return healthyConnected && + adapterTargetKey == captured.targetKey && adapterEpoch == captured.epoch && + modelTargetKey == captured.targetKey && modelEpoch == captured.epoch + } + + static func pairingAttemptTransition( + authoritativeTargetKey: String?, + remoteConnected: Bool + ) -> PairingAttemptProjectionTransition { + let replacesPairing = authoritativeTargetKey == "pairing" + return PairingAttemptProjectionTransition( + clearBoundRemoteProjection: replacesPairing, + remoteConnected: replacesPairing ? false : remoteConnected + ) + } + + static func acceptsReady( + targetKey: String, + epoch: UInt64, + revision: Int64, + lastApplied: RemoteAuthorityScope? + ) -> Bool { + guard let lastApplied, + lastApplied.targetKey == targetKey, + lastApplied.epoch == epoch, + lastApplied.revision > 0 else { + return true + } + return revision > 0 && revision >= lastApplied.revision + } + + static func updatedScope( + targetKey: String, + epoch: UInt64, + revision: Int64, + lastApplied: RemoteAuthorityScope? + ) -> RemoteAuthorityScope? { + guard revision > 0 else { + if let lastApplied, + lastApplied.targetKey == targetKey, + lastApplied.epoch == epoch { + return lastApplied + } + return nil + } + return RemoteAuthorityScope(targetKey: targetKey, epoch: epoch, revision: revision) + } + + static func succeededIsAlreadyAuthoritative( + targetKey: String, + epoch: UInt64, + commitRevision: Int64, + confirmedSessionVisible: Bool, + lastApplied: RemoteAuthorityScope? + ) -> Bool { + guard confirmedSessionVisible, + commitRevision > 0, + let lastApplied, + lastApplied.targetKey == targetKey, + lastApplied.epoch == epoch else { + return false + } + return lastApplied.revision >= commitRevision + } + + static func readyIncludesCommit( + readyRevision: Int64, + minimumAuthorityRevision: Int64, + confirmedSessionVisible: Bool + ) -> Bool { + if minimumAuthorityRevision > 0 { + return readyRevision >= minimumAuthorityRevision && confirmedSessionVisible + } + return confirmedSessionVisible + } + + static func committedProjectionDecision( + readyTargetKey: String, + readyEpoch: UInt64, + readyRevision: Int64, + committedTargetKey: String?, + committedEpoch: UInt64?, + minimumAuthorityRevision: Int64?, + confirmedSessionVisible: Bool + ) -> RemoteCommittedProjectionDecision { + guard let committedTargetKey, + let committedEpoch, + let minimumAuthorityRevision, + committedTargetKey == readyTargetKey, + committedEpoch == readyEpoch else { + return RemoteCommittedProjectionDecision( + retainMarker: false, + protectCommittedRowAndSelection: false + ) + } + let authoritative = readyIncludesCommit( + readyRevision: readyRevision, + minimumAuthorityRevision: minimumAuthorityRevision, + confirmedSessionVisible: confirmedSessionVisible + ) + return RemoteCommittedProjectionDecision( + retainMarker: !authoritative, + protectCommittedRowAndSelection: !authoritative + ) + } +} diff --git a/src/apps/mobile/ios/BitFun/Resources/Localizable.xcstrings b/src/apps/mobile/ios/BitFun/Resources/Localizable.xcstrings index 47bb52b80f..02e1245258 100644 --- a/src/apps/mobile/ios/BitFun/Resources/Localizable.xcstrings +++ b/src/apps/mobile/ios/BitFun/Resources/Localizable.xcstrings @@ -2,79 +2,254 @@ "sourceLanguage": "zh-Hans", "strings": { "BitFun 桌面版": { - "localizations": { "en": { "stringUnit": { "state": "translated", "value": "BitFun Desktop" } } } + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "BitFun Desktop" + } + } + } }, "不再询问,允许桌面端执行所有操作。": { - "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Allow the desktop to perform all operations without asking." } } } + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Allow the desktop to perform all operations without asking." + } + } + } }, "其他": { - "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Other" } } } + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Other" + } + } + } }, "其他连接方式": { - "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Other ways to connect" } } } + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Other ways to connect" + } + } + } }, "启用完全访问": { - "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Enable full access" } } } + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enable full access" + } + } + } }, "尚未连接桌面端": { - "localizations": { "en": { "stringUnit": { "state": "translated", "value": "No desktop connected yet" } } } + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No desktop connected yet" + } + } + } }, "当前远程控制": { - "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Current remote control" } } } + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Current remote control" + } + } + } }, "执行需要授权的操作前先询问。": { - "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Ask before performing an operation that requires approval." } } } + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Ask before performing an operation that requires approval." + } + } + } }, "扫码配对": { - "localizations": { "en": { "stringUnit": { "state": "translated", "value": "QR pairing" } } } + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "QR pairing" + } + } + } }, "扫描二维码连接": { - "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Scan QR code to connect" } } } + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Scan QR code to connect" + } + } + } }, "控制桌面端执行工具时采用的确认方式。": { - "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Choose how the desktop confirms tool operations." } } } + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Choose how the desktop confirms tool operations." + } + } + } }, "断开": { - "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Disconnect" } } } + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Disconnect" + } + } + } }, "未连接": { - "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Not connected" } } } + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Not connected" + } + } + } }, "正在重新连接": { - "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Reconnecting" } } } + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reconnecting" + } + } + } }, "完全访问会取消所有操作确认。仅在你信任当前桌面端时启用。": { - "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Full access removes all operation confirmations. Enable it only when you trust this desktop." } } } + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Full access removes all operation confirmations. Enable it only when you trust this desktop." + } + } + } }, "确认完全访问": { - "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Confirm full access" } } } + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Confirm full access" + } + } + } }, "自动允许": { - "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Allow automatically" } } } + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Allow automatically" + } + } + } }, "自动允许常规操作,高风险操作仍会询问。": { - "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Allow routine operations automatically and still ask for high-risk operations." } } } + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Allow routine operations automatically and still ask for high-risk operations." + } + } + } }, "账号设备": { - "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Account device" } } } + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Account device" + } + } + } }, "连接已断开": { - "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Connection lost" } } } + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Connection lost" + } + } + } }, "连接来源": { - "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Connection source" } } } + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Connection source" + } + } + } }, "适用于临时配对或未登录账号的桌面端。": { - "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Use this for a temporary pair or a desktop that is not signed in." } } } + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Use this for a temporary pair or a desktop that is not signed in." + } + } + } }, "远程控制": { - "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Remote control" } } } + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Remote control" + } + } + } }, "远程控制设置": { - "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Remote control settings" } } } + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Remote control settings" + } + } + } }, "重新连接": { - "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Reconnect" } } } + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reconnect" + } + } + } }, "会话详情": { "localizations": { @@ -1586,6 +1761,26 @@ } } }, + "Relay 服务暂时不可用,请稍后重试": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The relay service is temporarily unavailable. Try again later." + } + } + } + }, + "Relay 响应异常,请稍后重试或升级应用": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The relay returned an invalid response. Try again later or update the app." + } + } + } + }, "登录失败,请检查账号、密码和 relay 地址": { "localizations": { "en": { @@ -1596,6 +1791,36 @@ } } }, + "登录服务暂时不可用,请稍后重试": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The sign-in service is temporarily unavailable. Try again later." + } + } + } + }, + "登录请求过多,请稍后重试": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Too many sign-in requests. Try again later." + } + } + } + }, + "登录状态无效,请重新输入账号和密码": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Your sign-in is no longer valid. Enter the account and password again." + } + } + } + }, "登录超时,请稍后重试": { "localizations": { "en": { @@ -1676,6 +1901,16 @@ } } }, + "网络连接失败,请检查网络和 relay 地址后重试": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Could not connect. Check the network and relay URL, then try again." + } + } + } + }, "网络不可用,请检查 relay 地址": { "localizations": { "en": { @@ -2438,219 +2673,623 @@ }, "普通对话模型": { "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Chat model" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "Chat model" + } + } } }, "选择账号模型": { "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Choose account model" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "Choose account model" + } + } } }, "本机自定义模型": { "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Local custom model" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "Local custom model" + } + } } }, "当前使用": { "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "In use" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "In use" + } + } } }, "模型来源": { "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Model sources" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "Model sources" + } + } } }, "云端账号模型": { "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Account models" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "Account models" + } + } } }, "暂无可用的账号模型": { "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "No account models available" } } - } - }, + "en": { + "stringUnit": { + "state": "translated", + "value": "No account models available" + } + } + } + }, "已同步 %d 个": { "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "%d synced" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "%d synced" + } + } } }, "本机": { "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "On this device" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "On this device" + } + } } }, "云端账号": { "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Account" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "Account" + } + } } }, "模型名称": { "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Model name" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "Model name" + } + } } }, "例如 chat-model": { "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "e.g. chat-model" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "e.g. chat-model" + } + } } }, "保留已保存的 Key": { "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Keep the saved key" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "Keep the saved key" + } + } } }, "保留或输入 API Key 后可测试连接。": { "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Keep or enter an API key to test the connection." } } + "en": { + "stringUnit": { + "state": "translated", + "value": "Keep or enter an API key to test the connection." + } + } } }, "测试中…": { "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Testing…" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "Testing…" + } + } } }, "从桌面端获取配对码": { - "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Get a pairing code from desktop" } } } + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Get a pairing code from desktop" + } + } + } }, "在桌面端打开 BitFun,然后进入远程连接。": { - "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Open BitFun on desktop, then go to Remote Connect." } } } + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open BitFun on desktop, then go to Remote Connect." + } + } + } }, "点击“添加设备”获取配对二维码。": { - "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Choose Add device to get a pairing QR code." } } } + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Choose Add device to get a pairing QR code." + } + } + } }, "我有配对码": { - "localizations": { "en": { "stringUnit": { "state": "translated", "value": "I have a pairing code" } } } + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "I have a pairing code" + } + } + } }, "手动输入配对码": { - "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Enter pairing code manually" } } } + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enter pairing code manually" + } + } + } }, "输入桌面端显示的配对链接或代码。": { - "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Enter the pairing link or code shown on desktop." } } } + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enter the pairing link or code shown on desktop." + } + } + } }, "配对码或连接链接": { - "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Pairing code or connection link" } } } + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Pairing code or connection link" + } + } + } }, "账号认证配对": { - "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Account-authenticated pairing" } } } + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Account-authenticated pairing" + } + } + } }, "此桌面要求使用 BitFun 账号验证身份。": { - "localizations": { "en": { "stringUnit": { "state": "translated", "value": "This desktop requires your BitFun account to verify your identity." } } } + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This desktop requires your BitFun account to verify your identity." + } + } + } }, "BitFun 用户名": { - "localizations": { "en": { "stringUnit": { "state": "translated", "value": "BitFun username" } } } + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "BitFun username" + } + } + } }, "BitFun 密码": { - "localizations": { "en": { "stringUnit": { "state": "translated", "value": "BitFun password" } } } + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "BitFun password" + } + } + } }, "账号凭据只用于本次加密配对,不会保存。": { - "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Account credentials are used only for this encrypted pairing attempt and are not saved." } } } + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Account credentials are used only for this encrypted pairing attempt and are not saved." + } + } + } }, "配对": { - "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Pair" } } } + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Pair" + } + } + } }, "BitFun 账号": { - "localizations": { "en": { "stringUnit": { "state": "translated", "value": "BitFun account" } } } + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "BitFun account" + } + } + } }, "已登录": { - "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Signed in" } } } + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Signed in" + } + } + } }, "当前以 %@ 登录。": { - "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Currently signed in as %@." } } } + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Currently signed in as %@." + } + } + } }, "设备管理": { - "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Device management" } } } + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Device management" + } + } + } }, "刷新": { - "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Refresh" } } } + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Refresh" + } + } + } }, "暂无可连接的桌面设备": { - "localizations": { "en": { "stringUnit": { "state": "translated", "value": "No desktop devices are available" } } } + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No desktop devices are available" + } + } + } }, "个人资料详情": { - "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Profile details" } } } + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Profile details" + } + } + } }, "用户 ID": { - "localizations": { "en": { "stringUnit": { "state": "translated", "value": "User ID" } } } + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "User ID" + } + } + } }, "设备 ID": { - "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Device ID" } } } + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Device ID" + } + } + } }, "告诉 BitFun 要做什么": { - "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Tell BitFun what to do" } } } + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Tell BitFun what to do" + } + } + } }, "选择桌面设备": { - "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Choose a desktop" } } } + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Choose a desktop" + } + } + } }, "对话": { - "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Chat" } } } + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Chat" + } + } + } }, "暂无可用模型": { - "localizations": { "en": { "stringUnit": { "state": "translated", "value": "No models available" } } } + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No models available" + } + } + } }, "工作区": { - "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Workspace" } } } + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Workspace" + } + } + } }, "视图设置": { - "localizations": { "en": { "stringUnit": { "state": "translated", "value": "View settings" } } } + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "View settings" + } + } + } }, "调整会话列表的分组和信息密度": { - "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Adjust session grouping and information density" } } } + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Adjust session grouping and information density" + } + } + } }, "分组方式": { - "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Grouping" } } } - }, - "按项目": { - "localizations": { "en": { "stringUnit": { "state": "translated", "value": "By project" } } } - }, - "按时间倒序排列": { - "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Newest first" } } } - }, + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Grouping" + } + } + } + }, + "按项目": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "By project" + } + } + } + }, + "按时间倒序排列": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Newest first" + } + } + } + }, "聊天优先": { - "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Chat first" } } } + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Chat first" + } + } + } }, "筛选": { - "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Filters" } } } + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Filters" + } + } + } }, "所有工作区": { - "localizations": { "en": { "stringUnit": { "state": "translated", "value": "All workspaces" } } } + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "All workspaces" + } + } + } }, "Agent 类型": { - "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Agent type" } } } + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Agent type" + } + } + } }, "所有 Agent 类型": { - "localizations": { "en": { "stringUnit": { "state": "translated", "value": "All agent types" } } } + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "All agent types" + } + } + } }, "状态": { - "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Status" } } } + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Status" + } + } + } }, "所有状态": { - "localizations": { "en": { "stringUnit": { "state": "translated", "value": "All statuses" } } } + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "All statuses" + } + } + } }, "显示信息": { - "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Shown details" } } } + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Shown details" + } + } + } }, "更新时间": { - "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Updated" } } } + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Updated" + } + } + } }, "运行中": { - "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Running" } } } + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Running" + } + } + } }, "就绪": { - "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Ready" } } } + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Ready" + } + } + } }, "今天": { - "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Today" } } } + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Today" + } + } + } }, "昨天": { - "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Yesterday" } } } + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Yesterday" + } + } + } }, "更早": { - "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Earlier" } } } + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Earlier" + } + } + } }, "暂无远程会话": { - "localizations": { "en": { "stringUnit": { "state": "translated", "value": "No remote sessions yet" } } } + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No remote sessions yet" + } + } + } }, "问问 BitFun": { "localizations": { @@ -2663,25 +3302,4522 @@ } }, "查看详情": { - "localizations": { "en": { "stringUnit": { "state": "translated", "value": "View details" } } } + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "View details" + } + } + } }, "创建时间": { - "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Created" } } } + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Created" + } + } + } }, "消息数量": { - "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Messages" } } } + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Messages" + } + } + } }, "展开侧栏": { - "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Expand sidebar" } } } + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Expand sidebar" + } + } + } }, "选择连接方式": { - "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Choose how to connect" } } } + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Choose how to connect" + } + } + } }, "扫码连接": { - "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Scan to connect" } } } + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Scan to connect" + } + } + } }, "已连接": { - "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Connected" } } } + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Connected" + } + } + } + }, + "BitFun": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "BitFun" + } + } + } + }, + "当前": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Current" + } + } + } + }, + "已完成": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Done" + } + } + } + }, + "失败": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Failed" + } + } + } + }, + "保留": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Keep" + } + } + } + }, + "加载中...": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Loading..." + } + } + } + }, + "打开": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open" + } + } + } + }, + "等待中": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Waiting" + } + } + } + }, + "今天想做什么?": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "What do you want to do today?" + } + } + } + }, + "这是手机上的对话。要读写电脑上的项目,请切换到远程。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This chat stays on the phone. Switch to Remote to read and write projects on your computer." + } + } + } + }, + "查找资料": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Find sources" + } + } + } + }, + "帮我查找资料并整理重点:": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Help me find sources and summarize the key points:" + } + } + } + }, + "分析图片": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Analyze image" + } + } + } + }, + "请分析这张图片。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Please analyze this image." + } + } + } + }, + "处理文件": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Work with a file" + } + } + } + }, + "帮我总结这个文件的重点。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Help me summarize the key points in this file." + } + } + } + }, + "请帮我起草一段内容:": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Please draft this for me:" + } + } + } + }, + "请帮我梳理这个问题的重点和思路:": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Help me organize the key points and approach for this problem:" + } + } + } + }, + "请帮我制定一份清晰、可执行的行动计划:": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Help me make a clear, actionable plan:" + } + } + } + }, + "这个任务可能需要远程能力": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This task may need Remote" + } + } + } + }, + "连接桌面工作区后,BitFun 才能读取项目文件、运行命令并修改代码。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Connect a desktop workspace before BitFun can read project files, run commands, and change code." + } + } + } + }, + "进入远程": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open Remote" + } + } + } + }, + "BitFun 已就绪": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "BitFun is ready" + } + } + } + }, + "BitFun 正在回复": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "BitFun is answering" + } + } + } + }, + "正在发送消息": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Sending message" + } + } + } + }, + "网络不可用,草稿和历史仍保存在本机。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Network unavailable. Drafts and history stay on this phone." + } + } + } + }, + "已停止生成。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Generation stopped." + } + } + } + }, + "回复已中断,已保留收到的内容。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The reply was interrupted. Received content was kept." + } + } + } + }, + "回复中断": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reply interrupted" + } + } + } + }, + "普通聊天暂不支持下载桌面端文件,请进入远程后处理本地文件。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "General chat cannot download desktop files. Open Remote to work with local files." + } + } + } + }, + "普通聊天暂不支持预览桌面端文件,请进入远程后打开。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "General chat cannot preview desktop files. Open Remote to view them." + } + } + } + }, + "普通对话历史暂时无法恢复,你仍可新建对话。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "General chat history could not be restored. You can still start a new chat." + } + } + } + }, + "请先在设置中配置普通对话模型。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Configure a general chat model in Settings first." + } + } + } + }, + "API Key 无效或无权访问该模型。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The API key is invalid or cannot access this model." + } + } + } + }, + "模型服务请求过于频繁,请稍后重试。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The model service is rate-limited. Try again later." + } + } + } + }, + "模型服务暂时不可用,请稍后重试。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The model service is temporarily unavailable. Try again later." + } + } + } + }, + "模型服务返回了无法识别的响应。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The model service returned an unrecognized response." + } + } + } + }, + "模型没有返回文字内容。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The model returned no text." + } + } + } + }, + "模型请求失败(HTTP %@)。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Model request failed (HTTP %@)." + } + } + } + }, + "账号同步": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Account sync" + } + } + } + }, + "本机自定义": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Custom local" + } + } + } + }, + "已配置": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Configured" + } + } + } + }, + "API URL": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "API URL" + } + } + } + }, + "https://api.example.com": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "https://api.example.com" + } + } + } + }, + "API Key": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "API Key" + } + } + } + }, + "输入 API Key": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enter API Key" + } + } + } + }, + "留空则保留已保存的 Key": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Leave blank to keep the saved key" + } + } + } + }, + "已安全保存": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Saved securely" + } + } + } + }, + "清除已保存的 Key": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Clear saved key" + } + } + } + }, + "连接测试通过。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Connection test passed." + } + } + } + }, + "API URL 必须以 http:// 或 https:// 开头。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "API URL must start with http:// or https://." + } + } + } + }, + "请输入模型名称。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enter a model name." + } + } + } + }, + "请输入 API Key。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enter an API key." + } + } + } + }, + "API Key 加密失败。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Could not encrypt the API key." + } + } + } + }, + "无法访问系统安全存储,请稍后重试。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "System secure storage is unavailable. Try again later." + } + } + } + }, + "库": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Library" + } + } + } + }, + "项目": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Projects" + } + } + } + }, + "已计划": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Scheduled" + } + } + } + }, + "应用": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Apps" + } + } + } + }, + "更多": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "More" + } + } + } + }, + "连接电脑": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Connect a computer" + } + } + } + }, + "在此工作区新建": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "New in this workspace" + } + } + } + }, + "这台电脑还没有工作区": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This computer has no workspaces yet" + } + } + } + }, + "还有 %lld 个工作区": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%lld more workspaces" + } + } + } + }, + "这台电脑暂时无法读取": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This computer cannot be read right now" + } + } + } + }, + "还有 %lld 台设备": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%lld more devices" + } + } + } + }, + "置顶": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Pinned" + } + } + } + }, + "归档": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Archive" + } + } + } + }, + "复制 Markdown": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Copy Markdown" + } + } + } + }, + "开始聊天后会显示在这里。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "They will appear here after you start chatting." + } + } + } + }, + "删除后将无法恢复这条会话。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This session cannot be recovered after deletion." + } + } + } + }, + "新聊天": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "New chat" + } + } + } + }, + "扫码连接电脑": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Scan to connect a computer" + } + } + } + }, + "收起侧边栏": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Collapse sidebar" + } + } + } + }, + "展开侧边栏": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Expand sidebar" + } + } + } + }, + "消息数": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Messages" + } + } + } + }, + "全部工作区": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "All workspaces" + } + } + } + }, + "全部类型": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "All types" + } + } + } + }, + "全部状态": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "All statuses" + } + } + } + }, + "运行状态": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Run status" + } + } + } + }, + "BitFun 远程": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "BitFun Remote" + } + } + } + }, + "本地开发助手": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Local development assistant" + } + } + } + }, + "连接桌面端后,BitFun 可以访问你的代码仓库、运行命令和修改文件。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "After connecting the desktop, BitFun can access your repositories, run commands, and edit files." + } + } + } + }, + "连接本地工作区": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Connect a local workspace" + } + } + } + }, + "扫码或粘贴连接链接": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Scan or paste a connection link" + } + } + } + }, + "连接桌面端后会显示最近打开的代码仓库。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Recently opened repositories appear here after you connect the desktop." + } + } + } + }, + "新建远程任务": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "New remote task" + } + } + } + }, + "切换工作区": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Switch workspace" + } + } + } + }, + "断开连接": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Disconnect" + } + } + } + }, + "最近远程会话": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Recent remote sessions" + } + } + } + }, + "连接本地工作区后,可以新建远程任务处理项目问题。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "After connecting a local workspace, create a remote task to work on the project." + } + } + } + }, + "远程": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Remote" + } + } + } + }, + "搜索聊天记录": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Search chat history" + } + } + } + }, + "还没有远程对话": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No remote chats yet" + } + } + } + }, + "新建聊天后,可以从手机继续处理桌面端任务。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Start a chat to continue desktop tasks from your phone." + } + } + } + }, + "选择一个会话": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Choose a session" + } + } + } + }, + "从侧边栏打开会话,或新建一个。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open a session from the sidebar, or create one." + } + } + } + }, + "新建会话": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "New session" + } + } + } + }, + "远程设置": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Remote settings" + } + } + } + }, + "连接的设备": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Connected device" + } + } + } + }, + "新建任务": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "New task" + } + } + } + }, + "没有可用的在线桌面设备": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No online desktop is available" + } + } + } + }, + "设备列表加载失败,请稍后重试。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Could not load devices. Try again later." + } + } + } + }, + "工作区加载失败,请稍后重试。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Could not load workspaces. Try again later." + } + } + } + }, + "所选设备尚未连接,请重新选择设备后再试。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The selected device is not connected. Choose it again and retry." + } + } + } + }, + "无法创建会话,请检查桌面连接后重试。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Could not create the session. Check the desktop connection and retry." + } + } + } + }, + "助理": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Assistant" + } + } + } + }, + "默认助理": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Default assistant" + } + } + } + }, + "加载更多聊天": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Load more chats" + } + } + } + }, + "整理": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Organize" + } + } + } + }, + "管理": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Manage" + } + } + } + }, + "云端任务": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cloud tasks" + } + } + } + }, + "刷新连接": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Refresh connection" + } + } + } + }, + "待上线": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Coming soon" + } + } + } + }, + "再显示 %lld 个项目": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Show %lld more projects" + } + } + } + }, + "再显示 %lld 条会话": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Show %lld more sessions" + } + } + } + }, + "BitFun 用户": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "BitFun user" + } + } + } + }, + "资料": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Details" + } + } + } + }, + "已认证": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Authenticated" + } + } + } + }, + "未认证": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Not authenticated" + } + } + } + }, + "当前连接已通过账号 %@ 验证。密码不会保存到手机。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This connection is verified as account %@. The password is not saved on the phone." + } + } + } + }, + "扫码连接时,如果桌面端要求账号验证,手机会在本次配对中完成认证。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "If the desktop asks for account verification while scanning, the phone completes it for this pairing." + } + } + } + }, + "登录 BitFun": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Sign in to BitFun" + } + } + } + }, + "登录后即可同步设备、会话和远程控制状态。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Sign in to sync devices, sessions, and remote control state." + } + } + } + }, + "正在登录…": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Signing in…" + } + } + } + }, + "登录失败,请检查账号密码或服务器设置。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Sign-in failed. Check the account details or server settings." + } + } + } + }, + "Relay 地址,例如 https://relay.example.com": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Relay URL, for example https://relay.example.com" + } + } + } + }, + "退出登录": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Sign out" + } + } + } + }, + "正在退出…": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Signing out…" + } + } + } + }, + "当前设备": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This device" + } + } + } + }, + "正在加载设备…": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Loading devices…" + } + } + } + }, + "账号下还没有其他已注册设备。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No other registered devices on this account." + } + } + } + }, + "登录云账号后可查看该账号下的所有设备。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Sign in to the cloud account to see every device on it." + } + } + } + }, + "当前控制": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Controlling" + } + } + } + }, + "正在切换到 %@…": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Switching to %@…" + } + } + } + }, + "切换控制设备失败,请稍后重试。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Could not switch the control device. Try again later." + } + } + } + }, + "云账号已失效,请重新登录。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The cloud account expired. Sign in again." + } + } + } + }, + "目标桌面暂时不可用,请刷新设备列表后重试。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The target desktop is unavailable. Refresh the device list and retry." + } + } + } + }, + "此设备的账号凭据已使用 HUKS 加密保存。退出账号后会立即清理。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This device stores account credentials encrypted with HUKS. Signing out clears them immediately." + } + } + } + }, + "本机设备 ID": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This device ID" + } + } + } + }, + "当前桌面端": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Current desktop" + } + } + } + }, + "权限模式": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Permission mode" + } + } + } + }, + "此设置会同步到当前桌面,并应用于所有新工具调用。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This setting syncs to the current desktop and applies to new tool calls." + } + } + } + }, + "需要审批": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Ask to approve" + } + } + } + }, + "高风险操作会等待你确认。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "High-risk actions wait for your confirmation." + } + } + } + }, + "自动批准": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Auto-approve" + } + } + } + }, + "自动通过原本需要确认的操作。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Automatically allow actions that would otherwise ask for confirmation." + } + } + } + }, + "允许所有工具操作,不再请求确认。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Allow every tool action without asking again." + } + } + } + }, + "正在读取桌面权限设置…": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reading desktop permission settings…" + } + } + } + }, + "权限设置加载失败,请重试。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Could not load permission settings. Try again." + } + } + } + }, + "权限模式保存失败,请重试。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Could not save the permission mode. Try again." + } + } + } + }, + "连接桌面后可修改权限模式。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Connect a desktop before changing the permission mode." + } + } + } + }, + "确认开启完全访问": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Turn on full access?" + } + } + } + }, + "开启后,桌面端工具可以直接执行命令和修改文件。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Desktop tools will be able to run commands and change files directly." + } + } + } + }, + "开启完全访问": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Turn on full access" + } + } + } + }, + "连接桌面端后,BitFun 可以访问代码仓库、运行命令和处理本地开发任务。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "After connecting the desktop, BitFun can access repositories, run commands, and handle local development tasks." + } + } + } + }, + "获取配对码": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Get a pairing code" + } + } + } + }, + "在电脑端打开 BitFun 的远程控制,获取二维码或配对码。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open Remote Control in BitFun Desktop to get a QR code or pairing code." + } + } + } + }, + "在 BitFun 桌面版侧边栏中,点击“设置 远程控制”以获取配对码。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "In the BitFun Desktop sidebar, tap Settings > Remote control to get a pairing code." + } + } + } + }, + "扫描二维码以配对": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Scan the QR code to pair" + } + } + } + }, + "选择一台在线桌面继续工作。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Choose an online desktop to continue." + } + } + } + }, + "上次连接": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Last connected" + } + } + } + }, + "改为手动配对": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Pair manually instead" + } + } + } + }, + "手动配对": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Manual pairing" + } + } + } + }, + "请输入桌面上显示的配对码。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enter the pairing code shown on the desktop." + } + } + } + }, + "账号配对验证": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Account pairing" + } + } + } + }, + "桌面端已登录 BitFun 账号,需要验证同一账号后继续连接。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The desktop is signed in to a BitFun account. Verify the same account to continue." + } + } + } + }, + "密码只用于本次加密配对,不会保存到手机。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The password is only used for this encrypted pairing and is not saved on the phone." + } + } + } + }, + "请输入 BitFun 账号密码完成配对": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enter the BitFun account password to finish pairing" + } + } + } + }, + "配对码": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Pairing code" + } + } + } + }, + "需要相机权限才能扫码,请在系统设置中允许 BitFun 访问相机,或改为手动配对。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Camera access is required to scan. Allow BitFun to use the camera in system settings, or pair manually." + } + } + } + }, + "无法打开相机,请检查权限后重试,或改为手动配对。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The camera could not be opened. Check permissions and retry, or pair manually." + } + } + } + }, + "连接步骤": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "How to connect" + } + } + } + }, + "在桌面端 BitFun 打开远程控制入口,扫描二维码或复制连接链接。扫码会打开系统扫码界面,粘贴适合模拟器和远程调试。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open remote control in BitFun Desktop, then scan the QR code or copy the connection link. Scanning opens the system camera; pasting works well on simulators and remote debugging." + } + } + } + }, + "扫描桌面端二维码": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Scan the desktop QR code" + } + } + } + }, + "在 BitFun 桌面端点击「连接移动端」\n扫描二维码完成连接": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "On BitFun Desktop, tap Connect mobile\nthen scan the QR code" + } + } + } + }, + "或": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "or" + } + } + } + }, + "输入用户 ID": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enter user ID" + } + } + } + }, + "例如:DESKTOP-KM3L4UI": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "For example: DESKTOP-KM3L4UI" + } + } + } + }, + "连接中...": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Connecting..." + } + } + } + }, + "粘贴远程连接链接": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Paste remote connection link" + } + } + } + }, + "远程连接链接": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Remote connection link" + } + } + } + }, + "已填写": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Filled" + } + } + } + }, + "填写": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Fill" + } + } + } + }, + "清除": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Clear" + } + } + } + }, + "bitfun:// 或 https://...#/pair?room=...&pk=...&relay=...": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "bitfun:// or https://...#/pair?room=...&pk=...&relay=..." + } + } + } + }, + "连接状态": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Connection status" + } + } + } + }, + "目标桌面端": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Target desktop" + } + } + } + }, + "尚未选择桌面端": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No desktop selected" + } + } + } + }, + "扫描二维码或粘贴远程连接链接后会显示桌面端连接状态。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Desktop connection status appears after you scan a QR code or paste a remote link." + } + } + } + }, + "桌面 ID:%@": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Desktop ID: %@" + } + } + } + }, + "桌面 ID 暂不可用": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Desktop ID unavailable" + } + } + } + }, + "等待连接桌面设备": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Waiting to connect a desktop" + } + } + } + }, + "已连接到 %@ · %@": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Connected to %@ · %@" + } + } + } + }, + "等待桌面端确认": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Waiting for the desktop to confirm" + } + } + } + }, + "连接失败": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Connection failed" + } + } + } + }, + "无法连接到桌面设备": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Could not connect to the desktop" + } + } + } + }, + "已断开": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Disconnected" + } + } + } + }, + "与桌面断开连接": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Disconnected from the desktop" + } + } + } + }, + "连接二维码已过期或房间不存在,请在桌面端重新打开移动端连接并扫码。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The connection QR code expired or the room does not exist. Reopen mobile connect on the desktop and scan again." + } + } + } + }, + "手机无法访问中继服务,请检查网络、代理或桌面端中继地址后重试。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This phone cannot reach the relay. Check the network, proxy, or desktop relay URL, then retry." + } + } + } + }, + "这个连接已绑定用户 ID,请继续使用桌面端确认过的用户 ID。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This connection is bound to a user ID. Continue with the user ID confirmed on the desktop." + } + } + } + }, + "连接链接不完整,请重新扫描二维码或粘贴完整远程连接链接。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The connection link is incomplete. Scan again or paste the full remote link." + } + } + } + }, + "正在重连": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reconnecting" + } + } + } + }, + "连接中": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Connecting" + } + } + } + }, + "连接异常": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Connection error" + } + } + } + }, + "等待连接": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Waiting to connect" + } + } + } + }, + "用户 %@": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "User %@" + } + } + } + }, + "连接不可用,请重新连接": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Connection unavailable. Reconnect to continue." + } + } + } + }, + "与桌面端断开连接": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Disconnected from the desktop" + } + } + } + }, + "重连": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reconnect" + } + } + } + }, + "清除配对": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Clear pairing" + } + } + } + }, + "工作台": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Workbench" + } + } + } + }, + "当前助理": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Current assistant" + } + } + } + }, + "当前工作区": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Current workspace" + } + } + } + }, + "切换助理": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Switch assistant" + } + } + } + }, + "切换": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Switch" + } + } + } + }, + "最近工作区": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Recent workspaces" + } + } + } + }, + "没有最近工作区,请先在桌面端打开工作区。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No recent workspaces. Open one on the desktop first." + } + } + } + }, + "助理工作区": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Assistant workspaces" + } + } + } + }, + "没有可用助理,请先在桌面端创建或打开助理。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No assistants available. Create or open one on the desktop first." + } + } + } + }, + "助理会话": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Assistant session" + } + } + } + }, + "桌面端暂未开放创建助理会话": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The desktop does not allow creating assistant sessions yet" + } + } + } + }, + "处理编码任务": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Handle coding tasks" + } + } + } + }, + "处理日常办公任务": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Handle everyday work tasks" + } + } + } + }, + "最近": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Recent" + } + } + } + }, + "查看全部": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "View all" + } + } + } + }, + "%lld 个会话": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%lld sessions" + } + } + } + }, + "搜索会话标题": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Search session titles" + } + } + } + }, + "加载更多": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Load more" + } + } + } + }, + "删除会话": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Delete session" + } + } + } + }, + "加载会话中": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Loading sessions" + } + } + } + }, + "正在同步桌面端会话列表。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Syncing the desktop session list." + } + } + } + }, + "换一个关键词或筛选条件后再试。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Try another keyword or filter." + } + } + } + }, + "暂无助理会话": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No assistant sessions" + } + } + } + }, + "桌面端开放创建后可在这里开始。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "You can start one here after the desktop allows creation." + } + } + } + }, + "暂无会话": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No sessions" + } + } + } + }, + "用远程处理编码任务,或用普通对话处理日常问题。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Use Remote for coding tasks, or general chat for everyday questions." + } + } + } + }, + "创建 %@ 会话": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Create a %@ session" + } + } + } + }, + "首条指令(可选)": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "First instruction (optional)" + } + } + } + }, + "告诉 BitFun 要做什么...": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Tell BitFun what to do..." + } + } + } + }, + "高级选项": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Advanced options" + } + } + } + }, + "暂不可用": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Unavailable" + } + } + } + }, + "创建中...": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Creating..." + } + } + } + }, + "开始": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Start" + } + } + } + }, + "已上传的文件": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Uploaded files" + } + } + } + }, + "正在听,请说话...": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Listening, please speak..." + } + } + } + }, + "已排队,当前任务结束后执行": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Queued. It will run after the current task finishes." + } + } + } + }, + "图片": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Image" + } + } + } + }, + "已思考": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Thought" + } + } + } + }, + "调用工具": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Tool calls" + } + } + } + }, + "已运行 %lld 个工具": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Ran %lld tools" + } + } + } + }, + "更新任务列表": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Update task list" + } + } + } + }, + "发起任务": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Start task" + } + } + } + }, + "查看变更": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "View changes" + } + } + } + }, + "修改文件": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Edit file" + } + } + } + }, + "网页搜索": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Web search" + } + } + } + }, + "访问网页": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open web page" + } + } + } + }, + "任务列表": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Task list" + } + } + } + }, + "%@失败": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ failed" + } + } + } + }, + "正在运行": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Running" + } + } + } + }, + "查看更多": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Show more" + } + } + } + }, + "收起": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Show less" + } + } + } + }, + "正在处理": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Processing" + } + } + } + }, + "发送失败,请检查连接后重试": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Send failed. Check the connection and retry." + } + } + } + }, + "错误": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Error" + } + } + } + }, + "批准": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Approve" + } + } + } + }, + "取消工具": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cancel tool" + } + } + } + }, + "工具输入": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Tool input" + } + } + } + }, + "重置": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reset" + } + } + } + }, + "编辑 JSON 输入": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Edit JSON input" + } + } + } + }, + "输入回答": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enter your answer" + } + } + } + }, + "提交回答": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Submit answer" + } + } + } + }, + "正在同步": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Syncing" + } + } + } + }, + "暂无消息": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No messages yet" + } + } + } + }, + "正在加载桌面端会话消息。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Loading desktop session messages." + } + } + } + }, + "请返回工作台重新连接桌面端。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Return to the workbench and reconnect the desktop." + } + } + } + }, + "恢复后会继续同步桌面端消息。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Messages will keep syncing after the connection is restored." + } + } + } + }, + "发送第一条指令后,桌面端响应会显示在这里。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "After you send the first instruction, the desktop response appears here." + } + } + } + }, + "BitFun 正在执行...": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "BitFun is working..." + } + } + } + }, + "BitFun 正在处理...": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "BitFun is processing..." + } + } + } + }, + "BitFun 已更新会话": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "BitFun updated the session" + } + } + } + }, + "桌面端文件": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Desktop file" + } + } + } + }, + "文件链接": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "File link" + } + } + } + }, + "读取中": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reading" + } + } + } + }, + "下载": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Download" + } + } + } + }, + "文件预览": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "File preview" + } + } + } + }, + "正在读取文件": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reading file" + } + } + } + }, + "离线,仅显示已加载内容": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Offline. Showing loaded content only." + } + } + } + }, + "适应窗口": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Fit window" + } + } + } + }, + "原始大小": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Actual size" + } + } + } + }, + "图片无法解码,请重试或下载文件。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The image could not be decoded. Retry or download the file." + } + } + } + }, + "无法打开文件": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Could not open the file" + } + } + } + }, + "文件不存在或已被移动": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The file does not exist or was moved" + } + } + } + }, + "文件不存在或不在当前工作区": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The file does not exist or is outside the current workspace" + } + } + } + }, + "无法访问工作区外的文件": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cannot access files outside the workspace" + } + } + } + }, + "文件过大,无法在移动端预览": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The file is too large to preview on mobile" + } + } + } + }, + "此文件暂不支持预览": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This file type cannot be previewed yet" + } + } + } + }, + "已显示前 %@,下载后可查看完整内容": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Showing the first %@. Download the file to see all of it." + } + } + } + }, + "待确认": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Pending confirmation" + } + } + } + }, + "已取消": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cancelled" + } + } + } + }, + "请输入 JSON 对象": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enter a JSON object" + } + } + } + }, + "JSON 格式错误": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Invalid JSON" + } + } + } + }, + "桌面端需要你的回答。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The desktop needs your answer." + } + } + } + }, + "解析远程 URL": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Parsing remote URL" + } + } + } + }, + "正在配对": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Pairing" + } + } + } + }, + "已清除配对信息": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Pairing cleared" + } + } + } + }, + "已断开连接": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Disconnected" + } + } + } + }, + "读取剪贴板": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Read clipboard" + } + } + } + }, + "剪贴板没有可用文本": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Clipboard has no usable text" + } + } + } + }, + "已从剪贴板填入远程连接链接": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Filled the remote connection link from the clipboard" + } + } + } + }, + "剪贴板内容已填入,请确认远程连接链接": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Clipboard content was filled. Confirm the remote connection link." + } + } + } + }, + "打开扫码": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open scanner" + } + } + } + }, + "未识别到连接二维码": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No connection QR code was recognized" + } + } + } + }, + "已扫描远程连接链接": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Scanned remote connection link" + } + } + } + }, + "连接不可用,请先重新连接": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Connection unavailable. Reconnect first." + } + } + } + }, + "加载最近工作区": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Loading recent workspaces" + } + } + } + }, + "请选择工作区": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Choose a workspace" + } + } + } + }, + "没有最近工作区": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No recent workspaces" + } + } + } + }, + "正在切换工作区": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Switching workspace" + } + } + } + }, + "工作区已切换": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Workspace switched" + } + } + } + }, + "加载助理工作区": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Loading assistant workspaces" + } + } + } + }, + "请选择助理": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Choose an assistant" + } + } + } + }, + "没有可用助理": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No assistants available" + } + } + } + }, + "正在切换助理": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Switching assistant" + } + } + } + }, + "助理已切换": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Assistant switched" + } + } + } + }, + "刷新会话列表": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Refresh session list" + } + } + } + }, + "会话列表已更新": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Session list updated" + } + } + } + }, + "创建远程会话": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Create remote session" + } + } + } + }, + "会话已创建": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Session created" + } + } + } + }, + "加载会话消息": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Load session messages" + } + } + } + }, + "正在删除会话": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Deleting session" + } + } + } + }, + "会话已删除": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Session deleted" + } + } + } + }, + "会话已归档": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Session archived" + } + } + } + }, + "会话已取消归档": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Session unarchived" + } + } + } + }, + "已复制为 Markdown": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Copied as Markdown" + } + } + } + }, + "消息已同步": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Messages synced" + } + } + } + }, + "已恢复本地会话记录": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Restored local session history" + } + } + } + }, + "正在切换模型": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Switching model" + } + } + } + }, + "模型已切换": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Model switched" + } + } + } + }, + "发送图片": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Send image" + } + } + } + }, + "发送指令": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Send instruction" + } + } + } + }, + "已发送,等待桌面端响应": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Sent. Waiting for the desktop." + } + } + } + }, + "选择图片": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Choose image" + } + } + } + }, + "未选择图片": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No image selected" + } + } + } + }, + "已选择 %lld 张图片": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Selected %lld images" + } + } + } + }, + "启动语音输入": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Starting voice input" + } + } + } + }, + "正在语音输入": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Listening" + } + } + } + }, + "已填入语音识别文本": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Filled recognized speech" + } + } + } + }, + "已停止语音输入": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Voice input stopped" + } + } + } + }, + "当前没有正在执行的任务": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No task is running" + } + } + } + }, + "正在停止任务": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Stopping task" + } + } + } + }, + "已请求停止": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Stop requested" + } + } + } + }, + "正在更新会话标题": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Updating session title" + } + } + } + }, + "会话标题已更新": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Session title updated" + } + } + } + }, + "消息已复制": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Message copied" + } + } + } + }, + "读取文件信息": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Read file info" + } + } + } + }, + "准备下载 %@": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Preparing download %@" + } + } + } + }, + "下载完成 %@ · %@": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Downloaded %@ · %@" + } + } + } + }, + "正在批准工具调用": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Approving tool call" + } + } + } + }, + "已批准工具调用": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Tool call approved" + } + } + } + }, + "正在拒绝工具调用": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Rejecting tool call" + } + } + } + }, + "已拒绝工具调用": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Tool call rejected" + } + } + } + }, + "正在取消工具调用": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cancelling tool call" + } + } + } + }, + "已请求取消工具调用": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Tool cancellation requested" + } + } + } + }, + "正在提交回答": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Submitting answer" + } + } + } + }, + "回答已提交": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Answer submitted" + } + } + } + }, + "桌面端正在处理": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Desktop is working" + } + } + } + }, + "尝试次数过多,请 %lld 秒后再试。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Too many attempts. Try again in %lld seconds." + } + } + } + }, + "请输入用户 ID。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enter a user ID." + } + } + } + }, + "请输入 BitFun 用户名。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enter a BitFun username." + } + } + } + }, + "请输入 BitFun 账号密码。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enter the BitFun account password." + } + } + } + }, + "中继返回了无法解析的数据,请确认桌面端和移动端版本匹配。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The relay returned data that could not be parsed. Confirm the desktop and mobile versions match." + } + } + } + }, + "中继拒绝了配对请求,请在桌面端重新生成连接二维码后再试。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The relay rejected the pairing request. Generate a new connection QR code on the desktop and retry." + } + } + } + }, + "没有找到这个远程房间,请确认二维码未过期,或在桌面端重新打开移动端连接。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This remote room was not found. Confirm the QR code has not expired, or reopen mobile connect on the desktop." + } + } + } + }, + "连接中继超时,请检查网络后重试。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Timed out connecting to the relay. Check the network and retry." + } + } + } + }, + "请求过于频繁,请稍后再试。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Too many requests. Try again later." + } + } + } + }, + "中继服务暂时不可用(HTTP %@),请稍后重试。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The relay is temporarily unavailable (HTTP %@). Try again later." + } + } + } + }, + "中继返回 HTTP %@,请重新配对后再试。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The relay returned HTTP %@. Pair again and retry." + } + } + } + }, + "连接中继超时,请确认手机和桌面端网络可访问。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Timed out connecting to the relay. Confirm the phone and desktop can reach the network." + } + } + } + }, + "无法连接到中继服务,请检查远程链接、网络或代理设置。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Could not reach the relay. Check the remote link, network, or proxy settings." + } + } + } + }, + "远程连接失败,请检查网络后重试。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Remote connection failed. Check the network and retry." + } + } + } + }, + "请输入桌面端远程连接 URL。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enter the desktop remote connection URL." + } + } + } + }, + "远程 URL 缺少 room 或 pk 参数。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The remote URL is missing the room or pk parameter." + } + } + } + }, + "远程连接链接格式不正确,请重新扫描二维码或粘贴完整链接。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The remote connection link is invalid. Scan again or paste the full link." + } + } + } + }, + "没有获得所需权限,请允许扫码或剪贴板访问后重试。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Required permission was denied. Allow camera or clipboard access and retry." + } + } + } + }, + "操作失败,请稍后重试。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The operation failed. Try again later." + } + } + } + }, + "这条消息带的附件超过了中继允许的大小,请减少图片数量或选择更小的图片。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This message’s attachments exceed the relay size limit. Send fewer images or smaller ones." + } + } + } + }, + "图片压缩后仍超过 %@ MB,请选择较小图片。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The image is still larger than %@ MB after compression. Choose a smaller image." + } + } + } + }, + "图片压缩失败,请选择较小图片。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Image compression failed. Choose a smaller image." + } + } + } + }, + "没有获得麦克风权限,无法语音输入。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Microphone permission was denied, so voice input is unavailable." + } + } + } + }, + "语音识别暂不可用,请稍后重试。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Speech recognition is unavailable. Try again later." + } + } + } + }, + "语音识别失败(%@),请稍后重试。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Speech recognition failed (%@). Try again later." + } + } + } + }, + "把这块手表加入你的 BitFun 账号?": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Add this watch to your BitFun account?" + } + } + } + }, + "设备编号 %@": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Device %@" + } + } + } + }, + "同意后,这块手表可以用你的账号连接桌面端,有效期 30 天。请确认这台设备就在你手里。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "If you allow this, the watch can use your account to connect a desktop for 30 days. Confirm the device is in your hands." + } + } + } + }, + "知道了": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Got it" + } + } + } + }, + "正在从桌面端申请授权...": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Requesting authorization from the desktop..." + } + } + } + }, + "%@ 已加入账号,手表上可以直接使用了。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ was added to the account and can be used on the watch now." + } + } + } + }, + "已在手机上拒绝。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Denied on the phone." + } + } + } + }, + "手机正在处理另一台设备的请求,请稍后再试。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The phone is handling another device request. Try again later." + } + } + } + }, + "需要先在手机上扫码连接桌面端,才能给手表授权。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Scan and connect a desktop on the phone before authorizing a watch." + } + } + } + }, + "桌面端未在线或版本过旧,请更新桌面端后重试。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The desktop is offline or too old. Update it and retry." + } + } + } + }, + "授权已完成,但没能把凭证发给手表,请在手表上重试一次。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Authorization finished, but the credential could not be sent to the watch. Retry on the watch." + } + } + } + }, + "(空消息)": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "(empty message)" + } + } + } + }, + "桌面端正在处理...": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Desktop is working..." + } + } + } + }, + "%@ 已连接。你可以继续发送指令,桌面端会在 BitFun 中执行。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ is connected. You can keep sending instructions; the desktop will run them in BitFun." + } + } + } + }, + "时间未知": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Unknown time" + } + } + } + }, + "%lld 分钟前": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%lld minutes ago" + } + } + } + }, + "%lld 小时前": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%lld hours ago" + } + } + } + }, + "%lld 天前": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%lld days ago" + } + } + } + }, + "sidebar.time.today": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "今天" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Today" + } + } + } + }, + "sidebar.time.yesterday": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "昨天" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Yesterday" + } + } + } + }, + "sidebar.time.older": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "更早" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Older" + } + } + } + }, + "下载目标已变化,请重新打开文件": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "下载目标已变化,请重新打开文件" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "The download target changed. Reopen the file." + } + } + } + }, + "创建远程会话失败,请重试": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "创建远程会话失败,请重试" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Could not create the remote session. Try again." + } + } + } + }, + "创建远程会话已取消": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "创建远程会话已取消" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Remote session creation was cancelled." + } + } + } + }, + "创建远程会话已结束,请重试": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "创建远程会话已结束,请重试" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Remote session creation ended. Try again." + } + } + } + }, + "工作区加载失败,请重试": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "工作区加载失败,请重试" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Could not load the workspace. Try again." + } + } + } + }, + "找不到此文件": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "找不到此文件" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "File not found" + } + } + } + }, + "暂无可用工作区": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "暂无可用工作区" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "No workspaces available" + } + } + } + }, + "未选择远程设备": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "未选择远程设备" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "No remote device selected" + } + } + } + }, + "桌面端不支持创建此类会话": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "桌面端不支持创建此类会话" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "The desktop does not support creating this type of session" + } + } + } + }, + "此文件类型暂不支持": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "此文件类型暂不支持" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "This file type is not supported" + } + } + } + }, + "没有权限访问此文件": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "没有权限访问此文件" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "You do not have permission to access this file" + } + } + } + }, + "设备不支持助手会话": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "设备不支持助手会话" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "This device does not support assistant sessions" + } + } + } + }, + "远程设备当前离线": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "远程设备当前离线" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "The remote device is currently offline" + } + } + } + }, + "重试下载": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重试下载" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Retry download" + } + } + } + }, + "无法加载设备列表": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Unable to load devices" + } + } + } + }, + "登录已完成,但设备列表加载失败。请重试。": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "You are signed in, but the device list could not be loaded. Retry without signing in again." + } + } + } + }, + "正在重试": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Retrying" + } + } + } + }, + "重试加载设备": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Retry loading devices" + } + } + } + }, + "使用其他账号重新登录": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Sign in with another account" + } + } + } + }, + "重试文件预览": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重试文件预览" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Retry file preview" + } + } + } } }, "version": "1.0" diff --git a/src/apps/mobile/ios/README.md b/src/apps/mobile/ios/README.md index daafa38203..61f28c55a7 100644 --- a/src/apps/mobile/ios/README.md +++ b/src/apps/mobile/ios/README.md @@ -35,6 +35,15 @@ are supplied by the generated `BitFunMobileCore` framework from presentation model. Pairing accepts the desktop connection URL through the connection sheet (the camera scanner remains a native adapter concern). +Run the platform-independent Swift infrastructure checks through the registered +focused entry point. It compiles production helpers together with their local +test executables; test mains are not part of the app target: + +```bash +export DEVELOPER_DIR="$HOME/Downloads/Xcode.app/Contents/Developer" +./Testing/run-pure-swift-tests.sh +``` + When the framework has not been built yet, generate it with the same compatible toolchain before opening the Xcode project: diff --git a/src/apps/mobile/ios/Testing/AccountFailureCopyTests.swift b/src/apps/mobile/ios/Testing/AccountFailureCopyTests.swift new file mode 100644 index 0000000000..b83c624a17 --- /dev/null +++ b/src/apps/mobile/ios/Testing/AccountFailureCopyTests.swift @@ -0,0 +1,28 @@ +import Foundation + +@main +struct AccountFailureCopyTests { + static func main() { + let expected = [ + "INVALID_CREDENTIALS": "账号或密码错误", + "AUTHENTICATION": "登录状态无效,请重新输入账号和密码", + "RATE_LIMITED": "登录请求过多,请稍后重试", + "RELAY_UNAVAILABLE": "Relay 服务暂时不可用,请稍后重试", + "NETWORK": "网络连接失败,请检查网络和 relay 地址后重试", + "TIMEOUT": "登录超时,请稍后重试", + "MALFORMED_RESPONSE": "Relay 响应异常,请稍后重试或升级应用", + "SECURE_STORAGE": "无法访问系统安全存储,请稍后重试。", + ] + for (reason, key) in expected { + precondition(AccountFailureCopy.localizationKey(reason: reason, stage: "AUTHENTICATION") == key) + } + precondition( + AccountFailureCopy.localizationKey(reason: "NETWORK", stage: "DEVICE_LIST") == + "登录已完成,但设备列表加载失败。请重试。" + ) + precondition( + AccountFailureCopy.localizationKey(reason: "UNAUTHORIZED", stage: "AUTHENTICATION") == + "登录服务暂时不可用,请稍后重试" + ) + } +} diff --git a/src/apps/mobile/ios/Testing/RemoteAuthorityGateTests.swift b/src/apps/mobile/ios/Testing/RemoteAuthorityGateTests.swift new file mode 100644 index 0000000000..84bbe84c34 --- /dev/null +++ b/src/apps/mobile/ios/Testing/RemoteAuthorityGateTests.swift @@ -0,0 +1,436 @@ +import Foundation + +@main +struct RemoteAuthorityGateTests { + private struct ReadyCase { + let name: String + let target: String + let epoch: UInt64 + let revision: Int64 + let expected: Bool + } + + static func main() { + let authority = RemoteAuthorityScope(targetKey: "device-a", epoch: 7, revision: 12) + let readyCases = [ + ReadyCase(name: "target-only reset accepts legacy", target: "device-b", epoch: 7, revision: 0, expected: true), + ReadyCase(name: "epoch-only reset accepts legacy", target: "device-a", epoch: 8, revision: 0, expected: true), + ReadyCase(name: "lower positive rejected", target: "device-a", epoch: 7, revision: 11, expected: false), + ReadyCase(name: "equal positive accepted idempotently", target: "device-a", epoch: 7, revision: 12, expected: true), + ReadyCase(name: "same scope legacy rejected", target: "device-a", epoch: 7, revision: 0, expected: false), + ] + for testCase in readyCases { + expect( + RemoteAuthorityGate.acceptsReady( + targetKey: testCase.target, + epoch: testCase.epoch, + revision: testCase.revision, + lastApplied: authority + ) == testCase.expected, + testCase.name + ) + } + + expect( + RemoteAuthorityGate.updatedScope( + targetKey: "device-b", + epoch: 7, + revision: 0, + lastApplied: authority + ) == nil, + "target-only reset clears positive authority for legacy Ready" + ) + expect( + RemoteAuthorityGate.updatedScope( + targetKey: "device-a", + epoch: 8, + revision: 0, + lastApplied: authority + ) == nil, + "epoch-only reset clears positive authority for legacy Ready" + ) + expect( + RemoteAuthorityGate.updatedScope( + targetKey: "device-a", + epoch: 7, + revision: 12, + lastApplied: authority + ) == authority, + "equal Ready is an idempotent authority update" + ) + + expect( + RemoteAuthorityGate.succeededIsAlreadyAuthoritative( + targetKey: "device-a", + epoch: 7, + commitRevision: 12, + confirmedSessionVisible: true, + lastApplied: authority + ), + "Ready-first then Succeeded is immediately authoritative" + ) + expect( + RemoteAuthorityGate.readyIncludesCommit( + readyRevision: 12, + minimumAuthorityRevision: 12, + confirmedSessionVisible: true + ), + "Ready includes visible commit at its minimum revision" + ) + expect( + !RemoteAuthorityGate.readyIncludesCommit( + readyRevision: 12, + minimumAuthorityRevision: 12, + confirmedSessionVisible: false + ), + "revision without the exact confirmed row cannot acknowledge commit" + ) + expect( + RemoteAuthorityGate.readyIncludesCommit( + readyRevision: 0, + minimumAuthorityRevision: 0, + confirmedSessionVisible: true + ), + "legacy commit minimum zero accepts its exact visible row" + ) + + let protected = RemoteAuthorityGate.committedProjectionDecision( + readyTargetKey: "device-a", + readyEpoch: 7, + readyRevision: 11, + committedTargetKey: "device-a", + committedEpoch: 7, + minimumAuthorityRevision: 12, + confirmedSessionVisible: false + ) + expect(protected.retainMarker, "stale Ready retains committed marker") + expect(protected.protectCommittedRowAndSelection, "stale Ready protects committed row and selection") + + let acknowledged = RemoteAuthorityGate.committedProjectionDecision( + readyTargetKey: "device-a", + readyEpoch: 7, + readyRevision: 12, + committedTargetKey: "device-a", + committedEpoch: 7, + minimumAuthorityRevision: 12, + confirmedSessionVisible: true + ) + expect(!acknowledged.retainMarker, "authoritative Ready clears committed marker") + expect(!acknowledged.protectCommittedRowAndSelection, "authoritative Ready restores authoritative projection") + + let wrongTarget = RemoteAuthorityGate.committedProjectionDecision( + readyTargetKey: "device-b", + readyEpoch: 7, + readyRevision: 0, + committedTargetKey: "device-a", + committedEpoch: 7, + minimumAuthorityRevision: 12, + confirmedSessionVisible: false + ) + expect(!wrongTarget.retainMarker, "target reset discards old committed marker") + expect(!wrongTarget.protectCommittedRowAndSelection, "target reset cannot project old row or selection") + + let populatedProjection = RemoteTargetProjectionState( + hasSessionRows: true, + hasWorkspaceRows: true, + hasSelection: true, + hasTimeline: true, + hasActiveTurn: true, + hasPendingNavigation: true, + hasReadyAuthority: true, + hasCreateState: true + ) + let accountToPairing = RemoteAuthorityGate.targetBoundTransition( + currentTargetKey: "account:device-a", + currentEpoch: 7, + boundTargetKey: "pairing", + boundEpoch: 8, + projection: populatedProjection + ) + expect(accountToPairing.scopeChanged, "account to pairing is an authoritative scope change") + expect(accountToPairing.projection == .cleared, "account to pairing clears rows, selection, timeline, pending, Ready, and create metadata") + + let repeatedBound = RemoteAuthorityGate.targetBoundTransition( + currentTargetKey: "pairing", + currentEpoch: 8, + boundTargetKey: "pairing", + boundEpoch: 8, + projection: populatedProjection + ) + expect(!repeatedBound.scopeChanged, "repeated bound callback does not reset the scope") + expect(repeatedBound.projection == populatedProjection, "repeated bound callback preserves current projection") + + expect( + !RemoteAuthorityGate.callbackMatchesAuthority( + targetKey: "account:device-a", + epoch: 7, + expectedTargetKey: "pairing", + expectedEpoch: 8 + ), + "stale old-target callback is rejected" + ) + expect( + !RemoteAuthorityGate.callbackMatchesAuthority( + targetKey: "pairing", + epoch: 7, + expectedTargetKey: "pairing", + expectedEpoch: 8 + ), + "stale old-epoch callback is rejected" + ) + expect( + !RemoteAuthorityGate.fileTransferCallbackMatchesAuthority( + requestTargetKey: "account:device-a", + requestEpoch: 7, + adapterTargetKey: "account:device-b", + adapterEpoch: 8 + ), + "target switch rejects a queued preview or download callback from the old target" + ) + expect( + !RemoteAuthorityGate.fileTransferCallbackMatchesAuthority( + requestTargetKey: "account:device-a", + requestEpoch: 7, + adapterTargetKey: "account:device-a", + adapterEpoch: 8 + ), + "adapter epoch switch rejects a queued preview or download callback from the old store" + ) + expect( + RemoteAuthorityGate.fileTransferCallbackMatchesAuthority( + requestTargetKey: "account:device-b", + requestEpoch: 8, + adapterTargetKey: "account:device-b", + adapterEpoch: 8 + ), + "current target file transfer callback remains accepted" + ) + + let pairingReplacement = RemoteAuthorityGate.pairingAttemptTransition( + authoritativeTargetKey: "pairing", + remoteConnected: true + ) + expect(pairingReplacement.clearBoundRemoteProjection, "re-pair clears the replaced pairing projection") + expect(!pairingReplacement.remoteConnected, "failed re-pair remains disconnected after the old projection was discarded") + + expect( + RemoteAuthorityGate.exactInvalidationMatchesAuthority( + expectedTargetKey: "account:device-a", + expectedEpoch: 7, + currentTargetKey: "account:device-a", + currentEpoch: 7 + ), + "terminal pairing failure may invalidate the exact old account authority" + ) + expect( + !RemoteAuthorityGate.exactInvalidationMatchesAuthority( + expectedTargetKey: "account:device-a", + expectedEpoch: 7, + currentTargetKey: "account:device-a", + currentEpoch: 8 + ), + "terminal pairing failure cannot invalidate a newer account authority" + ) + + let capturedAccount = RetainedAccountAuthority(targetKey: "account:device-a", epoch: 7) + expect( + RemoteAuthorityGate.shouldRetainAccountAfterPairingFailure( + captured: capturedAccount, + adapterTargetKey: "account:device-a", + adapterEpoch: 7, + modelTargetKey: "account:device-a", + modelEpoch: 7, + healthyConnected: true + ), + "failed pairing retains the explicitly captured healthy authoritative account" + ) + expect( + !RemoteAuthorityGate.shouldRetainAccountAfterPairingFailure( + captured: capturedAccount, + adapterTargetKey: "account:device-a", + adapterEpoch: 7, + modelTargetKey: "account:device-a", + modelEpoch: 7, + healthyConnected: false + ), + "failed account remote cannot be retained by a later pairing failure" + ) + expect( + !RemoteAuthorityGate.shouldRetainAccountAfterPairingFailure( + captured: capturedAccount, + adapterTargetKey: "account:device-a", + adapterEpoch: 8, + modelTargetKey: "account:device-a", + modelEpoch: 7, + healthyConnected: true + ), + "changed adapter epoch cannot retain the captured account" + ) + + let retainedAccountPairingAttempt = RemoteAuthorityGate.pairingAttemptTransition( + authoritativeTargetKey: "account:device-a", + remoteConnected: true + ) + expect( + !retainedAccountPairingAttempt.clearBoundRemoteProjection, + "pairing submission does not invalidate a retained account before its terminal result" + ) + expect( + !RemoteAuthorityGate.fileTransferCallbackMatchesAuthority( + requestTargetKey: "account:device-a", + requestEpoch: 7, + adapterTargetKey: nil, + adapterEpoch: 8 + ), + "token expiry prevents a queued old transfer callback from reviving after adapter invalidation" + ) + expect( + !RemoteAuthorityGate.callbackMatchesAuthority( + targetKey: "account:device-a", + epoch: 7, + expectedTargetKey: nil, + expectedEpoch: 8 + ), + "token expiry prevents a late old Ready callback from reviving cleared authority" + ) + verifyProductionInvalidationPaths() + } + + private static func verifyProductionInvalidationPaths() { + let iosDirectory = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + let accountSource = readSource( + iosDirectory.appendingPathComponent("BitFun/Infrastructure/MobileAppModel+Account.swift") + ) + expectInvalidationBeforeMutation( + in: accountSource, + function: "func selectRemoteDevice(_ device: MobileAccountDevice)", + mutation: "coreAdapter?.selectAccountDevice(id: device.id)", + message: "device switch invalidates transfers before adapter target selection" + ) + expectInvalidationBeforeMutation( + in: accountSource, + function: "func logoutAccount()", + mutation: "coreAdapter?.beginAccountOperation()", + message: "non-retained logout invalidates transfers before adapter authority reset" + ) + expectInvalidationBeforeMutation( + in: accountSource, + function: "func loginAccount(relayURL: String, username: String, password: String)", + mutation: "coreAdapter?.beginAccountOperation()", + message: "non-retained login invalidates transfers before adapter authority reset" + ) + expectInvalidationBeforeMutation( + in: accountSource, + function: "func apply(accountState state: AccountUiState, generation: UInt64)", + mutation: "coreAdapter?.selectAccountDevice(id: target.id)", + message: "account Ready arbitration invalidates an old account transfer before automatic target selection" + ) + expectInvalidationBeforeMutation( + in: accountSource, + function: "private func invalidateTerminalAccountAuthority()", + mutation: "_ = coreAdapter?.invalidateRemoteAuthority", + message: "token expiry invalidates transfers before exact adapter authority invalidation" + ) + expectCallBeforeMutation( + in: accountSource, + function: "private func invalidateTerminalAccountAuthority()", + call: "_ = coreAdapter?.invalidateRemoteAuthority", + mutation: "clearInvalidatedRemoteAuthorityProjection(", + message: "token expiry invalidates exact adapter authority before clearing the complete projection" + ) + + let modelSource = readSource( + iosDirectory.appendingPathComponent("BitFun/Infrastructure/MobileAppModel.swift") + ) + expectInvalidationBeforeMutation( + in: modelSource, + function: "func disconnectRemote()", + mutation: "coreAdapter?.disconnect()", + message: "disconnect invalidates transfers before adapter authority reset" + ) + expectInvalidationBeforeMutation( + in: modelSource, + function: "private func prepareProjectionForPairingSubmission()", + mutation: "directPairingConnected = false", + message: "replacing pairing invalidates transfers before old pairing projection is revoked" + ) + expectCallBeforeMutation( + in: modelSource, + function: "func submitPairing(url: String)", + call: "prepareProjectionForPairingSubmission()", + mutation: "coreAdapter?.submitPairing(url: url)", + message: "pairing replacement preparation runs before adapter pairing mutation" + ) + expectInvalidationBeforeMutation( + in: modelSource, + function: "private func apply(pairingState state: PairingUiState, generation: UInt64)", + mutation: "_ = coreAdapter?.invalidateRemoteAuthority", + message: "non-retained pairing failure invalidates transfers before exact adapter authority invalidation" + ) + + let remoteSessionSource = readSource( + iosDirectory.appendingPathComponent("BitFun/Infrastructure/MobileAppModel+RemoteSession.swift") + ) + expectCallBeforeMutation( + in: remoteSessionSource, + function: "func clearInvalidatedRemoteAuthorityProjection(adapterEpoch: UInt64)", + call: "clearTargetScopedRemoteProjection(", + mutation: "remoteExpectedDeviceKey = nil", + message: "terminal authority loss clears the complete target projection before dropping model authority" + ) + + let filePreviewSource = readSource( + iosDirectory.appendingPathComponent("BitFun/Infrastructure/MobileAppModel+FilePreview.swift") + ) + let guardedEntryCount = filePreviewSource.components( + separatedBy: "guard surface == .remote, remoteSessionSelected" + ).count - 1 + expect( + guardedEntryCount >= 2, + "preview and download production entries remain closed after token expiry clears remote selection" + ) + } + + private static func readSource(_ url: URL) -> String { + guard let source = try? String(contentsOf: url, encoding: .utf8) else { + preconditionFailure("Unable to read production source at \(url.path)") + } + return source + } + + private static func expectInvalidationBeforeMutation( + in source: String, + function: String, + mutation: String, + message: String + ) { + expectCallBeforeMutation( + in: source, + function: function, + call: "invalidateTargetScopedFileTransfers()", + mutation: mutation, + message: message + ) + } + + private static func expectCallBeforeMutation( + in source: String, + function: String, + call: String, + mutation: String, + message: String + ) { + guard let functionRange = source.range(of: function), + let callRange = source.range(of: call, range: functionRange.lowerBound.. Bool, _ message: String) { + precondition(condition(), message) + } +} diff --git a/src/apps/mobile/ios/Testing/run-pure-swift-tests.sh b/src/apps/mobile/ios/Testing/run-pure-swift-tests.sh new file mode 100755 index 0000000000..54a37a0531 --- /dev/null +++ b/src/apps/mobile/ios/Testing/run-pure-swift-tests.sh @@ -0,0 +1,21 @@ +#!/bin/sh +set -eu + +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +IOS_DIR=$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd) +OUTPUT_DIR=$(mktemp -d "${TMPDIR:-/tmp}/bitfun-ios-pure-swift-tests.XXXXXX") +trap 'rm -rf "$OUTPUT_DIR"' EXIT HUP INT TERM + +xcrun --sdk macosx swiftc \ + "$IOS_DIR/BitFun/Infrastructure/RemoteAuthorityGate.swift" \ + "$SCRIPT_DIR/RemoteAuthorityGateTests.swift" \ + -o "$OUTPUT_DIR/remote-authority-gate-tests" +"$OUTPUT_DIR/remote-authority-gate-tests" + +xcrun --sdk macosx swiftc \ + "$IOS_DIR/BitFun/Infrastructure/AccountFailureCopy.swift" \ + "$SCRIPT_DIR/AccountFailureCopyTests.swift" \ + -o "$OUTPUT_DIR/account-failure-copy-tests" +"$OUTPUT_DIR/account-failure-copy-tests" + +printf '%s\n' 'iOS pure Swift focused tests passed.' diff --git a/src/apps/mobile/shared/core-crypto/build.gradle.kts b/src/apps/mobile/shared/core-crypto/build.gradle.kts index b274776431..8b7ad27716 100644 --- a/src/apps/mobile/shared/core-crypto/build.gradle.kts +++ b/src/apps/mobile/shared/core-crypto/build.gradle.kts @@ -28,8 +28,10 @@ fun registerArgon2Archive( val include = sharedArgon2Dir.asFile.absolutePath val objects = sources.mapIndexed { index, source -> directory.resolve("argon2-$index.o") } val wrapper = directory.resolve("bitfun-argon2-wrapper.o") + // The target triple is the single source of platform and deployment + // metadata, avoiding conflicting minimum-version flags. val compile = listOf( - "clang", "-target", targetTriple, "-miphoneos-version-min=16.0", + "clang", "-target", targetTriple, "-DARGON2_NO_THREADS", "-I$include", "-I${sharedArgon2Dir.dir("blake2").asFile.absolutePath}", "-O2", "-fvisibility=hidden", "-c", ) @@ -47,7 +49,7 @@ fun registerArgon2Archive( } val iosArgon2Archive = registerArgon2Archive("iosArm64", "arm64-apple-ios16.0") -val iosSimulatorArgon2Archive = registerArgon2Archive("iosSimulatorArm64", "arm64-apple-ios16.0-simulator") +val iosSimulatorArgon2Archive = registerArgon2Archive("iosSimulatorArm64", "arm64-apple-ios15.0-simulator") kotlin { jvmToolchain(17) @@ -83,6 +85,14 @@ kotlin { includeDirs(sharedArgon2Dir, argon2InteropDir) extraOpts("-libraryPath", layout.buildDirectory.dir("native/argon2/iosSimulatorArm64").get().asFile.absolutePath) } + // A non-standard Xcode selected explicitly through DEVELOPER_DIR needs its + // Swift compatibility archives when linking the simulator DEBUG test. + // Without that environment variable, leave toolchain discovery to Kotlin. + System.getenv("DEVELOPER_DIR")?.takeIf(String::isNotBlank)?.let { developerDir -> + val swiftSimulatorLibraries = file(developerDir) + .resolve("Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/iphonesimulator") + binaries.getTest("DEBUG").linkerOpts("-L${swiftSimulatorLibraries.absolutePath}") + } } sourceSets { diff --git a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/bitfun/mobile/core/feature/account/AccountStore.kt b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/bitfun/mobile/core/feature/account/AccountStore.kt index 7d1ac93b89..473fb9cb01 100644 --- a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/bitfun/mobile/core/feature/account/AccountStore.kt +++ b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/bitfun/mobile/core/feature/account/AccountStore.kt @@ -67,6 +67,8 @@ public class AccountStore internal constructor( public val state: StateFlow = _state.asStateFlow() private var session: AccountSessionData? = null private var work: Job? = null + /** Latest account membership snapshot, used to authorize explicit device stores. */ + private var controllableDevices: List = emptyList() public fun dispatch(intent: AccountIntent) { when (intent) { @@ -74,6 +76,7 @@ public class AccountStore internal constructor( is AccountIntent.Login -> login(intent) is AccountIntent.SelectDevice -> selectDevice(intent.deviceId) AccountIntent.RefreshDevices -> refreshDevices() + AccountIntent.Retry -> retryFailedStage() AccountIntent.Logout -> logout() AccountIntent.Stop -> stop() } @@ -93,7 +96,52 @@ public class AccountStore internal constructor( public fun createWorkspaceStore(scope: CoroutineScope): RemoteWorkspaceStore? { val current = session ?: return null val target = current.targetDeviceId?.takeIf(String::isNotBlank) ?: return null - return RemoteWorkspaceStore.create(scope, backend.transport(current, target)) + return RemoteWorkspaceStore.create( + scope, + backend.transport(current, target), + kotlinx.coroutines.Dispatchers.Default, + target, + ) + } + + /** + * A session store addressed to one specific device, independent of the + * currently selected control target. This is how a multi-device directory + * loads several devices at once while the old single-target methods keep + * their existing meaning. + */ + public fun createSessionStore(scope: CoroutineScope, deviceId: String): RemoteSessionStore? { + val current = session ?: return null + val target = authorizedDeviceId(deviceId) ?: return null + return RemoteSessionStore.create( + scope, + backend.transport(current, target), + deviceKey = target, + persistence = persistence, + ) + } + + /** The explicit-device twin of [createWorkspaceStore]. */ + public fun createWorkspaceStore(scope: CoroutineScope, deviceId: String): RemoteWorkspaceStore? { + val current = session ?: return null + val target = authorizedDeviceId(deviceId) ?: return null + return RemoteWorkspaceStore.create( + scope, + backend.transport(current, target), + kotlinx.coroutines.Dispatchers.Default, + target, + ) + } + + /** + * The directory may retain an offline account row, but an explicit store is + * still only granted to a device in the latest authenticated membership + * snapshot. Offline is allowed here so cached directory data can be shown; + * the directory's online guard prevents commands from being sent. + */ + private fun authorizedDeviceId(deviceId: String): String? { + val target = deviceId.trim().takeIf(String::isNotBlank) ?: return null + return controllableDevices.firstOrNull { it.id == target }?.id } /** @@ -125,9 +173,16 @@ public class AccountStore internal constructor( } decodeRecord(stored) } catch (_: Throwable) { - secureStore.delete(SESSION_KEY) + // A record we cannot decode may belong to a newer or older app. + // Keep the opaque value in secure storage so a retry or upgraded + // client can still read it; only clear this store's projection. session = null - _state.value = AccountUiState.Failed(AccountFailureReason.SECURE_STORAGE, true) + controllableDevices = emptyList() + _state.value = AccountUiState.Failed( + AccountFailureReason.SECURE_STORAGE, + true, + AccountFailureStage.RESTORE, + ) return@launch } session = restored @@ -137,12 +192,20 @@ public class AccountStore internal constructor( throw cancelled } catch (error: CloudAccountException) { if (error.failure == CloudAccountFailure.AUTHENTICATION) { - expireSession(error.failure.toUiReason()) + expireSession(error.failure.toUiReason(), AccountFailureStage.DEVICE_LIST) } else { - _state.value = AccountUiState.Failed(error.failure.toUiReason(), true) + _state.value = AccountUiState.Failed( + error.failure.toUiReason(), + true, + AccountFailureStage.DEVICE_LIST, + ) } } catch (_: Throwable) { - _state.value = AccountUiState.Failed(AccountFailureReason.NETWORK, true) + _state.value = AccountUiState.Failed( + AccountFailureReason.NETWORK, + true, + AccountFailureStage.DEVICE_LIST, + ) } } } @@ -151,38 +214,122 @@ public class AccountStore internal constructor( work?.cancel() _state.value = AccountUiState.SigningIn work = scope.launch { - try { - val loggedIn = backend.login( + val loggedIn = try { + backend.login( intent.relayUrl, intent.username, intent.password, deviceId, deviceName, ) - val devices = backend.listDevices(loggedIn, deviceId) - // Never this device, even as a fallback: driving the phone from - // the phone is what `canSelectAccountDevice` forbids, and a - // target nothing can be asked of is worse than none at all. - val preferred = AccountDevicePolicy.preferredTarget(devices, deviceId) - val selected = loggedIn.copy( - targetDeviceId = preferred?.id, - targetDeviceName = preferred?.name, - ) - secureStore.write(SESSION_KEY, encodeRecord(selected).encodeToByteArray()) - session = selected - publishReady(selected, devices) } catch (cancelled: CancellationException) { throw cancelled } catch (error: CloudAccountException) { - session = null - _state.value = AccountUiState.Failed(error.failure.toUiReason(), true) + failLogin(error.failure.toUiReason(), AccountFailureStage.AUTHENTICATION) + return@launch } catch (_: Throwable) { - session = null - _state.value = AccountUiState.Failed(AccountFailureReason.SECURE_STORAGE, true) + // Live transport failures are normalized by CloudAccountClient. + // An untyped failure here is therefore a crypto/protocol failure, + // never evidence that secure storage was involved. + failLogin(AccountFailureReason.MALFORMED_RESPONSE, AccountFailureStage.AUTHENTICATION) + return@launch } + + controllableDevices = emptyList() + if (!persistLogin(loggedIn)) return@launch + session = loggedIn + + val devices = loadDevices(loggedIn) ?: return@launch + + // Never this device, even as a fallback: driving the phone from + // the phone is what `canSelectAccountDevice` forbids, and a + // target nothing can be asked of is worse than none at all. + val preferred = AccountDevicePolicy.preferredTarget(devices, deviceId) + val selected = loggedIn.copy( + targetDeviceId = preferred?.id, + targetDeviceName = preferred?.name, + ) + if (!persistLogin(selected)) return@launch + session = selected + publishReady(selected, devices) + } + } + + private suspend fun loadDevices(current: AccountSessionData): List? = try { + backend.listDevices(current, deviceId) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (error: CloudAccountException) { + _state.value = AccountUiState.Failed( + error.failure.toUiReason(), + true, + AccountFailureStage.DEVICE_LIST, + ) + null + } catch (_: Throwable) { + _state.value = AccountUiState.Failed( + AccountFailureReason.NETWORK, + true, + AccountFailureStage.DEVICE_LIST, + ) + null + } + + private fun retryFailedStage() { + val failed = _state.value as? AccountUiState.Failed ?: return + val current = session ?: return + if (!failed.canRetry || failed.stage != AccountFailureStage.DEVICE_LIST) return + work?.cancel() + _state.value = AccountUiState.SigningIn + work = scope.launch { + val devices = loadDevices(current) ?: return@launch + val preferred = current.targetDeviceId + ?.let { selectedId -> devices.firstOrNull { it.id == selectedId } } + ?.takeIf { AccountDevicePolicy.canSelect(it, deviceId) } + ?: AccountDevicePolicy.preferredTarget(devices, deviceId) + val selected = current.copy( + targetDeviceId = preferred?.id, + targetDeviceName = preferred?.name, + ) + // This retry only refreshes the volatile device-list projection. The + // authenticated bytes saved before the failed list request stay exact. + session = selected + publishReady(selected, devices) + } + } + + private fun persistLogin(value: AccountSessionData): Boolean { + val previous = try { + secureStore.read(SESSION_KEY) + } catch (_: Throwable) { + failLogin(AccountFailureReason.SECURE_STORAGE, AccountFailureStage.SECURE_STORAGE) + return false + } + return try { + secureStore.write(SESSION_KEY, encodeRecord(value).encodeToByteArray()) + true + } catch (_: Throwable) { + // Platform secure stores are expected to update atomically. Restore a + // fake or adapter that mutated before reporting failure as an extra + // compatibility guard, without ever deleting pre-existing bytes. + try { + if (previous != null) secureStore.write(SESSION_KEY, previous) + else secureStore.delete(SESSION_KEY) + } catch (_: Throwable) { + // The observable session still fails closed below. The original + // write contract must preserve its previous value on failure. + } + failLogin(AccountFailureReason.SECURE_STORAGE, AccountFailureStage.SECURE_STORAGE) + false } } + private fun failLogin(reason: AccountFailureReason, stage: AccountFailureStage) { + session = null + controllableDevices = emptyList() + _state.value = AccountUiState.Failed(reason, true, stage) + } + private fun selectDevice(targetId: String) { val current = session ?: return val ready = _state.value as? AccountUiState.Ready ?: return @@ -192,13 +339,9 @@ public class AccountStore internal constructor( ?.takeIf { AccountDevicePolicy.canSelect(it, deviceId) } ?: return val updated = current.copy(targetDeviceId = selected.id, targetDeviceName = selected.name) - try { - secureStore.write(SESSION_KEY, encodeRecord(updated).encodeToByteArray()) - session = updated - _state.value = ready.copy(selectedDeviceId = selected.id, selectedDeviceName = selected.name) - } catch (_: Throwable) { - _state.value = AccountUiState.Failed(AccountFailureReason.SECURE_STORAGE, true) - } + if (!persistLogin(updated)) return + session = updated + _state.value = ready.copy(selectedDeviceId = selected.id, selectedDeviceName = selected.name) } /** @@ -221,7 +364,7 @@ public class AccountStore internal constructor( throw cancelled } catch (error: CloudAccountException) { if (error.failure == CloudAccountFailure.AUTHENTICATION) { - expireSession(error.failure.toUiReason()) + expireSession(error.failure.toUiReason(), AccountFailureStage.DEVICE_LIST) } else { _state.value = ready.copy(refreshing = false, refreshFailure = error.failure.toUiReason()) } @@ -234,19 +377,28 @@ public class AccountStore internal constructor( private fun logout() { work?.cancel() work = null + // Logout is immediately observable even when Keychain cannot remove the + // durable record. A stale persisted record must never keep capabilities + // active in this process. + session = null + controllableDevices = emptyList() + _state.value = AccountUiState.SignedOut try { secureStore.delete(SESSION_KEY) - session = null - _state.value = AccountUiState.SignedOut } catch (_: Throwable) { - _state.value = AccountUiState.Failed(AccountFailureReason.SECURE_STORAGE, true) + _state.value = AccountUiState.Failed( + AccountFailureReason.SECURE_STORAGE, + true, + AccountFailureStage.SECURE_STORAGE, + ) } } /** Clears every observable and persisted fact owned by an expired token. */ - private fun expireSession(reason: AccountFailureReason) { + private fun expireSession(reason: AccountFailureReason, stage: AccountFailureStage) { session = null - _state.value = AccountUiState.Failed(reason, true) + controllableDevices = emptyList() + _state.value = AccountUiState.Failed(reason, false, stage) try { secureStore.delete(SESSION_KEY) } catch (_: Throwable) { @@ -261,10 +413,11 @@ public class AccountStore internal constructor( * different answers on two platforms. */ private fun publishReady(current: AccountSessionData, devices: List) { + controllableDevices = AccountDevicePolicy.controlTargets(devices, deviceId) _state.value = AccountUiState.Ready( userId = current.userId, username = current.username, - devices = AccountDevicePolicy.controlTargets(devices, deviceId), + devices = controllableDevices, selectedDeviceId = current.targetDeviceId, selectedDeviceName = current.targetDeviceName, ) diff --git a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/bitfun/mobile/core/feature/account/AccountUiState.kt b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/bitfun/mobile/core/feature/account/AccountUiState.kt index 1a1d2c35a3..b1d2b5c026 100644 --- a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/bitfun/mobile/core/feature/account/AccountUiState.kt +++ b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/bitfun/mobile/core/feature/account/AccountUiState.kt @@ -11,6 +11,13 @@ public enum class AccountFailureReason { SECURE_STORAGE, } +public enum class AccountFailureStage { + RESTORE, + AUTHENTICATION, + DEVICE_LIST, + SECURE_STORAGE, +} + public data class AccountDeviceUi public constructor( public val id: String, public val name: String, @@ -60,6 +67,7 @@ public sealed interface AccountUiState { public data class Failed public constructor( public val reason: AccountFailureReason, public val canRetry: Boolean, + public val stage: AccountFailureStage, ) : AccountUiState } @@ -82,6 +90,9 @@ public sealed interface AccountIntent { * again, and this is that something. */ public data object RefreshDevices : AccountIntent + + /** Retry the failed device-list stage with the authenticated session already in memory. */ + public data object Retry : AccountIntent public data object Logout : AccountIntent public data object Stop : AccountIntent } diff --git a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/bitfun/mobile/core/feature/directory/DeviceDirectoryStore.kt b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/bitfun/mobile/core/feature/directory/DeviceDirectoryStore.kt new file mode 100644 index 0000000000..82d819b485 --- /dev/null +++ b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/bitfun/mobile/core/feature/directory/DeviceDirectoryStore.kt @@ -0,0 +1,370 @@ +package com.bitfun.mobile.core.feature.directory + +import com.bitfun.mobile.core.domain.RemoteSession +import com.bitfun.mobile.core.feature.account.AccountStore +import com.bitfun.mobile.core.feature.session.RemoteSessionFailureReason +import com.bitfun.mobile.core.feature.session.RemoteSessionIntent +import com.bitfun.mobile.core.feature.session.RemoteSessionStore +import com.bitfun.mobile.core.feature.session.RemoteSessionUiState +import com.bitfun.mobile.core.feature.workspace.RemoteWorkspaceIntent +import com.bitfun.mobile.core.feature.workspace.RemoteWorkspaceStore +import com.bitfun.mobile.core.feature.workspace.RemoteWorkspaceUiState +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch + +/** + * A per-device directory fan-out. + * + * Each account device gets its own [RemoteSessionStore] and [RemoteWorkspaceStore] + * keyed by the device id, so one device loading, failing, or stopping never + * clears another. This store owns only the fan-out coordination; transport, + * protocol, and persistence stay inside the stores it reuses. + */ +public class DeviceDirectoryStore internal constructor( + private val scope: CoroutineScope, + private val factory: DeviceStoreFactory, +) { + private val _state = MutableStateFlow(DeviceDirectoryUiState(emptyList())) + public val state: StateFlow = _state.asStateFlow() + + private val devices = linkedMapOf() + private val slots = mutableMapOf() + private val loads = mutableMapOf() + private val generations = mutableMapOf() + private val epochs = mutableMapOf() + + public fun dispatch(intent: DeviceDirectoryIntent) { + when (intent) { + is DeviceDirectoryIntent.Sync -> sync(intent.devices) + is DeviceDirectoryIntent.Load -> load(intent.deviceId) + is DeviceDirectoryIntent.Expand -> expand(intent.deviceId) + is DeviceDirectoryIntent.Collapse -> collapse(intent.deviceId) + is DeviceDirectoryIntent.Retry -> retry(intent.deviceId) + DeviceDirectoryIntent.Stop -> stop() + } + } + + /** Captures the membership epoch that a later confirmed create must match. */ + public fun reconcileKey(deviceId: String): DeviceDirectoryReconcileKey? { + val id = deviceId.trim() + val entry = devices[id] ?: return null + if (!entry.online) return null + return DeviceDirectoryReconcileKey(id, epochs[id] ?: return null) + } + + /** + * Merges one server-confirmed create into only its owning device and writes + * that device's session-list persistence. A stale membership key is rejected + * synchronously, so logout/removal/reconnect cannot resurrect an old row. + */ + public fun reconcileCreatedSession( + key: DeviceDirectoryReconcileKey, + session: RemoteSession, + ): Boolean { + val id = key.deviceId.trim() + val current = devices[id] ?: return false + if (!current.online || epochs[id] != key.epoch || session.id.isBlank()) return false + val slot = slotFor(id) ?: return false + if (!slot.sessionStore.reconcileConfirmedCreatedSession(session)) return false + val sessions = mergeSession(current.sessions, session) + devices[id] = current.copy(sessions = sessions) + publish() + return true + } + + /** + * Cancels every running load and releases the underlying stores, while + * keeping the directory entries themselves: already-loaded data stays on + * screen and in memory, so a later stop/resume does not need a re-fetch. + */ + public fun stop() { + for (job in loads.values) job.cancel() + loads.clear() + for (id in devices.keys) { + invalidate(id) + invalidateEpoch(id) + if (devices[id]?.status == DeviceDirectoryStatus.LOADING) { + devices[id] = devices.getValue(id).copy(status = DeviceDirectoryStatus.IDLE, error = null) + } + } + publish() + for (slot in slots.values) stopSlot(slot) + slots.clear() + } + + private fun sync(incoming: List) { + val ids = linkedSetOf() + val updated = linkedMapOf() + for (device in incoming) { + val id = device.deviceId.trim() + if (id.isEmpty()) continue + ids += id + val existing = devices[id] + if (existing == null) { + invalidateEpoch(id) + } else if (existing.online && !device.online) { + invalidate(id) + invalidateEpoch(id) + loads.remove(id)?.cancel() + slots.remove(id)?.let(::stopSlot) + } + updated[id] = if (existing == null) { + DeviceDirectoryEntry.empty(id, device.deviceName, device.online) + } else { + existing.copy( + deviceName = device.deviceName, + online = device.online, + expanded = if (device.online) existing.expanded else false, + status = if (device.online) { + existing.status + } else if (existing.workspaces.isNotEmpty() || existing.sessions.isNotEmpty()) { + DeviceDirectoryStatus.CACHED + } else { + DeviceDirectoryStatus.IDLE + }, + error = if (device.online) existing.error else null, + ) + } + } + val removed = devices.keys - ids + devices.clear() + devices.putAll(updated) + publish() + for (id in removed) { + invalidate(id) + invalidateEpoch(id) + loads.remove(id)?.cancel() + slots.remove(id)?.let(::stopSlot) + } + } + + private fun load(deviceId: String) { + val id = deviceId.trim() + if (id.isEmpty()) return + val entry = devices[id] ?: return + if (!entry.online) return + if (loads[id]?.isActive == true) return + if (entry.status == DeviceDirectoryStatus.READY) return + val slot = slotFor(id) + if (slot == null) { + setEntry(id) { it.copy(status = DeviceDirectoryStatus.FAILED, error = DeviceDirectoryFailure.NOT_SIGNED_IN) } + return + } + startLoad(id, slot, entry) + } + + private fun expand(deviceId: String) { + val id = deviceId.trim() + if (id.isEmpty()) return + val entry = devices[id] ?: return + devices[id] = entry.copy(expanded = true) + publish() + if (entry.status == DeviceDirectoryStatus.READY) return + if (entry.online) load(id) + } + + private fun collapse(deviceId: String) { + val id = deviceId.trim() + if (id.isEmpty()) return + val entry = devices[id] ?: return + devices[id] = entry.copy(expanded = false) + publish() + } + + private fun retry(deviceId: String) { + val id = deviceId.trim() + if (id.isEmpty()) return + val entry = devices[id] ?: return + if (!entry.online) return + if (loads[id]?.isActive == true) return + val expanded = entry.copy(expanded = true) + devices[id] = expanded + publish() + val slot = slotFor(id) + if (slot == null) { + setEntry(id) { it.copy(status = DeviceDirectoryStatus.FAILED, error = DeviceDirectoryFailure.NOT_SIGNED_IN) } + return + } + startLoad(id, slot, expanded) + } + + private fun slotFor(id: String): DeviceSlot? { + slots[id]?.let { return it } + val sessionStore = factory.createSessionStore(scope, id) ?: return null + val workspaceStore = try { + factory.createWorkspaceStore(scope, id) + } catch (error: Throwable) { + sessionStore.stop() + throw error + } + if (workspaceStore == null) { + sessionStore.stop() + return null + } + val slot = DeviceSlot(sessionStore, workspaceStore) + slots[id] = slot + return slot + } + + private fun startLoad(id: String, slot: DeviceSlot, previous: DeviceDirectoryEntry) { + val generation = nextGeneration(id) + setEntry(id) { it.copy(status = DeviceDirectoryStatus.LOADING, error = null) } + val job = scope.launch { + try { + runLoad(id, slot, generation) + } catch (cancelled: CancellationException) { + // Cancellation from stop/offline/remove must not restore an entry + // after a newer generation has already started. + val current = devices[id] + if (isCurrent(id, generation) && current?.status == DeviceDirectoryStatus.LOADING) { + devices[id] = previous.copy(expanded = current.expanded) + publish() + } + throw cancelled + } finally { + // Never remove a newer job installed for the same device. + if (loads[id] === coroutineContext[Job]) loads.remove(id) + } + } + loads[id] = job + if (!job.isActive && loads[id] === job) loads.remove(id) + } + + private suspend fun runLoad(id: String, slot: DeviceSlot, generation: Long) { + if (!isCurrent(id, generation)) return + slot.sessionStore.dispatch(RemoteSessionIntent.Load) + slot.workspaceStore.dispatch(RemoteWorkspaceIntent.Load) + val sessionState = slot.sessionStore.state.first(::sessionSettled) + if (!isCurrent(id, generation)) return + val workspaceState = slot.workspaceStore.state.first(::workspaceSettled) + if (!isCurrent(id, generation)) return + projectResult(id, generation, sessionState, workspaceState) + } + + private fun sessionSettled(state: RemoteSessionUiState): Boolean = when (state) { + is RemoteSessionUiState.Failed -> true + is RemoteSessionUiState.Ready -> !state.busy + else -> false + } + + private fun workspaceSettled(state: RemoteWorkspaceUiState): Boolean = when (state) { + is RemoteWorkspaceUiState.Ready -> true + is RemoteWorkspaceUiState.Failed -> true + else -> false + } + + private fun projectResult( + id: String, + generation: Long, + sessionState: RemoteSessionUiState, + workspaceState: RemoteWorkspaceUiState, + ) { + if (!isCurrent(id, generation)) return + val current = devices[id] ?: return + // A device that went offline while its load was in flight must not come + // back as READY with stale data; it stays in whatever offline state + // `sync` left it in and the in-flight result is dropped. + if (!current.online) return + val workspaces = (workspaceState as? RemoteWorkspaceUiState.Ready)?.workspaces.orEmpty() + val sessions = (sessionState as? RemoteSessionUiState.Ready)?.sessions.orEmpty() + val sessionFailed = sessionState as? RemoteSessionUiState.Failed + val workspaceFailed = workspaceState as? RemoteWorkspaceUiState.Failed + devices[id] = if (sessionFailed == null && workspaceFailed == null) { + current.copy( + status = DeviceDirectoryStatus.READY, + error = null, + workspaces = workspaces, + sessions = sessions, + ) + } else { + current.copy( + status = DeviceDirectoryStatus.FAILED, + error = sessionFailed?.let { mapSessionFailure(it.reason) } ?: DeviceDirectoryFailure.LOAD_FAILED, + workspaces = workspaces, + sessions = sessions, + ) + } + publish() + } + + private fun mapSessionFailure(reason: RemoteSessionFailureReason): DeviceDirectoryFailure = when (reason) { + RemoteSessionFailureReason.NETWORK -> DeviceDirectoryFailure.NETWORK + RemoteSessionFailureReason.TIMEOUT -> DeviceDirectoryFailure.TIMEOUT + RemoteSessionFailureReason.RATE_LIMITED -> DeviceDirectoryFailure.RATE_LIMITED + RemoteSessionFailureReason.NO_WORKSPACE -> DeviceDirectoryFailure.NO_WORKSPACE + RemoteSessionFailureReason.REMOTE_REJECTED -> DeviceDirectoryFailure.REJECTED + else -> DeviceDirectoryFailure.LOAD_FAILED + } + + private fun nextGeneration(id: String): Long { + val next = (generations[id] ?: 0L) + 1L + generations[id] = next + return next + } + + private fun invalidate(id: String) { + generations[id] = (generations[id] ?: 0L) + 1L + } + + private fun invalidateEpoch(id: String) { + epochs[id] = (epochs[id] ?: 0L) + 1L + } + + private fun mergeSession( + sessions: List, + confirmed: RemoteSession, + ): List = + listOf(confirmed) + sessions.filterNot { it.id == confirmed.id } + + private fun isCurrent(id: String, generation: Long): Boolean = generations[id] == generation + + private inline fun setEntry(id: String, transform: (DeviceDirectoryEntry) -> DeviceDirectoryEntry) { + val entry = devices[id] ?: return + devices[id] = transform(entry) + publish() + } + + private fun stopSlot(slot: DeviceSlot) { + slot.sessionStore.stop() + slot.workspaceStore.stop() + } + + private fun publish() { + _state.value = DeviceDirectoryUiState(devices.values.toList()) + } + + public companion object { + public fun create(scope: CoroutineScope, accountStore: AccountStore): DeviceDirectoryStore = + DeviceDirectoryStore(scope, AccountDeviceStoreFactory(accountStore)) + + internal fun create(scope: CoroutineScope, factory: DeviceStoreFactory): DeviceDirectoryStore = + DeviceDirectoryStore(scope, factory) + } +} + +/** Creates a device-keyed store pair through [AccountStore]'s explicit-device entry points. */ +private class AccountDeviceStoreFactory( + private val accountStore: AccountStore, +) : DeviceStoreFactory { + override fun createSessionStore(scope: CoroutineScope, deviceId: String): RemoteSessionStore? = + accountStore.createSessionStore(scope, deviceId) + + override fun createWorkspaceStore(scope: CoroutineScope, deviceId: String): RemoteWorkspaceStore? = + accountStore.createWorkspaceStore(scope, deviceId) +} + +internal interface DeviceStoreFactory { + fun createSessionStore(scope: CoroutineScope, deviceId: String): RemoteSessionStore? + fun createWorkspaceStore(scope: CoroutineScope, deviceId: String): RemoteWorkspaceStore? +} + +private class DeviceSlot( + val sessionStore: RemoteSessionStore, + val workspaceStore: RemoteWorkspaceStore, +) diff --git a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/bitfun/mobile/core/feature/directory/DeviceDirectoryUiState.kt b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/bitfun/mobile/core/feature/directory/DeviceDirectoryUiState.kt new file mode 100644 index 0000000000..62028083dd --- /dev/null +++ b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/bitfun/mobile/core/feature/directory/DeviceDirectoryUiState.kt @@ -0,0 +1,116 @@ +package com.bitfun.mobile.core.feature.directory + +import com.bitfun.mobile.core.domain.RecentWorkspace +import com.bitfun.mobile.core.domain.RemoteSession + +/** + * Where one device's directory entry is in its load lifecycle. + * + * [CACHED] means an offline device still has non-empty workspace or session + * data retained from an earlier successful load. An offline device with no + * retained data remains [IDLE]; `online = false` carries the offline fact. + * [CACHED] is therefore not a generic offline marker or a promise of disk + * hydration. Online entries transition IDLE -> LOADING -> READY/FAILED; + * online -> offline changes READY/LOADING to CACHED only when data exists, and + * offline -> online permits a new load/retry. + */ +public enum class DeviceDirectoryStatus { + IDLE, + CACHED, + LOADING, + READY, + FAILED, +} + +/** Why a device's directory content cannot be shown. */ +public enum class DeviceDirectoryFailure { + NOT_SIGNED_IN, + NO_WORKSPACE, + REJECTED, + NETWORK, + TIMEOUT, + RATE_LIMITED, + LOAD_FAILED, +} + +/** One device the account directory knows about. */ +public data class DeviceDirectoryDevice public constructor( + public val deviceId: String, + public val deviceName: String, + public val online: Boolean, +) { + /** A device whose display name is unknown; the id stands in until one arrives. */ + public constructor(deviceId: String, online: Boolean) : this(deviceId, deviceId, online) +} + +/** One device's directory state: identity plus the content it loaded. */ +public data class DeviceDirectoryEntry public constructor( + public val deviceId: String, + public val deviceName: String, + public val online: Boolean, + public val expanded: Boolean, + public val status: DeviceDirectoryStatus, + public val error: DeviceDirectoryFailure?, + public val workspaces: List, + public val sessions: List, +) { + public companion object { + public fun empty(deviceId: String, deviceName: String, online: Boolean): DeviceDirectoryEntry = + DeviceDirectoryEntry( + deviceId = deviceId, + deviceName = deviceName, + online = online, + expanded = false, + status = DeviceDirectoryStatus.IDLE, + error = null, + workspaces = emptyList(), + sessions = emptyList(), + ) + } +} + +/** The whole device directory a sidebar renders. */ +public data class DeviceDirectoryUiState public constructor( + public val devices: List, +) { + public fun device(deviceId: String): DeviceDirectoryEntry? = + devices.firstOrNull { it.deviceId == deviceId } +} + +/** + * Capability for reconciling a create result into one authenticated device row. + * + * The opaque [epoch] binds a result to the device membership snapshot in which + * the create started. Callers obtain this immediately before creating and must + * return the same key with the confirmed session. + */ +public data class DeviceDirectoryReconcileKey public constructor( + public val deviceId: String, + public val epoch: Long, +) + +/** Intents the directory store handles. */ +public sealed interface DeviceDirectoryIntent { + /** Replace the device list from the account's latest device projection. */ + public data class Sync public constructor( + public val devices: List, + ) : DeviceDirectoryIntent + + public data class Load public constructor( + public val deviceId: String, + ) : DeviceDirectoryIntent + + public data class Expand public constructor( + public val deviceId: String, + ) : DeviceDirectoryIntent + + public data class Collapse public constructor( + public val deviceId: String, + ) : DeviceDirectoryIntent + + public data class Retry public constructor( + public val deviceId: String, + ) : DeviceDirectoryIntent + + public data object Stop : DeviceDirectoryIntent +} diff --git a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/bitfun/mobile/core/feature/generalchat/GeneralChatStore.kt b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/bitfun/mobile/core/feature/generalchat/GeneralChatStore.kt index 1678449e4b..17a21f52b8 100644 --- a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/bitfun/mobile/core/feature/generalchat/GeneralChatStore.kt +++ b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/bitfun/mobile/core/feature/generalchat/GeneralChatStore.kt @@ -84,19 +84,40 @@ public class GeneralChatStore internal constructor( private var sequence: Long = 0 init { - val stored = config.snapshot() val sessions = listSessions() sessionId = sessions.firstOrNull()?.id ?: newSessionId() timeline.reset(sessionId) timeline.setPersistedMessages(chats.loadMessages(sessionId).map(::restoreMessage)) + + var stored = GeneralChatConfigUi("", "", false) + var configured = false + var configFailure: GeneralChatConfigFailure? = null + var models = emptyList() + var activeModelId = "" + try { + stored = config.snapshot() + configured = config.activeEndpoint() != null + models = config.catalog() + activeModelId = config.activeModelId() + } catch (_: Throwable) { + // A platform keystore failure is not the same as an empty config. + // Keep all provider data out of observable state and do not retry the + // secure store while restoring the independent local conversation. + stored = GeneralChatConfigUi("", "", false) + configured = false + configFailure = GeneralChatConfigFailure.SECURE_STORAGE + models = emptyList() + activeModelId = "" + } + _state = MutableStateFlow( GeneralChatUiState( - configured = config.activeEndpoint() != null, + configured = configured, config = stored, - configFailure = null, + configFailure = configFailure, connectionTest = GeneralChatConnectionTestUi(), - models = config.catalog(), - activeModelId = config.activeModelId(), + models = models, + activeModelId = activeModelId, sessionId = sessionId, sessions = sessions, timeline = timeline.snapshot(), diff --git a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/bitfun/mobile/core/feature/session/RemoteSessionStore.kt b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/bitfun/mobile/core/feature/session/RemoteSessionStore.kt index 1556f47bde..4b0a3da490 100644 --- a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/bitfun/mobile/core/feature/session/RemoteSessionStore.kt +++ b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/bitfun/mobile/core/feature/session/RemoteSessionStore.kt @@ -38,13 +38,18 @@ import com.bitfun.mobile.core.protocol.WorkspaceInfoResponse import com.bitfun.mobile.core.transport.PairedRoom import com.bitfun.mobile.core.transport.RemoteCommandTransport import com.bitfun.mobile.core.transport.send +import com.bitfun.mobile.core.feature.workspace.RemoteWorkspaceIntent +import com.bitfun.mobile.core.feature.workspace.RemoteWorkspaceStore +import com.bitfun.mobile.core.feature.workspace.RemoteWorkspaceUiState import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeout import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.buildJsonObject @@ -66,6 +71,14 @@ public class RemoteSessionStore internal constructor( public val state: StateFlow = _state.asStateFlow() private val _connectionPhase = MutableStateFlow(ConnectionPhase.IDLE) public val connectionPhase: StateFlow = _connectionPhase.asStateFlow() + private val _createOperation = MutableStateFlow(CreateSessionOperationState.Idle) + /** Outcome is changed only by create operations, never by open/list selection. */ + public val createOperation: StateFlow = _createOperation.asStateFlow() + private var nextCreateRequestId: Long = 0 + private var nextCreateGeneration: Long = 0 + private var activeCreateGeneration: Long? = null + private var authorityRevision: Long = 0 + private var workGeneration: Long = 0 private val timelineStore = ChatTimelineStore() private val controller = ChatSessionController.create(scope, RoomPoller(transport), ControllerCallbacks()) private var work: Job? = null @@ -102,7 +115,13 @@ public class RemoteSessionStore internal constructor( load(intent.query, current?.agentFilter ?: SessionAgentFilter.ALL) is RemoteSessionIntent.SetAgentFilter -> load(current?.query.orEmpty(), intent.filter) is RemoteSessionIntent.Open -> open(intent.sessionId) - is RemoteSessionIntent.CreateSession -> createSession(intent) + is RemoteSessionIntent.CreateSession -> createSession(intent, nextRequestId()) + is RemoteSessionIntent.CreateSessionOperation -> createSession( + RemoteSessionIntent.CreateSession( + intent.agentType, intent.title, intent.instruction, intent.modelId, intent.workspacePath, + ), + intent.requestId.trim().ifEmpty { nextRequestId() }, + ) is RemoteSessionIntent.DeleteSession -> deleteSession(intent.sessionId) is RemoteSessionIntent.RenameSession -> renameSession(intent) is RemoteSessionIntent.AnswerQuestion -> runAction( @@ -156,8 +175,196 @@ public class RemoteSessionStore internal constructor( } } - public fun stop() { + private fun nextRequestId(): String { + nextCreateRequestId += 1 + return "create-${nextCreateRequestId}" + } + + /** + * Selects an assistant workspace on this same remote target before creating. + * The create command is not sent until the selection store reports that exact + * assistant path, preventing a stale workspace from receiving the request. + */ + public fun createAssistantSession( + workspaceStore: RemoteWorkspaceStore, + requestId: String, + assistantPath: String, + title: String, + instruction: String, + modelId: String?, + ) { + val normalizedRequestId = requestId.trim().ifEmpty { nextRequestId() } + val normalizedPath = assistantPath.trim() + if (workspaceStore.deviceKey == null || workspaceStore.deviceKey != deviceKey) { + _createOperation.value = CreateSessionOperationState.Failed( + normalizedRequestId, CreateSessionOperationFailure.DEVICE_MISMATCH, false, false, + ) + return + } + if (normalizedPath.isEmpty()) { + _createOperation.value = CreateSessionOperationState.Failed( + normalizedRequestId, CreateSessionOperationFailure.WORKSPACE, true, false, + ) + return + } + _createOperation.value = CreateSessionOperationState.InFlight(normalizedRequestId, deviceKey, normalizedPath) + nextCreateGeneration += 1 + activeCreateGeneration = nextCreateGeneration + val generation = nextCreateGeneration + val stopVersion = workspaceStore.stopVersion.value + val operationToken = beginWork() + work = scope.launch { + try { + if (workspaceStore.stopVersion.value != stopVersion) { + cancelCreateIfActive(normalizedRequestId, generation, CreateSessionOperationFailure.CANCELLED) + return@launch + } + if (workspaceStore.state.value is RemoteWorkspaceUiState.Idle) { + workspaceStore.dispatch(RemoteWorkspaceIntent.Load) + } + val beforeSelection = withTimeout(30_000) { + workspaceStore.state.first { state -> + workspaceStore.stopVersion.value != stopVersion || when (state) { + is RemoteWorkspaceUiState.Ready -> !state.busy + is RemoteWorkspaceUiState.Failed -> true + else -> false + } + } + } + if (!isCurrentWork(operationToken) || activeCreateGeneration != generation) return@launch + if (workspaceStore.stopVersion.value != stopVersion) { + cancelCreateIfActive(normalizedRequestId, generation, CreateSessionOperationFailure.CANCELLED) + return@launch + } + if (beforeSelection !is RemoteWorkspaceUiState.Ready) { + failCreate(normalizedRequestId, generation, CreateSessionOperationFailure.WORKSPACE, true, false) + return@launch + } + if (beforeSelection.assistants.none { it.path == normalizedPath }) { + failCreate(normalizedRequestId, generation, CreateSessionOperationFailure.WORKSPACE, false, false) + return@launch + } + val targetSelected = beforeSelection.selected?.path == normalizedPath && + beforeSelection.assistants.any { it.path == normalizedPath } + if (!targetSelected) { + workspaceStore.dispatch(RemoteWorkspaceIntent.SelectAssistant(normalizedPath)) + } + val selected = withTimeout(30_000) { + workspaceStore.state.first { state -> + workspaceStore.stopVersion.value != stopVersion || when (state) { + is RemoteWorkspaceUiState.Ready -> !state.busy && + state.selected?.path == normalizedPath && + state.assistants.any { it.path == normalizedPath } + is RemoteWorkspaceUiState.Failed -> true + else -> false + } + } + } + if (!isCurrentWork(operationToken) || activeCreateGeneration != generation) return@launch + if (workspaceStore.stopVersion.value != stopVersion || selected !is RemoteWorkspaceUiState.Ready) { + failCreate(normalizedRequestId, generation, CreateSessionOperationFailure.WORKSPACE, true, false) + return@launch + } + work = null + createSession( + RemoteSessionIntent.CreateSession( + agentType = "cowork", + title = title, + instruction = instruction, + modelId = modelId, + workspacePath = normalizedPath, + ), + normalizedRequestId, + ) + } catch (cancelled: CancellationException) { + cancelCreateIfActive(normalizedRequestId, generation, CreateSessionOperationFailure.CANCELLED) + throw cancelled + } catch (_: Throwable) { + if (isCurrentWork(operationToken)) { + failCreate(normalizedRequestId, generation, CreateSessionOperationFailure.WORKSPACE, true, false) + } + } + } + } + + /** + * Accepts a create result already confirmed by the owning remote device. + * The supplied projection is authoritative, including its own workspace; + * no active-workspace state is consulted. Repeating the same id is idempotent. + */ + public fun reconcileConfirmedCreatedSession(session: RemoteSession): Boolean { + beginWork() + return projectConfirmedCreatedSession(session) + } + + private fun projectConfirmedCreatedSession(session: RemoteSession): Boolean { + val sessionId = session.id.trim() + if (sessionId.isEmpty()) return false + val confirmed = if (sessionId == session.id) session else session.copy(id = sessionId) + locallyCreatedSessions[sessionId] = confirmed + val current = _state.value as? RemoteSessionUiState.Ready + if (current != null) { + publishAuthorityReady(current.copy(sessions = mergeConfirmed(current.sessions, confirmed))) + } + if (persistenceEnabled) { + val persistedRows = persistence!!.remoteSessions.load(deviceKey!!) + restorePendingConfirmed(persistedRows) + persistence.remoteSessions.save( + deviceKey, + mergeConfirmed(persistedRows.map(::toRemoteSession), confirmed).map(::toPersistedSession), + persistence.remoteSessions.hasMore(deviceKey), + ) + } + return true + } + + private fun publishCommittedCreate(session: RemoteSession, previous: RemoteSessionUiState.Ready?): Long { + projectConfirmedCreatedSession(session) + if (_state.value !is RemoteSessionUiState.Ready) { + publishAuthorityReady(RemoteSessionUiState.Ready( + sessions = listOf(session), + selectedSessionId = session.id, + timeline = null, + busy = true, + permissionMode = previous?.permissionMode, + permissionModeFailure = previous?.permissionModeFailure, + query = previous?.query.orEmpty(), + agentFilter = previous?.agentFilter ?: SessionAgentFilter.ALL, + hasMore = previous?.hasMore ?: false, + hasMoreMessages = false, + modelCatalog = modelCatalog ?: previous?.modelCatalog, + modelCatalogFailure = modelCatalogFailure ?: previous?.modelCatalogFailure, + draft = "", + )) + } + return (_state.value as RemoteSessionUiState.Ready).revision + } + + private fun publishAuthorityReady(ready: RemoteSessionUiState.Ready): Long { + authorityRevision += 1 + _state.value = ready.copy(revision = authorityRevision) + return authorityRevision + } + + private fun beginWork(): Long { + workGeneration += 1 work?.cancel() + return workGeneration + } + + private fun isCurrentWork(token: Long): Boolean = token == workGeneration + + public fun stop() { + activeCreateGeneration?.let { generation -> + val requestId = (_createOperation.value as? CreateSessionOperationState.InFlight)?.requestId + if (requestId != null) { + _createOperation.value = CreateSessionOperationState.Cancelled( + requestId, CreateSessionOperationFailure.CANCELLED, + ) + } + activeCreateGeneration = null + } + beginWork() work = null turnEndSync?.cancel() turnEndSync = null @@ -170,34 +377,41 @@ public class RemoteSessionStore internal constructor( val current = _state.value as? RemoteSessionUiState.Ready if (current == null && persistenceEnabled) { val cached = persistence!!.remoteSessions.load(deviceKey!!) + restorePendingConfirmed(cached) if (cached.isNotEmpty()) { - _state.value = RemoteSessionUiState.Ready( + publishAuthorityReady(RemoteSessionUiState.Ready( sessions = cached.map(::toRemoteSession), selectedSessionId = null, timeline = null, busy = true, permissionMode = null, permissionModeFailure = null, query = query, agentFilter = filter, hasMore = persistence.remoteSessions.hasMore(deviceKey), hasMoreMessages = false, modelCatalog = null, - ) + )) } } if (current == null) _connectionPhase.value = ConnectionPhase.CONNECTING - work?.cancel() + val generation = beginWork() // Searching or switching tabs keeps the list on screen; only a cold start // blanks it, so typing in the search box does not flash a spinner. _state.value = (_state.value as? RemoteSessionUiState.Ready)?.copy(busy = true, query = query, agentFilter = filter) ?: RemoteSessionUiState.Loading work = scope.launch { try { - if (!resolveWorkspacePath()) { + val workspaceResolved = resolveWorkspacePath(generation) + if (!isCurrentWork(generation)) return@launch + if (!workspaceResolved) { failKnown(RemoteSessionFailureReason.NO_WORKSPACE, current) return@launch } val page = listSessions(0, query, filter) val catalog = loadModelCatalog(force = false) + if (!isCurrentWork(generation)) return@launch + commitSessionPage(page) + commitModelCatalog(catalog) if (persistenceEnabled && query.isEmpty() && filter == SessionAgentFilter.ALL) { persistence!!.remoteSessions.save(deviceKey!!, page.sessions.map(::toPersistedSession), page.hasMore) } - _state.value = RemoteSessionUiState.Ready( + if (generation != workGeneration) return@launch + publishAuthorityReady(RemoteSessionUiState.Ready( sessions = page.sessions, selectedSessionId = current?.selectedSessionId, timeline = currentTimeline(), @@ -211,12 +425,14 @@ public class RemoteSessionStore internal constructor( modelCatalog = catalog.catalog ?: current?.modelCatalog, modelCatalogFailure = catalog.failure, draft = current?.draft ?: "", - ) + )) markConnected() } catch (cancelled: CancellationException) { throw cancelled } catch (error: Throwable) { - handleFailure(error, _state.value as? RemoteSessionUiState.Ready) + if (isCurrentWork(generation)) { + handleFailure(error, _state.value as? RemoteSessionUiState.Ready) + } } } } @@ -225,25 +441,29 @@ public class RemoteSessionStore internal constructor( val current = _state.value as? RemoteSessionUiState.Ready ?: return if (!current.hasMore || current.busy) return setBusy(current, true) - work?.cancel() + val operationToken = beginWork() work = scope.launch { try { val page = listSessions(current.sessions.size, current.query, current.agentFilter) + if (!isCurrentWork(operationToken)) return@launch val known = current.sessions.mapTo(mutableSetOf()) { it.id } val ready = (_state.value as? RemoteSessionUiState.Ready) ?: current val sessions = current.sessions + page.sessions.filterNot { it.id in known } + commitSessionPage(page) if (persistenceEnabled && current.query.isEmpty() && current.agentFilter == SessionAgentFilter.ALL) { persistence!!.remoteSessions.save(deviceKey!!, sessions.map(::toPersistedSession), page.hasMore) } - _state.value = ready.copy( + publishAuthorityReady(ready.copy( sessions = sessions, hasMore = page.hasMore, busy = false, - ) + )) } catch (cancelled: CancellationException) { throw cancelled } catch (_: Throwable) { - setBusy((_state.value as? RemoteSessionUiState.Ready) ?: current, false) + if (isCurrentWork(operationToken)) { + setBusy((_state.value as? RemoteSessionUiState.Ready) ?: current, false) + } } } } @@ -255,8 +475,9 @@ public class RemoteSessionStore internal constructor( * typed one: the desktop answers a missing workspace with a prose message * that the phone would otherwise have to pattern-match. */ - private suspend fun resolveWorkspacePath(): Boolean { + private suspend fun resolveWorkspacePath(operationToken: Long): Boolean { val info = transport.send(RemoteCommand(cmd = "get_workspace_info")) + if (!isCurrentWork(operationToken)) return false workspacePath = (info.path ?: info.workspacePath).orEmpty().trim() return workspacePath.isNotEmpty() && workspacePath != "/" } @@ -283,14 +504,17 @@ public class RemoteSessionStore internal constructor( if (sessions.isEmpty()) break } val serverIds = filtered.mapTo(mutableSetOf()) { it.id } - serverIds.forEach(locallyCreatedSessions::remove) val projected = mergeLocallyCreated(filtered, trimmedQuery, filter) return SessionPage( sessions = projected.subList(minOf(offset, projected.size), minOf(targetCount, projected.size)).toList(), hasMore = projected.size > targetCount || hasMore, + confirmedServerIds = serverIds, ) } + private fun mergeConfirmed(sessions: List, confirmed: RemoteSession): List = + listOf(confirmed) + sessions.filterNot { it.id == confirmed.id } + private fun mergeLocallyCreated( sessions: List, query: String, @@ -329,17 +553,19 @@ public class RemoteSessionStore internal constructor( return try { val catalog = transport.send(RemoteCommand(cmd = "get_model_catalog")).catalog ?.takeUnless { it.version == 0L && it.models.isEmpty() } - ?.also { modelCatalog = it } - modelCatalogFailure = null ModelCatalogLoadResult(catalog, null) } catch (cancelled: CancellationException) { throw cancelled } catch (_: Throwable) { - modelCatalogFailure = ModelCatalogFailure.LOAD_FAILED ModelCatalogLoadResult(null, ModelCatalogFailure.LOAD_FAILED) } } + private fun commitModelCatalog(result: ModelCatalogLoadResult) { + result.catalog?.let { modelCatalog = it } + modelCatalogFailure = result.failure + } + private data class ModelCatalogLoadResult( val catalog: RemoteModelCatalog?, val failure: ModelCatalogFailure?, @@ -386,16 +612,18 @@ public class RemoteSessionStore internal constructor( modelCatalog = modelCatalog ?: current?.modelCatalog, modelCatalogFailure = modelCatalogFailure ?: current?.modelCatalogFailure, draft = restoredDraft, + revision = current?.revision ?: authorityRevision, ) } } if (current == null) _connectionPhase.value = ConnectionPhase.CONNECTING - work?.cancel() + val operationToken = beginWork() _state.value = (_state.value as? RemoteSessionUiState.Ready)?.copy(busy = true) ?: current?.copy(busy = true) ?: RemoteSessionUiState.Loading work = scope.launch { try { - val opened = openSession(normalized) + val opened = openSession(normalized, operationToken) ?: return@launch + if (!isCurrentWork(operationToken)) return@launch _state.value = RemoteSessionUiState.Ready( sessions = current?.sessions.orEmpty(), selectedSessionId = normalized, @@ -410,21 +638,25 @@ public class RemoteSessionStore internal constructor( modelCatalog = modelCatalog ?: current?.modelCatalog, modelCatalogFailure = modelCatalogFailure ?: current?.modelCatalogFailure, draft = restoredDraft, + revision = current?.revision ?: authorityRevision, ) markConnected() } catch (cancelled: CancellationException) { throw cancelled } catch (error: Throwable) { - handleFailure(error, _state.value as? RemoteSessionUiState.Ready) + if (isCurrentWork(operationToken)) { + handleFailure(error, _state.value as? RemoteSessionUiState.Ready) + } } } } /** Loads a session's history, starts polling it, and reports its permission mode. */ - private suspend fun openSession(sessionId: String): OpenedSession { + private suspend fun openSession(sessionId: String, operationToken: Long): OpenedSession? { val response = transport.send( RemoteCommand(cmd = "get_session_messages", sessionId = sessionId, limit = 100), ) + if (!isCurrentWork(operationToken)) return null val cursor = timelineStore.snapshot().cursor.takeIf { timelineStore.snapshot().sessionId == sessionId } timelineStore.reset(sessionId) timelineStore.setPersistedMessages(response.messages.map(RemoteResponseMapper::chatMessage)) @@ -437,7 +669,9 @@ public class RemoteSessionStore internal constructor( ), ) persistTranscript(sessionId) - return OpenedSession(readPermissionMode(), response.hasMore) + val permission = readPermissionMode() + if (!isCurrentWork(operationToken)) return null + return OpenedSession(permission, response.hasMore) } private data class OpenedSession( @@ -451,7 +685,7 @@ public class RemoteSessionStore internal constructor( val beforeMessageId = current.timeline?.persistedMessages?.firstOrNull()?.id.orEmpty() if (sessionId.isEmpty() || beforeMessageId.isEmpty() || !current.hasMoreMessages || current.busy) return setBusy(current, true) - work?.cancel() + val operationToken = beginWork() work = scope.launch { try { val response = transport.send( @@ -462,7 +696,7 @@ public class RemoteSessionStore internal constructor( beforeMessageId = beforeMessageId, ), ) - if (timelineStore.snapshot().sessionId != sessionId) return@launch + if (!isCurrentWork(operationToken) || timelineStore.snapshot().sessionId != sessionId) return@launch val visible = timelineStore.snapshot().persistedMessages val visibleIds = visible.mapTo(mutableSetOf()) { it.id } val older = response.messages @@ -479,7 +713,9 @@ public class RemoteSessionStore internal constructor( } catch (cancelled: CancellationException) { throw cancelled } catch (_: Throwable) { - setBusy((_state.value as? RemoteSessionUiState.Ready) ?: current, false) + if (isCurrentWork(operationToken)) { + setBusy((_state.value as? RemoteSessionUiState.Ready) ?: current, false) + } } } } @@ -513,10 +749,20 @@ public class RemoteSessionStore internal constructor( val failure: PermissionModeFailure?, ) - private fun createSession(intent: RemoteSessionIntent.CreateSession) { + private fun createSession(intent: RemoteSessionIntent.CreateSession, requestId: String) { val current = _state.value as? RemoteSessionUiState.Ready if (current == null) _connectionPhase.value = ConnectionPhase.CONNECTING - work?.cancel() + // Create has priority over list refresh. Invalidating the work generation + // prevents a cancelled refresh from publishing a late stale page. + val operationToken = beginWork() + nextCreateGeneration += 1 + val generation = nextCreateGeneration + activeCreateGeneration = generation + _createOperation.value = CreateSessionOperationState.InFlight( + requestId = requestId, + deviceKey = deviceKey, + workspacePath = intent.workspacePath?.trim().orEmpty(), + ) _state.value = current?.copy(busy = true) ?: RemoteSessionUiState.Loading work = scope.launch { try { @@ -525,7 +771,10 @@ public class RemoteSessionStore internal constructor( // there must not change the desktop's active workspace. The // ordinary create flow still re-reads the active path so a // recent workspace selection cannot race a cached value. - if (requestedWorkspacePath.isEmpty() && !resolveWorkspacePath()) { + val workspaceResolved = requestedWorkspacePath.isNotEmpty() || resolveWorkspacePath(operationToken) + if (!isCurrentWork(operationToken)) return@launch + if (!workspaceResolved) { + failCreate(requestId, generation, CreateSessionOperationFailure.WORKSPACE, retryable = true, unsupported = false) failKnown(RemoteSessionFailureReason.NO_WORKSPACE, current) return@launch } @@ -540,15 +789,38 @@ public class RemoteSessionStore internal constructor( ) val sessionId = created.resolvedSessionId?.trim().orEmpty() if (sessionId.isEmpty()) { + failCreate(requestId, generation, CreateSessionOperationFailure.PROTOCOL, retryable = false, unsupported = true) failKnown(RemoteSessionFailureReason.PROTOCOL_MISMATCH, current) return@launch } + // A valid id is the remote commit point. Persist and publish it + // before optional initialization so cancellation or failure below + // cannot turn an already-created remote session into a failed create. + val now = Clock.System.now().toString() + val confirmedSession = RemoteSession( + id = sessionId, + title = created.title?.takeIf(String::isNotBlank) + ?: SessionNaming.fallbackTitle(intent.agentType), + agentType = intent.agentType, + status = "active", + updatedAt = now, + createdAt = now, + messageCount = 0, + workspacePath = targetWorkspacePath, + workspaceName = null, + ) + if (!isCurrentWork(operationToken)) return@launch + val commitRevision = publishCommittedCreate(confirmedSession, current) + if (!succeedCreate(requestId, generation, confirmedSession, commitRevision)) return@launch + activeCreateGeneration = null + intent.modelId?.trim()?.takeIf(String::isNotEmpty)?.let { modelId -> transport.send( RemoteCommand(cmd = "set_session_model", sessionId = sessionId, modelId = modelId), ) + if (!isCurrentWork(operationToken)) return@launch } - val opened = openSession(sessionId) + val opened = openSession(sessionId, operationToken) ?: return@launch intent.instruction.trim().takeIf(String::isNotEmpty)?.let { instruction -> val sent = transport.send( RemoteCommand( @@ -558,24 +830,14 @@ public class RemoteSessionStore internal constructor( agentType = intent.agentType, ), ) + if (!isCurrentWork(operationToken)) return@launch sent.turnId?.let(timelineStore::setLocalActiveTurn) controller.nudge() } - val now = Clock.System.now().toString() - locallyCreatedSessions[sessionId] = RemoteSession( - id = sessionId, - title = created.title?.takeIf(String::isNotBlank) - ?: SessionNaming.fallbackTitle(intent.agentType), - agentType = intent.agentType, - status = "active", - updatedAt = now, - createdAt = now, - messageCount = if (intent.instruction.isBlank()) 0 else 1, - workspacePath = targetWorkspacePath, - workspaceName = null, - ) val page = listSessions(0, current?.query.orEmpty(), current?.agentFilter ?: SessionAgentFilter.ALL) - _state.value = RemoteSessionUiState.Ready( + if (!isCurrentWork(operationToken)) return@launch + commitSessionPage(page) + publishAuthorityReady(RemoteSessionUiState.Ready( sessions = page.sessions, selectedSessionId = sessionId, timeline = timelineStore.snapshot(), @@ -589,27 +851,87 @@ public class RemoteSessionStore internal constructor( modelCatalog = modelCatalog ?: current?.modelCatalog, modelCatalogFailure = modelCatalogFailure ?: current?.modelCatalogFailure, draft = "", - ) + )) markConnected() } catch (cancelled: CancellationException) { + if (isCurrentWork(operationToken)) { + if (isCommittedCreate(requestId)) { + val ready = _state.value as? RemoteSessionUiState.Ready + if (ready != null) _state.value = ready.copy(busy = false) + } else { + cancelCreateIfActive(requestId, generation, CreateSessionOperationFailure.CANCELLED) + } + } throw cancelled } catch (error: Throwable) { + if (!isCurrentWork(operationToken)) return@launch + if (isCommittedCreate(requestId)) { + handleFailure(error, _state.value as? RemoteSessionUiState.Ready) + return@launch + } + if (activeCreateGeneration != generation) return@launch + failCreateFromError(requestId, generation, error) handleFailure(error, current) } } } + private fun isCommittedCreate(requestId: String): Boolean = + (_createOperation.value as? CreateSessionOperationState.Succeeded)?.requestId == requestId + + private fun cancelCreateIfActive(requestId: String, generation: Long, reason: CreateSessionOperationFailure) { + if (activeCreateGeneration == generation && (_createOperation.value as? CreateSessionOperationState.InFlight)?.requestId == requestId) { + _createOperation.value = CreateSessionOperationState.Cancelled(requestId, reason) + activeCreateGeneration = null + } + } + + private fun succeedCreate( + requestId: String, + generation: Long, + session: RemoteSession, + commitRevision: Long, + ): Boolean { + if (activeCreateGeneration != generation || + (_createOperation.value as? CreateSessionOperationState.InFlight)?.requestId != requestId + ) return false + _createOperation.value = CreateSessionOperationState.Succeeded( + requestId, session.id, session, commitRevision, + ) + return true + } + + private fun failCreate(requestId: String, generation: Long, reason: CreateSessionOperationFailure, retryable: Boolean, unsupported: Boolean) { + if (activeCreateGeneration == generation && (_createOperation.value as? CreateSessionOperationState.InFlight)?.requestId == requestId) { + _createOperation.value = CreateSessionOperationState.Failed(requestId, reason, retryable, unsupported) + activeCreateGeneration = null + } + } + + private fun failCreateFromError(requestId: String, generation: Long, error: Throwable) { + val reason = when (remoteSessionFailure(error).reason) { + RemoteSessionFailureReason.PROTOCOL_MISMATCH -> CreateSessionOperationFailure.UNSUPPORTED + RemoteSessionFailureReason.NO_WORKSPACE -> CreateSessionOperationFailure.WORKSPACE + RemoteSessionFailureReason.NETWORK, RemoteSessionFailureReason.TIMEOUT, + RemoteSessionFailureReason.TRANSPORT, RemoteSessionFailureReason.RATE_LIMITED, + RemoteSessionFailureReason.REMOTE_REJECTED, RemoteSessionFailureReason.SESSION_NOT_FOUND -> + CreateSessionOperationFailure.TRANSPORT + } + failCreate(requestId, generation, reason, retryable = reason != CreateSessionOperationFailure.UNSUPPORTED, unsupported = reason == CreateSessionOperationFailure.UNSUPPORTED) + } + private fun deleteSession(sessionId: String) { val normalized = sessionId.trim() if (normalized.isEmpty()) return val current = _state.value as? RemoteSessionUiState.Ready ?: return setBusy(current, true) - work?.cancel() + val operationToken = beginWork() work = scope.launch { try { transport.send( RemoteCommand(cmd = "delete_session", sessionId = normalized), ) + if (!isCurrentWork(operationToken)) return@launch locallyCreatedSessions.remove(normalized) val closingOpenSession = current.selectedSessionId == normalized if (closingOpenSession) { @@ -617,19 +939,22 @@ public class RemoteSessionStore internal constructor( controller.stop() timelineStore.reset("") } + if (!isCurrentWork(operationToken)) return@launch val ready = (_state.value as? RemoteSessionUiState.Ready) ?: current - _state.value = ready.copy( - sessions = current.sessions.filterNot { it.id == normalized }, - selectedSessionId = current.selectedSessionId.takeUnless { closingOpenSession }, + publishAuthorityReady(ready.copy( + sessions = ready.sessions.filterNot { it.id == normalized }, + selectedSessionId = ready.selectedSessionId.takeUnless { closingOpenSession }, timeline = if (closingOpenSession) null else ready.timeline, permissionMode = if (closingOpenSession) null else ready.permissionMode, busy = false, - ) + )) } catch (cancelled: CancellationException) { throw cancelled } catch (error: Throwable) { - setBusy((_state.value as? RemoteSessionUiState.Ready) ?: current, false) - handleFailure(error, current) + if (isCurrentWork(operationToken)) { + setBusy((_state.value as? RemoteSessionUiState.Ready) ?: current, false) + handleFailure(error, current) + } } } } @@ -641,27 +966,38 @@ public class RemoteSessionStore internal constructor( val current = _state.value as? RemoteSessionUiState.Ready ?: return if (current.sessions.any { it.id == sessionId && it.title == title }) return setBusy(current, true) - work?.cancel() + val operationToken = beginWork() work = scope.launch { try { transport.send( RemoteCommand(cmd = "update_session_title", sessionId = sessionId, title = title), ) + if (!isCurrentWork(operationToken)) return@launch val ready = (_state.value as? RemoteSessionUiState.Ready) ?: current - _state.value = ready.copy( + publishAuthorityReady(ready.copy( sessions = ready.sessions.map { if (it.id == sessionId) it.copy(title = title) else it }, busy = false, - ) + )) } catch (cancelled: CancellationException) { throw cancelled } catch (error: Throwable) { - setBusy((_state.value as? RemoteSessionUiState.Ready) ?: current, false) - handleFailure(error, current) + if (isCurrentWork(operationToken)) { + setBusy((_state.value as? RemoteSessionUiState.Ready) ?: current, false) + handleFailure(error, current) + } } } } - private class SessionPage(val sessions: List, val hasMore: Boolean) + private class SessionPage( + val sessions: List, + val hasMore: Boolean, + val confirmedServerIds: Set, + ) + + private fun commitSessionPage(page: SessionPage) { + page.confirmedServerIds.forEach(locallyCreatedSessions::remove) + } private fun currentTimeline() = timelineStore.snapshot().takeIf { it.sessionId.isNotEmpty() } @@ -769,7 +1105,7 @@ public class RemoteSessionStore internal constructor( ) timelineStore.appendOptimisticMessage(local) setBusy(current, true) - work?.cancel() + val operationToken = beginWork() work = scope.launch { try { val agentType = current.sessions.firstOrNull { it.id == sessionId }?.agentType @@ -783,6 +1119,7 @@ public class RemoteSessionStore internal constructor( imageContexts = imageContexts, ), ) + if (!isCurrentWork(operationToken)) return@launch response.turnId?.let(timelineStore::setLocalActiveTurn) controller.nudge() if (persistenceEnabled) persistence!!.drafts.delete(draftId(sessionId)) @@ -792,9 +1129,11 @@ public class RemoteSessionStore internal constructor( } catch (cancelled: CancellationException) { throw cancelled } catch (error: Throwable) { - timelineStore.markOptimisticMessageFailed(local.id) - setBusy((_state.value as? RemoteSessionUiState.Ready) ?: current, false) - handleFailure(error, current) + if (isCurrentWork(operationToken)) { + timelineStore.markOptimisticMessageFailed(local.id) + setBusy((_state.value as? RemoteSessionUiState.Ready) ?: current, false) + handleFailure(error, current) + } } } } @@ -831,12 +1170,13 @@ public class RemoteSessionStore internal constructor( val wireMode = intent.mode.toWireMode() ?: return val current = _state.value as? RemoteSessionUiState.Ready ?: return setBusy(current, true) - work?.cancel() + val operationToken = beginWork() work = scope.launch { try { transport.send( RemoteCommand(cmd = "set_permission_mode", mode = wireMode), ) + if (!isCurrentWork(operationToken)) return@launch val ready = (_state.value as? RemoteSessionUiState.Ready) ?: current _state.value = ready.copy( busy = false, @@ -846,6 +1186,7 @@ public class RemoteSessionStore internal constructor( } catch (cancelled: CancellationException) { throw cancelled } catch (error: Throwable) { + if (!isCurrentWork(operationToken)) return@launch // The session itself is fine — only this one setting failed, so // the failure stays inside the permission section rather than // replacing the transcript the user is reading. @@ -861,9 +1202,10 @@ public class RemoteSessionStore internal constructor( private fun refreshPermissionMode() { val current = _state.value as? RemoteSessionUiState.Ready ?: return setBusy(current, true) - work?.cancel() + val operationToken = beginWork() work = scope.launch { val permission = readPermissionMode() + if (!isCurrentWork(operationToken)) return@launch val ready = (_state.value as? RemoteSessionUiState.Ready) ?: current _state.value = ready.copy( busy = false, @@ -888,10 +1230,12 @@ public class RemoteSessionStore internal constructor( // every generic failure is typed as LOAD_FAILED and remains retryable. if (modelCatalogFailure == ModelCatalogFailure.UNSUPPORTED_BY_PEER) return setBusy(current, true) - work?.cancel() + val operationToken = beginWork() work = scope.launch { try { val result = loadModelCatalog(force = true) + if (!isCurrentWork(operationToken)) return@launch + commitModelCatalog(result) val timeline = result.catalog?.let { catalog -> val snapshot = timelineStore.snapshot() if (snapshot.sessionId.isNotEmpty()) { @@ -911,7 +1255,9 @@ public class RemoteSessionStore internal constructor( } catch (cancelled: CancellationException) { throw cancelled } catch (_: Throwable) { - setBusy((_state.value as? RemoteSessionUiState.Ready) ?: current, false) + if (isCurrentWork(operationToken)) { + setBusy((_state.value as? RemoteSessionUiState.Ready) ?: current, false) + } } } } @@ -920,19 +1266,22 @@ public class RemoteSessionStore internal constructor( val current = _state.value as? RemoteSessionUiState.Ready ?: return if (intent.modelId.trim().isEmpty()) return setBusy(current, true) - work?.cancel() + val operationToken = beginWork() work = scope.launch { try { val response = transport.send( RemoteCommand(cmd = "set_session_model", sessionId = intent.sessionId, modelId = intent.modelId), ) + if (!isCurrentWork(operationToken)) return@launch timelineStore.setSelectedModelId(response.modelId ?: intent.modelId) setBusy((_state.value as? RemoteSessionUiState.Ready) ?: current, false) } catch (cancelled: CancellationException) { throw cancelled } catch (error: Throwable) { - setBusy((_state.value as? RemoteSessionUiState.Ready) ?: current, false) - handleFailure(error, current) + if (isCurrentWork(operationToken)) { + setBusy((_state.value as? RemoteSessionUiState.Ready) ?: current, false) + handleFailure(error, current) + } } } } @@ -940,17 +1289,20 @@ public class RemoteSessionStore internal constructor( private fun runAction(sessionId: String, command: RemoteCommand) { val current = _state.value as? RemoteSessionUiState.Ready ?: return setBusy(current, true) - work?.cancel() + val operationToken = beginWork() work = scope.launch { try { transport.send(command.copy(sessionId = command.sessionId ?: sessionId)) + if (!isCurrentWork(operationToken)) return@launch setBusy((_state.value as? RemoteSessionUiState.Ready) ?: current, false) controller.nudge() } catch (cancelled: CancellationException) { throw cancelled } catch (error: Throwable) { - setBusy((_state.value as? RemoteSessionUiState.Ready) ?: current, false) - handleFailure(error, current) + if (isCurrentWork(operationToken)) { + setBusy((_state.value as? RemoteSessionUiState.Ready) ?: current, false) + handleFailure(error, current) + } } } } @@ -995,6 +1347,14 @@ public class RemoteSessionStore internal constructor( )) } + private fun restorePendingConfirmed(rows: List) { + rows.filter { it.pendingConfirmed }.forEach { row -> + if (row.sessionId !in locallyCreatedSessions) { + locallyCreatedSessions[row.sessionId] = toRemoteSession(row) + } + } + } + private fun toRemoteSession(s: PersistedRemoteSession): RemoteSession = RemoteSession( id = s.sessionId, title = s.title, agentType = s.agentType, status = s.status, updatedAt = s.updatedAt, createdAt = s.createdAt, messageCount = s.messageCount, @@ -1005,6 +1365,7 @@ public class RemoteSessionStore internal constructor( sessionId = s.id, title = s.title, agentType = s.agentType, status = s.status, updatedAt = s.updatedAt, createdAt = s.createdAt, messageCount = s.messageCount, lastMessageId = "", workspacePath = s.workspacePath, workspaceName = s.workspaceName, + pendingConfirmed = s.id in locallyCreatedSessions, ) private inner class ControllerCallbacks : ChatSessionControllerCallbacks { diff --git a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/bitfun/mobile/core/feature/session/RemoteSessionUiState.kt b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/bitfun/mobile/core/feature/session/RemoteSessionUiState.kt index eef2f7f96e..cb8883fdfe 100644 --- a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/bitfun/mobile/core/feature/session/RemoteSessionUiState.kt +++ b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/bitfun/mobile/core/feature/session/RemoteSessionUiState.kt @@ -49,6 +49,57 @@ public enum class PermissionModeFailure { SAVE, } +/** Typed lifecycle of the most recent create-session request. */ +public sealed interface CreateSessionOperationState { + public data object Idle : CreateSessionOperationState + + public data class InFlight public constructor( + public val requestId: String, + /** The remote target owning this operation; never a controller path. */ + public val deviceKey: String?, + public val workspacePath: String, + ) : CreateSessionOperationState + + public data class Succeeded public constructor( + public val requestId: String, + public val createdSessionId: String, + /** Canonical projection confirmed by create, including its owning workspace. */ + public val confirmedSession: RemoteSession?, + /** Minimum Ready revision that contains this committed session. */ + public val commitRevision: Long, + ) : CreateSessionOperationState { + public constructor( + requestId: String, + createdSessionId: String, + confirmedSession: RemoteSession?, + ) : this(requestId, createdSessionId, confirmedSession, 0) + + public constructor(requestId: String, createdSessionId: String) : this(requestId, createdSessionId, null, 0) + } + + public data class Failed public constructor( + public val requestId: String, + public val reason: CreateSessionOperationFailure, + public val retryable: Boolean, + public val unsupported: Boolean, + ) : CreateSessionOperationState + + public data class Cancelled public constructor( + public val requestId: String, + public val reason: CreateSessionOperationFailure, + ) : CreateSessionOperationState +} + +public enum class CreateSessionOperationFailure { + TRANSPORT, + UNSUPPORTED, + DISCONNECTED, + WORKSPACE, + DEVICE_MISMATCH, + PROTOCOL, + CANCELLED, +} + /** Why the optional model catalog could not be loaded. */ public enum class ModelCatalogFailure { /** @@ -140,7 +191,28 @@ public sealed interface RemoteSessionUiState { */ public val modelCatalogFailure: ModelCatalogFailure?, public val draft: String, + /** Monotonic authority revision for session-list projection on this store. */ + public val revision: Long, ) : RemoteSessionUiState { + public constructor( + sessions: List, + selectedSessionId: String?, + timeline: ChatTimelineState?, + busy: Boolean, + permissionMode: SessionPermissionMode?, + permissionModeFailure: PermissionModeFailure?, + query: String, + agentFilter: SessionAgentFilter, + hasMore: Boolean, + hasMoreMessages: Boolean, + modelCatalog: RemoteModelCatalog?, + modelCatalogFailure: ModelCatalogFailure?, + draft: String, + ) : this( + sessions, selectedSessionId, timeline, busy, permissionMode, permissionModeFailure, + query, agentFilter, hasMore, hasMoreMessages, modelCatalog, modelCatalogFailure, draft, 0, + ) + /** * The pre-catalog/pre-draft shape. A secondary constructor rather than * default arguments: Kotlin defaults do not survive into Swift, so an @@ -229,6 +301,24 @@ public sealed interface RemoteSessionIntent { public constructor(agentType: String) : this(agentType, "", "", null, null) } + /** Create with an identity that Swift can retain and correlate with its UI. */ + public data class CreateSessionOperation public constructor( + public val requestId: String, + public val agentType: String, + public val title: String, + public val instruction: String, + public val modelId: String?, + public val workspacePath: String?, + ) : RemoteSessionIntent { + public constructor( + requestId: String, + agentType: String, + title: String, + instruction: String, + modelId: String?, + ) : this(requestId, agentType, title, instruction, modelId, null) + } + public data class DeleteSession public constructor( public val sessionId: String, ) : RemoteSessionIntent diff --git a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/bitfun/mobile/core/feature/workspace/RemoteWorkspaceStore.kt b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/bitfun/mobile/core/feature/workspace/RemoteWorkspaceStore.kt index 65f35d265d..9a77729fc0 100644 --- a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/bitfun/mobile/core/feature/workspace/RemoteWorkspaceStore.kt +++ b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/bitfun/mobile/core/feature/workspace/RemoteWorkspaceStore.kt @@ -27,6 +27,7 @@ import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Job import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -37,11 +38,29 @@ public class RemoteWorkspaceStore internal constructor( private val scope: CoroutineScope, private val transport: RemoteCommandTransport, private val backgroundDispatcher: CoroutineDispatcher, + public val deviceKey: String? = null, ) { private val _state = MutableStateFlow(RemoteWorkspaceUiState.Idle) public val state: StateFlow = _state.asStateFlow() + private val _stopVersion = MutableStateFlow(0L) + /** Changes only when this target store is stopped; useful to cancel observers. */ + public val stopVersion: StateFlow = _stopVersion.asStateFlow() private var work: Job? = null private var targetEpoch: Int = 0 + private var previewGeneration: Long = 0 + private var activePreviewRequestId: String? = null + + private fun nextPreviewIdentity(target: FilePreviewTarget, requestedId: String = ""): PreviewRequestIdentity { + previewGeneration += 1 + val requestId = requestedId.trim().ifEmpty { "preview-$previewGeneration" } + activePreviewRequestId = requestId + return PreviewRequestIdentity(requestId, deviceKey, target.sessionId, target.remotePath) + } + + private fun invalidatePreview() { + previewGeneration += 1 + activePreviewRequestId = null + } public fun dispatch(intent: RemoteWorkspaceIntent) { when (intent) { @@ -52,17 +71,23 @@ public class RemoteWorkspaceStore internal constructor( is RemoteWorkspaceIntent.DownloadFile -> resolveAndDownloadFile(intent) is RemoteWorkspaceIntent.DownloadSaved -> finishDownload(intent.reference, true) is RemoteWorkspaceIntent.DownloadSaveFailed -> finishDownload(intent.reference, false) - RemoteWorkspaceIntent.DismissPreview -> updateReady { it.copy(preview = RemoteFilePreviewUiState.None) } + RemoteWorkspaceIntent.DismissPreview -> { + invalidatePreview() + updateReady { it.copy(preview = RemoteFilePreviewUiState.None) } + } RemoteWorkspaceIntent.Stop -> stop() } } public fun stop() { + _stopVersion.value += 1 + invalidatePreview() work?.cancel() work = null } private fun load() { + invalidatePreview() work?.cancel() _state.value = RemoteWorkspaceUiState.Loading work = scope.launch { @@ -109,6 +134,7 @@ public class RemoteWorkspaceStore internal constructor( private fun runSelection(command: RemoteCommand, assistant: Boolean) { val current = _state.value as? RemoteWorkspaceUiState.Ready ?: return + invalidatePreview() work?.cancel() _state.value = current.copy(busy = true) work = scope.launch { @@ -128,10 +154,12 @@ public class RemoteWorkspaceStore internal constructor( } } - private fun openFile(target: FilePreviewTarget) { + private fun openFile(target: FilePreviewTarget, requestedId: String) { val current = _state.value as? RemoteWorkspaceUiState.Ready ?: return + val identity = nextPreviewIdentity(target, requestedId) + val generation = previewGeneration work?.cancel() - _state.value = current.copy(preview = RemoteFilePreviewUiState.Loading(target)) + _state.value = current.copy(preview = RemoteFilePreviewUiState.Loading(target, identity)) work = scope.launch { try { val info = transport.send( @@ -144,23 +172,23 @@ public class RemoteWorkspaceStore internal constructor( // `text/plain` for Markdown, and `image/svg+xml` for a file the // preview can only show as source. when (FilePreviewPolicy.rendererFor(name.ifEmpty { target.remotePath }, mime)) { - FilePreviewRenderer.MARKDOWN -> loadText(target, name, mime, size, markdown = true) - FilePreviewRenderer.TEXT -> loadText(target, name, mime, size, markdown = false) + FilePreviewRenderer.MARKDOWN -> loadText(target, identity, generation, name, mime, size, markdown = true) + FilePreviewRenderer.TEXT -> loadText(target, identity, generation, name, mime, size, markdown = false) FilePreviewRenderer.IMAGE -> if (FilePreviewPolicy.canPreviewImage(size)) { - loadImage(target, name, mime, size) + loadImage(target, identity, generation, name, mime, size) } else { - failPreview(target, "file too large", mime, size) + failPreview(target, identity, generation, "file too large", mime, size) } FilePreviewRenderer.UNSUPPORTED -> - updateReady { it.copy(preview = RemoteFilePreviewUiState.Unsupported(target, mime, size)) } + updatePreview(identity, generation) { it.copy(preview = RemoteFilePreviewUiState.Unsupported(target, mime, size, identity)) } } } catch (cancelled: CancellationException) { throw cancelled } catch (error: Throwable) { // `get_file_info` may be what failed, so the type and size are // not known here; the header falls back to the path it asked for. - failPreview(target, error.message.orEmpty(), "", 0) + failPreview(target, identity, generation, error.message.orEmpty(), "", 0) } } } @@ -202,7 +230,7 @@ public class RemoteWorkspaceStore internal constructor( } return } - openFile(resolvedTarget) + openFile(resolvedTarget, intent.requestId) } private fun resolveAndDownloadFile(intent: RemoteWorkspaceIntent.DownloadFile) { @@ -333,6 +361,8 @@ public class RemoteWorkspaceStore internal constructor( private suspend fun loadText( target: FilePreviewTarget, + identity: PreviewRequestIdentity, + generation: Long, name: String, mime: String, size: Long, @@ -345,18 +375,19 @@ public class RemoteWorkspaceStore internal constructor( // The type said text; the bytes are the only thing that can disagree, // and a wall of replacement characters is worse than saying no. if (FilePreviewPolicy.looksBinary(bytes) || FilePreviewPolicy.looksUndecodable(bytes, content)) { - updateReady { + updatePreview(identity, generation) { it.copy( preview = RemoteFilePreviewUiState.Unsupported( target, response.mimeType ?: mime, response.totalSize ?: size, + identity, ), ) } return } - updateReady { + updatePreview(identity, generation) { it.copy( preview = RemoteFilePreviewUiState.Text( target = target, @@ -367,15 +398,16 @@ public class RemoteWorkspaceStore internal constructor( mimeType = response.mimeType ?: mime, sizeBytes = response.totalSize ?: size, markdown = markdown, + identity = identity, ), ) } } - private suspend fun loadImage(target: FilePreviewTarget, name: String, mime: String, size: Long) { + private suspend fun loadImage(target: FilePreviewTarget, identity: PreviewRequestIdentity, generation: Long, name: String, mime: String, size: Long) { val response = readChunk(target, size.coerceAtLeast(1).coerceAtMost(Int.MAX_VALUE.toLong()).toInt()) val bytes = withContext(backgroundDispatcher) { decode(response.chunkBase64.orEmpty()) } - updateReady { + updatePreview(identity, generation) { it.copy( preview = RemoteFilePreviewUiState.Image( target = target, @@ -383,6 +415,7 @@ public class RemoteWorkspaceStore internal constructor( mimeType = response.mimeType ?: mime, bytes = bytes, sizeBytes = response.totalSize ?: size, + identity = identity, ), ) } @@ -399,19 +432,28 @@ public class RemoteWorkspaceStore internal constructor( ), ) - private fun failPreview(target: FilePreviewTarget, message: String, mime: String, size: Long) { + private fun failPreview(target: FilePreviewTarget, identity: PreviewRequestIdentity, generation: Long, message: String, mime: String, size: Long) { val failure = if (message.isBlank()) { FilePreviewFailure(FilePreviewFailureReason.LOAD_FAILED, true) } else { FilePreviewPolicy.failure(message) } - updateReady { + updatePreview(identity, generation) { it.copy( - preview = RemoteFilePreviewUiState.Failed(target, failure.toKind(), failure.retryable, mime, size), + preview = RemoteFilePreviewUiState.Failed(target, failure.toKind(), failure.retryable, mime, size, identity), ) } } + private fun updatePreview( + identity: PreviewRequestIdentity, + generation: Long, + transform: (RemoteWorkspaceUiState.Ready) -> RemoteWorkspaceUiState.Ready, + ) { + if (previewGeneration != generation || activePreviewRequestId != identity.requestId) return + updateReady(transform) + } + private fun updateReady(transform: (RemoteWorkspaceUiState.Ready) -> RemoteWorkspaceUiState.Ready) { val current = _state.value as? RemoteWorkspaceUiState.Ready ?: return _state.value = transform(current) @@ -453,6 +495,13 @@ public class RemoteWorkspaceStore internal constructor( backgroundDispatcher: CoroutineDispatcher, ): RemoteWorkspaceStore = RemoteWorkspaceStore(scope, transport, backgroundDispatcher) + internal fun create( + scope: CoroutineScope, + transport: RemoteCommandTransport, + backgroundDispatcher: CoroutineDispatcher, + deviceKey: String, + ): RemoteWorkspaceStore = RemoteWorkspaceStore(scope, transport, backgroundDispatcher, deviceKey) + private const val DOWNLOAD_CHUNK_BYTES = 3 * 1024 * 1024 } } diff --git a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/bitfun/mobile/core/feature/workspace/RemoteWorkspaceUiState.kt b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/bitfun/mobile/core/feature/workspace/RemoteWorkspaceUiState.kt index d9940c5787..9e8611e9b2 100644 --- a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/bitfun/mobile/core/feature/workspace/RemoteWorkspaceUiState.kt +++ b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/bitfun/mobile/core/feature/workspace/RemoteWorkspaceUiState.kt @@ -7,9 +7,21 @@ import com.bitfun.mobile.core.domain.RecentWorkspace import com.bitfun.mobile.core.domain.SelectedWorkspace import com.bitfun.mobile.core.domain.WorkspaceAssistant +public data class PreviewRequestIdentity public constructor( + public val requestId: String, + public val deviceKey: String?, + public val sessionId: String, + public val path: String, +) + public sealed interface RemoteFilePreviewUiState { public data object None : RemoteFilePreviewUiState - public data class Loading public constructor(public val target: FilePreviewTarget) : RemoteFilePreviewUiState + public data class Loading public constructor( + public val target: FilePreviewTarget, + public val identity: PreviewRequestIdentity, + ) : RemoteFilePreviewUiState { + public constructor(target: FilePreviewTarget) : this(target, PreviewRequestIdentity("", null, target.sessionId, target.remotePath)) + } public data class Text public constructor( public val target: FilePreviewTarget, public val name: String, @@ -20,19 +32,34 @@ public sealed interface RemoteFilePreviewUiState { public val sizeBytes: Long, /** Markdown is rendered rather than shown as numbered source. */ public val markdown: Boolean, - ) : RemoteFilePreviewUiState + public val identity: PreviewRequestIdentity, + ) : RemoteFilePreviewUiState { + public constructor( + target: FilePreviewTarget, name: String, content: String, truncated: Boolean, + loadedBytes: Long, mimeType: String, sizeBytes: Long, markdown: Boolean, + ) : this(target, name, content, truncated, loadedBytes, mimeType, sizeBytes, markdown, + PreviewRequestIdentity("", null, target.sessionId, target.remotePath)) + } public data class Image public constructor( public val target: FilePreviewTarget, public val name: String, public val mimeType: String, public val bytes: ByteArray, public val sizeBytes: Long, - ) : RemoteFilePreviewUiState + public val identity: PreviewRequestIdentity, + ) : RemoteFilePreviewUiState { + public constructor(target: FilePreviewTarget, name: String, mimeType: String, bytes: ByteArray, sizeBytes: Long) : + this(target, name, mimeType, bytes, sizeBytes, PreviewRequestIdentity("", null, target.sessionId, target.remotePath)) + } public data class Unsupported public constructor( public val target: FilePreviewTarget, public val mimeType: String, public val sizeBytes: Long, - ) : RemoteFilePreviewUiState + public val identity: PreviewRequestIdentity, + ) : RemoteFilePreviewUiState { + public constructor(target: FilePreviewTarget, mimeType: String, sizeBytes: Long) : + this(target, mimeType, sizeBytes, PreviewRequestIdentity("", null, target.sessionId, target.remotePath)) + } /** * @param retryable whether asking again could give a different answer. A * file outside the workspace will not appear on a second try, so offering @@ -44,7 +71,11 @@ public sealed interface RemoteFilePreviewUiState { public val retryable: Boolean, public val mimeType: String, public val sizeBytes: Long, - ) : RemoteFilePreviewUiState + public val identity: PreviewRequestIdentity, + ) : RemoteFilePreviewUiState { + public constructor(target: FilePreviewTarget, kind: FilePreviewFailureKind, retryable: Boolean, mimeType: String, sizeBytes: Long) : + this(target, kind, retryable, mimeType, sizeBytes, PreviewRequestIdentity("", null, target.sessionId, target.remotePath)) + } } /** @@ -135,7 +166,12 @@ public sealed interface RemoteWorkspaceIntent { public val reference: String, public val label: String, public val sessionId: String, - ) : RemoteWorkspaceIntent + /** Optional local correlation supplied by Swift; blank values are generated. */ + public val requestId: String, + ) : RemoteWorkspaceIntent { + public constructor(reference: String, label: String, sessionId: String) : + this(reference, label, sessionId, "") + } public data class DownloadFile public constructor( public val reference: String, public val label: String, diff --git a/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/bitfun/mobile/core/feature/account/AccountStoreTest.kt b/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/bitfun/mobile/core/feature/account/AccountStoreTest.kt index 54b42b0a13..2832d2ef55 100644 --- a/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/bitfun/mobile/core/feature/account/AccountStoreTest.kt +++ b/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/bitfun/mobile/core/feature/account/AccountStoreTest.kt @@ -70,9 +70,37 @@ class AccountStoreTest { store.dispatch(AccountIntent.Logout) assertIs(store.state.value) + assertEquals(1, secure.deleteCount) assertNull(secure.read("cloud_account_session")) } + @Test + fun deviceSelectionWriteFailureRestoresBytesAndFailsClosed() = runTest { + val secure = MemorySecureStore() + val backend = FakeAccountBackend().also { it.desktop2Online = true } + val store = AccountStore.create(this, backend, secure, "phone-1", "Android") + store.dispatch(AccountIntent.Login("https://relay.test", "user", "password")) + advanceUntilIdle() + assertEquals("desktop-1", assertIs(store.state.value).selectedDeviceId) + val stored = secure.read("cloud_account_session")!!.toList() + + secure.failWrites = true + secure.mutateBeforeWriteFailure = true + store.dispatch(AccountIntent.SelectDevice("desktop-2")) + + val failed = assertIs(store.state.value) + assertEquals(AccountFailureReason.SECURE_STORAGE, failed.reason) + assertEquals(AccountFailureStage.SECURE_STORAGE, failed.stage) + assertEquals(stored, secure.read("cloud_account_session")?.toList()) + assertNull(store.createSessionStore(this)) + assertNull(store.createSessionStore(this, "desktop-1")) + assertNull(store.createWorkspaceStore(this)) + assertNull(store.createWorkspaceStore(this, "desktop-1")) + assertNull(store.cloudSettingsSource()) + assertTrue(backend.transportTargets.isEmpty()) + assertEquals(0, secure.deleteCount) + } + @Test fun refreshPicksUpADesktopThatCameOnlineAndSurvivesFailing() = runTest { val backend = FakeAccountBackend() @@ -100,6 +128,121 @@ class AccountStoreTest { assertEquals("desktop-1", failed.selectedDeviceId) } + @Test + fun explicitDeviceStoresCoexistWithSelectedTargetStore() = runTest { + val backend = FakeAccountBackend() + val store = AccountStore.create(this, backend, MemorySecureStore(), "phone-1", "Android") + store.dispatch(AccountIntent.Login("https://relay.test", "user", "password")) + advanceUntilIdle() + + // The old single-target entry points still resolve the selected device. + assertTrue(store.createSessionStore(this) != null) + assertTrue(store.createWorkspaceStore(this) != null) + + // The new explicit-device entry points address a device without changing selection. + assertTrue(store.createSessionStore(this, "desktop-2") != null) + assertTrue(store.createWorkspaceStore(this, "desktop-2") != null) + + assertEquals( + listOf("desktop-1", "desktop-1", "desktop-2", "desktop-2"), + backend.transportTargets, + ) + } + + @Test + fun explicitDeviceStoresRequireAuthenticatedRegisteredControlTargets() = runTest { + val backend = FakeAccountBackend() + val signedOut = AccountStore.create(this, backend, MemorySecureStore(), "phone-1", "Android") + assertNull(signedOut.createSessionStore(this, "desktop-1")) + assertNull(signedOut.createWorkspaceStore(this, "desktop-1")) + assertTrue(backend.transportTargets.isEmpty()) + + signedOut.dispatch(AccountIntent.Login("https://relay.test", "user", "password")) + advanceUntilIdle() + assertNull(signedOut.createSessionStore(this, "")) + assertNull(signedOut.createSessionStore(this, "phone-1")) + assertNull(signedOut.createSessionStore(this, "phone-2")) + assertNull(signedOut.createSessionStore(this, "unknown")) + // Registered offline targets remain authorized for cache-backed directory rows. + assertTrue(signedOut.createSessionStore(this, "desktop-2") != null) + assertTrue(signedOut.createWorkspaceStore(this, "desktop-2") != null) + assertEquals(listOf("desktop-2", "desktop-2"), backend.transportTargets) + } + + @Test + fun invalidRestoreKeepsOpaqueRecordAndCanBeRetried() = runTest { + val secure = MemorySecureStore() + val raw = "not-a-session-record".encodeToByteArray() + secure.write("cloud_account_session", raw) + val store = AccountStore.create(this, FakeAccountBackend(), secure, "phone-1", "Android") + + store.dispatch(AccountIntent.Restore) + advanceUntilIdle() + assertEquals(AccountFailureReason.SECURE_STORAGE, assertIs(store.state.value).reason) + assertTrue(assertIs(store.state.value).canRetry) + assertEquals(0, secure.deleteCount) + assertEquals(raw.toList(), secure.read("cloud_account_session")?.toList()) + + store.dispatch(AccountIntent.Restore) + advanceUntilIdle() + assertEquals(AccountFailureReason.SECURE_STORAGE, assertIs(store.state.value).reason) + assertEquals(0, secure.deleteCount) + assertEquals(raw.toList(), secure.read("cloud_account_session")?.toList()) + } + + @Test + fun secureReadFailureFailsClosedWithoutDeletingStoredBytes() = runTest { + val secure = MemorySecureStore() + val raw = "opaque-existing-session".encodeToByteArray() + secure.write("cloud_account_session", raw) + secure.failReads = true + val store = AccountStore.create(this, FakeAccountBackend(), secure, "phone-1", "Android") + + store.dispatch(AccountIntent.Restore) + advanceUntilIdle() + + val failed = assertIs(store.state.value) + assertEquals(AccountFailureReason.SECURE_STORAGE, failed.reason) + assertEquals(AccountFailureStage.RESTORE, failed.stage) + assertNull(store.cloudSettingsSource()) + assertEquals(0, secure.deleteCount) + secure.failReads = false + assertEquals(raw.toList(), secure.read("cloud_account_session")?.toList()) + } + + @Test + fun legacyRestoreKeepsOpaqueRecord() = runTest { + val secure = MemorySecureStore() + val raw = "{\"token\":\"legacy-token\"}".encodeToByteArray() + secure.write("cloud_account_session", raw) + val store = AccountStore.create(this, FakeAccountBackend(), secure, "phone-1", "Android") + + store.dispatch(AccountIntent.Restore) + advanceUntilIdle() + + assertEquals(AccountFailureReason.SECURE_STORAGE, assertIs(store.state.value).reason) + assertEquals(0, secure.deleteCount) + assertEquals(raw.toList(), secure.read("cloud_account_session")?.toList()) + } + + @Test + fun validRestoreDoesNotDeletePersistedSession() = runTest { + val secure = MemorySecureStore() + val backend = FakeAccountBackend() + val first = AccountStore.create(this, backend, secure, "phone-1", "Android") + first.dispatch(AccountIntent.Login("https://relay.test", "user", "password")) + advanceUntilIdle() + val raw = secure.read("cloud_account_session") + + val restored = AccountStore.create(this, backend, secure, "phone-1", "Android") + restored.dispatch(AccountIntent.Restore) + advanceUntilIdle() + + assertIs(restored.state.value) + assertEquals(0, secure.deleteCount) + assertEquals(raw?.toList(), secure.read("cloud_account_session")?.toList()) + } + @Test fun expiredRefreshClearsDevicesAndThePersistedSession() = runTest { val secure = MemorySecureStore() @@ -142,6 +285,128 @@ class AccountStoreTest { val failed = assertIs(store.state.value) assertEquals(AccountFailureReason.AUTHENTICATION, failed.reason) + assertEquals(AccountFailureStage.AUTHENTICATION, failed.stage) + } + + @Test + fun unknownAuthenticationFailureIsProtocolFailureNotSecureStorage() = runTest { + val backend = FakeAccountBackend().also { it.loginThrowable = IllegalStateException("crypto failed") } + val store = AccountStore.create(this, backend, MemorySecureStore(), "phone-1", "Android") + + store.dispatch(AccountIntent.Login("https://relay.test", "user", "password")) + advanceUntilIdle() + + val failed = assertIs(store.state.value) + assertEquals(AccountFailureReason.MALFORMED_RESPONSE, failed.reason) + assertEquals(AccountFailureStage.AUTHENTICATION, failed.stage) + } + + @Test + fun secureStoreWriteFailureIsTheOnlyUntypedSecureStorageFailure() = runTest { + val secure = MemorySecureStore(failWrites = true) + val store = AccountStore.create(this, FakeAccountBackend(), secure, "phone-1", "Android") + + store.dispatch(AccountIntent.Login("https://relay.test", "user", "password")) + advanceUntilIdle() + + val failed = assertIs(store.state.value) + assertEquals(AccountFailureReason.SECURE_STORAGE, failed.reason) + assertEquals(AccountFailureStage.SECURE_STORAGE, failed.stage) + } + + @Test + fun deviceListFailurePreservesAuthenticatedAccountAndPersistedData() = runTest { + val secure = MemorySecureStore() + val backend = FakeAccountBackend().also { it.listThrowable = IllegalStateException("transport failed") } + val store = AccountStore.create(this, backend, secure, "phone-1", "Android") + + store.dispatch(AccountIntent.Login("https://relay.test", "user", "password")) + advanceUntilIdle() + + val failed = assertIs(store.state.value) + assertEquals(AccountFailureReason.NETWORK, failed.reason) + assertEquals(AccountFailureStage.DEVICE_LIST, failed.stage) + assertTrue(store.cloudSettingsSource() != null) + assertTrue(secure.read("cloud_account_session")?.isNotEmpty() == true) + assertEquals(0, secure.deleteCount) + } + + @Test + fun deviceListFailuresKeepTheirTypedReasons() = runTest { + CloudAccountFailure.entries.forEach { transportReason -> + val backend = FakeAccountBackend().also { it.listFailure = transportReason } + val store = AccountStore.create(this, backend, MemorySecureStore(), "phone-1", "Android") + + store.dispatch(AccountIntent.Login("https://relay.test", "user", "password")) + advanceUntilIdle() + + val failed = assertIs(store.state.value) + assertEquals(transportReason.toExpectedReason(), failed.reason) + assertEquals(AccountFailureStage.DEVICE_LIST, failed.stage) + } + } + + @Test + fun deviceListRetryReusesSessionWithoutTouchingStoredBytes() = runTest { + val secure = MemorySecureStore() + val backend = FakeAccountBackend().also { it.listFailure = CloudAccountFailure.TIMEOUT } + val store = AccountStore.create(this, backend, secure, "phone-1", "Android") + + store.dispatch(AccountIntent.Login("https://relay.test", "user", "password")) + advanceUntilIdle() + val failed = assertIs(store.state.value) + assertEquals(AccountFailureStage.DEVICE_LIST, failed.stage) + val stored = secure.read("cloud_account_session")!!.toList() + val writes = secure.writeCount + val deletes = secure.deleteCount + + backend.listFailure = null + store.dispatch(AccountIntent.Retry) + advanceUntilIdle() + + val ready = assertIs(store.state.value) + assertEquals("desktop-1", ready.selectedDeviceId) + assertEquals(stored, secure.read("cloud_account_session")?.toList()) + assertEquals(writes, secure.writeCount) + assertEquals(deletes, secure.deleteCount) + } + + @Test + fun writeFailureFailsClosedAndRestoresExistingBytes() = runTest { + val secure = MemorySecureStore() + val existing = "existing-session-bytes".encodeToByteArray() + secure.write("cloud_account_session", existing) + secure.failWrites = true + secure.mutateBeforeWriteFailure = true + val store = AccountStore.create(this, FakeAccountBackend(), secure, "phone-1", "Android") + + store.dispatch(AccountIntent.Login("https://relay.test", "user", "password")) + advanceUntilIdle() + + val failed = assertIs(store.state.value) + assertEquals(AccountFailureReason.SECURE_STORAGE, failed.reason) + assertNull(store.cloudSettingsSource()) + assertNull(store.createSessionStore(this)) + assertEquals(existing.toList(), secure.read("cloud_account_session")?.toList()) + assertEquals(0, secure.deleteCount) + } + + @Test + fun deleteFailureLogsOutInMemoryWithoutDestroyingStoredBytes() = runTest { + val secure = MemorySecureStore() + val store = AccountStore.create(this, FakeAccountBackend(), secure, "phone-1", "Android") + store.dispatch(AccountIntent.Login("https://relay.test", "user", "password")) + advanceUntilIdle() + val stored = secure.read("cloud_account_session")!!.toList() + secure.failDeletes = true + + store.dispatch(AccountIntent.Logout) + + val failed = assertIs(store.state.value) + assertEquals(AccountFailureReason.SECURE_STORAGE, failed.reason) + assertNull(store.cloudSettingsSource()) + assertNull(store.createSessionStore(this)) + assertEquals(stored, secure.read("cloud_account_session")?.toList()) } @Test @@ -162,23 +427,62 @@ class AccountStoreTest { } } -private class MemorySecureStore : SecureStore { +private class MemorySecureStore( + var failWrites: Boolean = false, + var failReads: Boolean = false, + var failDeletes: Boolean = false, + var mutateBeforeWriteFailure: Boolean = false, +) : SecureStore { private val values = mutableMapOf() - override fun read(key: String): ByteArray? = values[key]?.copyOf() + var writeCount: Int = 0 + private set + var deleteCount: Int = 0 + private set + + override fun read(key: String): ByteArray? { + if (failReads) error("secure store read failed") + return values[key]?.copyOf() + } + override fun write(key: String, value: ByteArray) { + writeCount += 1 + if (failWrites) { + if (mutateBeforeWriteFailure) { + values[key] = value.copyOf() + // Let AccountStore prove that it restores the previous bytes. + failWrites = false + } + error("secure store write failed") + } values[key] = value.copyOf() } + override fun delete(key: String) { + deleteCount += 1 + if (failDeletes) error("secure store delete failed") values.remove(key) } } +private fun CloudAccountFailure.toExpectedReason(): AccountFailureReason = when (this) { + CloudAccountFailure.INVALID_CREDENTIALS -> AccountFailureReason.INVALID_CREDENTIALS + CloudAccountFailure.AUTHENTICATION -> AccountFailureReason.AUTHENTICATION + CloudAccountFailure.RATE_LIMITED -> AccountFailureReason.RATE_LIMITED + CloudAccountFailure.RELAY_UNAVAILABLE -> AccountFailureReason.RELAY_UNAVAILABLE + CloudAccountFailure.NETWORK -> AccountFailureReason.NETWORK + CloudAccountFailure.TIMEOUT -> AccountFailureReason.TIMEOUT + CloudAccountFailure.MALFORMED_RESPONSE -> AccountFailureReason.MALFORMED_RESPONSE +} + private class FakeAccountBackend : AccountBackend { var failure: CloudAccountFailure? = null + var loginThrowable: Throwable? = null var listFailure: CloudAccountFailure? = null + var listThrowable: Throwable? = null var desktop1Online: Boolean = true var desktop2Online: Boolean = false var settings: String? = null + val transportTargets = mutableListOf() override suspend fun login( relayUrl: String, username: String, @@ -186,6 +490,7 @@ private class FakeAccountBackend : AccountBackend { deviceId: String, deviceName: String, ): AccountSessionData { + loginThrowable?.let { throw it } failure?.let { throw CloudAccountException(it) } return AccountSessionData( relayUrl, @@ -199,6 +504,7 @@ private class FakeAccountBackend : AccountBackend { } override suspend fun listDevices(session: AccountSessionData, selfDeviceId: String): List { + listThrowable?.let { throw it } listFailure?.let { throw CloudAccountException(it) } // The shape the live account returns: this device, one of the user's // other phones, and the desktops that are the only real targets. @@ -212,12 +518,14 @@ private class FakeAccountBackend : AccountBackend { override suspend fun fetchSettings(session: AccountSessionData): String? = settings - override fun transport(session: AccountSessionData, targetDeviceId: String): RemoteCommandTransport = - object : RemoteCommandTransport { + override fun transport(session: AccountSessionData, targetDeviceId: String): RemoteCommandTransport { + transportTargets += targetDeviceId + return object : RemoteCommandTransport { override suspend fun send( deserializer: DeserializationStrategy, command: RemoteCommand, timeoutMs: Long, ): T = error("unused") } + } } diff --git a/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/bitfun/mobile/core/feature/directory/DeviceDirectoryStoreTest.kt b/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/bitfun/mobile/core/feature/directory/DeviceDirectoryStoreTest.kt new file mode 100644 index 0000000000..101bfbbd61 --- /dev/null +++ b/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/bitfun/mobile/core/feature/directory/DeviceDirectoryStoreTest.kt @@ -0,0 +1,367 @@ +package com.bitfun.mobile.core.feature.directory + +import com.bitfun.mobile.core.domain.RemoteSession +import com.bitfun.mobile.core.feature.session.RemoteSessionStore +import com.bitfun.mobile.core.feature.workspace.RemoteWorkspaceStore +import com.bitfun.mobile.core.protocol.CommandStatus +import com.bitfun.mobile.core.protocol.RelayJson +import com.bitfun.mobile.core.protocol.RemoteCommand +import com.bitfun.mobile.core.transport.RelayFailure +import com.bitfun.mobile.core.transport.RelayTransportException +import com.bitfun.mobile.core.transport.RemoteCommandTransport +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.DeserializationStrategy +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class DeviceDirectoryStoreTest { + @Test + fun devicesLoadIndependentlyAndOneFailureDoesNotClearOthers() = runTest { + val transports = mutableMapOf( + "a" to FakeDeviceTransport("a"), + "b" to FakeDeviceTransport("b"), + ) + transports.getValue("b").sessionFailure = RelayFailure.Timeout + val store = DeviceDirectoryStore.create(this, FakeDeviceStoreFactory(transports)) + + store.dispatch( + DeviceDirectoryIntent.Sync( + listOf( + DeviceDirectoryDevice("a", "Alpha", true), + DeviceDirectoryDevice("b", "Beta", true), + ), + ), + ) + store.dispatch(DeviceDirectoryIntent.Load("a")) + store.dispatch(DeviceDirectoryIntent.Load("b")) + advanceUntilIdle() + + val a = store.state.value.device("a")!! + assertEquals(DeviceDirectoryStatus.READY, a.status) + assertEquals(listOf("/repo-a"), a.workspaces.map { it.path }) + assertEquals(listOf("s-a"), a.sessions.map { it.id }) + + val failedB = store.state.value.device("b")!! + assertEquals(DeviceDirectoryStatus.FAILED, failedB.status) + assertEquals(DeviceDirectoryFailure.TIMEOUT, failedB.error) + + // A retry recovers b without disturbing a's already-loaded content. + transports.getValue("b").sessionFailure = null + store.dispatch(DeviceDirectoryIntent.Retry("b")) + advanceUntilIdle() + + val recoveredB = store.state.value.device("b")!! + assertEquals(DeviceDirectoryStatus.READY, recoveredB.status) + assertEquals(listOf("s-b"), recoveredB.sessions.map { it.id }) + val stillA = store.state.value.device("a")!! + assertEquals(DeviceDirectoryStatus.READY, stillA.status) + assertEquals(listOf("s-a"), stillA.sessions.map { it.id }) + } + + @Test + fun duplicateLoadCollapsesToOneFetch() = runTest { + val transport = FakeDeviceTransport("a") + val store = DeviceDirectoryStore.create(this, FakeDeviceStoreFactory(mutableMapOf("a" to transport))) + + store.dispatch(DeviceDirectoryIntent.Sync(listOf(DeviceDirectoryDevice("a", true)))) + store.dispatch(DeviceDirectoryIntent.Load("a")) + store.dispatch(DeviceDirectoryIntent.Load("a")) + advanceUntilIdle() + + val a = store.state.value.device("a")!! + assertEquals(DeviceDirectoryStatus.READY, a.status) + assertEquals(1, transport.commands.count { it.cmd == "list_recent_workspaces" }) + assertEquals(1, transport.commands.count { it.cmd == "list_sessions" }) + } + + @Test + fun expandPreservesCachedDataWithoutRefetching() = runTest { + val transport = FakeDeviceTransport("a") + val store = DeviceDirectoryStore.create(this, FakeDeviceStoreFactory(mutableMapOf("a" to transport))) + + store.dispatch(DeviceDirectoryIntent.Sync(listOf(DeviceDirectoryDevice("a", true)))) + store.dispatch(DeviceDirectoryIntent.Expand("a")) + advanceUntilIdle() + + val first = store.state.value.device("a")!! + assertTrue(first.expanded) + assertEquals(DeviceDirectoryStatus.READY, first.status) + assertEquals(listOf("s-a"), first.sessions.map { it.id }) + val listSessionsBefore = transport.commands.count { it.cmd == "list_sessions" } + assertEquals(1, listSessionsBefore) + + store.dispatch(DeviceDirectoryIntent.Collapse("a")) + assertFalse(store.state.value.device("a")!!.expanded) + + // Re-expanding a ready device keeps the cache instead of re-fetching. + store.dispatch(DeviceDirectoryIntent.Expand("a")) + advanceUntilIdle() + + val again = store.state.value.device("a")!! + assertTrue(again.expanded) + assertEquals(DeviceDirectoryStatus.READY, again.status) + assertEquals(listOf("s-a"), again.sessions.map { it.id }) + assertEquals(listSessionsBefore, transport.commands.count { it.cmd == "list_sessions" }) + } + + @Test + fun stopCancelsRunningLoadsButKeepsLoadedData() = runTest { + val transports = mutableMapOf( + "a" to FakeDeviceTransport("a"), + "b" to FakeDeviceTransport("b"), + ) + val bGate = CompletableDeferred() + transports.getValue("b").sessionGate = bGate + val store = DeviceDirectoryStore.create(this, FakeDeviceStoreFactory(transports)) + + store.dispatch( + DeviceDirectoryIntent.Sync( + listOf( + DeviceDirectoryDevice("a", true), + DeviceDirectoryDevice("b", true), + ), + ), + ) + store.dispatch(DeviceDirectoryIntent.Load("a")) + advanceUntilIdle() + assertEquals(DeviceDirectoryStatus.READY, store.state.value.device("a")!!.status) + + store.dispatch(DeviceDirectoryIntent.Load("b")) + runCurrent() + + // b reached its session request and is blocked, so it is still loading. + assertEquals(DeviceDirectoryStatus.LOADING, store.state.value.device("b")!!.status) + assertTrue(transports.getValue("b").commands.any { it.cmd == "list_sessions" }) + + store.dispatch(DeviceDirectoryIntent.Stop) + advanceUntilIdle() + + // Loaded data survives; the in-flight load is cancelled, not turned into a failure. + assertEquals(DeviceDirectoryStatus.READY, store.state.value.device("a")!!.status) + assertEquals(listOf("s-a"), store.state.value.device("a")!!.sessions.map { it.id }) + assertEquals(DeviceDirectoryStatus.IDLE, store.state.value.device("b")!!.status) + assertFalse(bGate.isCompleted) + } + + @Test + fun offlineTransitionCancelsLoadAndCachedDataCanReloadAfterReconnect() = runTest { + val transport = FakeDeviceTransport("a") + val factory = FakeDeviceStoreFactory(mutableMapOf("a" to transport)) + val store = DeviceDirectoryStore.create(this, factory) + store.dispatch(DeviceDirectoryIntent.Sync(listOf(DeviceDirectoryDevice("a", true)))) + store.dispatch(DeviceDirectoryIntent.Load("a")) + advanceUntilIdle() + assertEquals(DeviceDirectoryStatus.READY, store.state.value.device("a")!!.status) + + val gate = CompletableDeferred() + transport.sessionGate = gate + store.dispatch(DeviceDirectoryIntent.Retry("a")) + runCurrent() + assertEquals(DeviceDirectoryStatus.LOADING, store.state.value.device("a")!!.status) + + store.dispatch(DeviceDirectoryIntent.Sync(listOf(DeviceDirectoryDevice("a", "Alpha", false)))) + assertEquals(DeviceDirectoryStatus.CACHED, store.state.value.device("a")!!.status) + assertFalse(store.state.value.device("a")!!.online) + assertEquals(2, transport.commands.count { it.cmd == "list_recent_workspaces" }) + transport.sessionGate = null + + store.dispatch(DeviceDirectoryIntent.Sync(listOf(DeviceDirectoryDevice("a", "Alpha", true)))) + store.dispatch(DeviceDirectoryIntent.Load("a")) + advanceUntilIdle() + assertEquals(DeviceDirectoryStatus.READY, store.state.value.device("a")!!.status) + } + + @Test + fun stopThenImmediateReloadIgnoresCancelledJobFinally() = runTest { + val transport = FakeDeviceTransport("a") + val gate = CompletableDeferred() + transport.sessionGate = gate + val factory = FakeDeviceStoreFactory(mutableMapOf("a" to transport)) + val store = DeviceDirectoryStore.create(this, factory) + store.dispatch(DeviceDirectoryIntent.Sync(listOf(DeviceDirectoryDevice("a", true)))) + store.dispatch(DeviceDirectoryIntent.Load("a")) + runCurrent() + store.dispatch(DeviceDirectoryIntent.Stop) + transport.sessionGate = null + store.dispatch(DeviceDirectoryIntent.Load("a")) + advanceUntilIdle() + assertEquals(DeviceDirectoryStatus.READY, store.state.value.device("a")!!.status) + assertEquals(2, transport.commands.count { it.cmd == "list_sessions" }) + assertFalse(gate.isCompleted) + } + + @Test + fun partialSlotCreationStopsTheSessionStore() = runTest { + val transport = FakeDeviceTransport("a") + val factory = FakeDeviceStoreFactory(mutableMapOf("a" to transport), failWorkspace = setOf("a")) + val store = DeviceDirectoryStore.create(this, factory) + store.dispatch(DeviceDirectoryIntent.Sync(listOf(DeviceDirectoryDevice("a", true)))) + store.dispatch(DeviceDirectoryIntent.Load("a")) + assertEquals(DeviceDirectoryFailure.NOT_SIGNED_IN, store.state.value.device("a")!!.error) + assertTrue(transport.commands.none { it.cmd == "list_sessions" }) + } + + @Test + fun confirmedCreateReconcilesOnlyOwningDeviceAndServerEventuallyCalibratesIt() = runTest { + val transports = mutableMapOf( + "a" to FakeDeviceTransport("a"), + "b" to FakeDeviceTransport("b"), + ) + val store = DeviceDirectoryStore.create(this, FakeDeviceStoreFactory(transports)) + store.dispatch(DeviceDirectoryIntent.Sync(listOf(DeviceDirectoryDevice("a", true), DeviceDirectoryDevice("b", true)))) + store.dispatch(DeviceDirectoryIntent.Load("a")) + store.dispatch(DeviceDirectoryIntent.Load("b")) + advanceUntilIdle() + val key = store.reconcileKey("a")!! + val confirmed = RemoteSession( + id = "created", title = "Confirmed", agentType = "cowork", status = "active", + updatedAt = "created-time", createdAt = "created-time", messageCount = 1, + workspacePath = "/assistant-not-current", workspaceName = "Assistant", + ) + + assertTrue(store.reconcileCreatedSession(key, confirmed)) + assertTrue(store.reconcileCreatedSession(key, confirmed)) + assertEquals(listOf("created", "s-a"), store.state.value.device("a")!!.sessions.map { it.id }) + assertEquals("/assistant-not-current", store.state.value.device("a")!!.sessions.first().workspacePath) + assertEquals(listOf("s-b"), store.state.value.device("b")!!.sessions.map { it.id }) + + // The first server list is behind the confirmed create; the local row survives. + store.dispatch(DeviceDirectoryIntent.Retry("a")) + advanceUntilIdle() + assertEquals(1, store.state.value.device("a")!!.sessions.count { it.id == "created" }) + + // Once the source returns the id, its newer fields replace the projection without duplication. + transports.getValue("a").sessionJson = + """[{"id":"created","title":"Server title","agent_type":"cowork","status":"idle","workspace_path":"/assistant-not-current","workspace_name":"Server assistant"}]""" + store.dispatch(DeviceDirectoryIntent.Retry("a")) + advanceUntilIdle() + val calibrated = store.state.value.device("a")!!.sessions.single { it.id == "created" } + assertEquals("Server title", calibrated.title) + assertEquals("Server assistant", calibrated.workspaceName) + assertEquals(1, store.state.value.device("a")!!.sessions.count { it.id == "created" }) + } + + @Test + fun staleReconcileCannotReviveRemovedStoppedOrReconnectedDevice() = runTest { + val transport = FakeDeviceTransport("a") + val store = DeviceDirectoryStore.create(this, FakeDeviceStoreFactory(mutableMapOf("a" to transport))) + val device = DeviceDirectoryDevice("a", true) + val confirmed = RemoteSession( + id = "created", title = "Created", agentType = "code", status = "active", + updatedAt = "", createdAt = "", messageCount = 0, + workspacePath = "/repo-a", workspaceName = null, + ) + + store.dispatch(DeviceDirectoryIntent.Sync(listOf(device))) + val removedKey = store.reconcileKey("a")!! + store.dispatch(DeviceDirectoryIntent.Sync(emptyList())) + assertFalse(store.reconcileCreatedSession(removedKey, confirmed)) + + store.dispatch(DeviceDirectoryIntent.Sync(listOf(device))) + val stoppedKey = store.reconcileKey("a")!! + store.dispatch(DeviceDirectoryIntent.Stop) + assertFalse(store.reconcileCreatedSession(stoppedKey, confirmed)) + + val reconnectKey = store.reconcileKey("a")!! + store.dispatch(DeviceDirectoryIntent.Sync(listOf(DeviceDirectoryDevice("a", false)))) + store.dispatch(DeviceDirectoryIntent.Sync(listOf(device))) + assertFalse(store.reconcileCreatedSession(reconnectKey, confirmed)) + assertTrue(store.state.value.device("a")!!.sessions.none { it.id == "created" }) + } + + @Test + fun offlineDevicesWithoutCacheRemainIdleAndMissingStoresFailTyped() = runTest { + val transports = mutableMapOf("a" to FakeDeviceTransport("a")) + val store = DeviceDirectoryStore.create(this, FakeDeviceStoreFactory(transports, missing = setOf("b"))) + + store.dispatch( + DeviceDirectoryIntent.Sync( + listOf( + DeviceDirectoryDevice("a", true), + DeviceDirectoryDevice("b", "Offline", false), + ), + ), + ) + store.dispatch(DeviceDirectoryIntent.Load("b")) + advanceUntilIdle() + + // Offline rows stay idle; they are never asked for content. + val offline = store.state.value.device("b")!! + assertEquals(DeviceDirectoryStatus.IDLE, offline.status) + assertTrue(offline.workspaces.isEmpty()) + assertTrue(offline.sessions.isEmpty()) + assertEquals(0, transports.getValue("a").commands.count { it.cmd == "list_sessions" }) + + // An online device whose store cannot be created fails as NOT_SIGNED_IN. + val missingTransports = mutableMapOf() + val missingStore = DeviceDirectoryStore.create( + this, + FakeDeviceStoreFactory(missingTransports, missing = setOf("x")), + ) + missingStore.dispatch(DeviceDirectoryIntent.Sync(listOf(DeviceDirectoryDevice("x", true)))) + missingStore.dispatch(DeviceDirectoryIntent.Load("x")) + advanceUntilIdle() + val x = missingStore.state.value.device("x")!! + assertEquals(DeviceDirectoryStatus.FAILED, x.status) + assertEquals(DeviceDirectoryFailure.NOT_SIGNED_IN, x.error) + } +} + +private class FakeDeviceStoreFactory( + private val transports: MutableMap, + private val missing: Set = emptySet(), + private val failWorkspace: Set = emptySet(), +) : DeviceStoreFactory { + override fun createSessionStore(scope: CoroutineScope, deviceId: String): RemoteSessionStore? { + if (deviceId in missing) return null + return RemoteSessionStore.create(scope, transports.getValue(deviceId)) + } + + override fun createWorkspaceStore(scope: CoroutineScope, deviceId: String): RemoteWorkspaceStore? { + if (deviceId in missing || deviceId in failWorkspace) return null + return RemoteWorkspaceStore.create(scope, transports.getValue(deviceId)) + } +} + +private class FakeDeviceTransport(private val deviceId: String) : RemoteCommandTransport { + val commands = mutableListOf() + var workspacePath: String = "/repo-$deviceId" + var sessionFailure: RelayFailure? = null + var workspaceFailure: RelayFailure? = null + var sessionGate: CompletableDeferred? = null + var sessionJson: String = + """[{"id":"s-$deviceId","title":"Session $deviceId","agent_type":"code"}]""" + + override suspend fun send( + deserializer: DeserializationStrategy, + command: RemoteCommand, + timeoutMs: Long, + ): T { + commands += command + val json = when (command.cmd) { + "list_recent_workspaces" -> { + workspaceFailure?.let { throw RelayTransportException(it) } + """{"resp":"ok","workspaces":[{"path":"/repo-$deviceId","name":"Repo $deviceId","last_opened":"2026-08-09","workspace_kind":"local"}]}""" + } + "list_assistants" -> """{"resp":"ok","assistants":[]}""" + "get_workspace_info" -> + """{"resp":"ok","has_workspace":true,"path":"$workspacePath","project_name":"Repo","git_branch":"main"}""" + "list_sessions" -> { + sessionFailure?.let { throw RelayTransportException(it) } + sessionGate?.await() + """{"resp":"ok","has_more":false,"sessions":$sessionJson}""" + } + "get_model_catalog" -> """{"resp":"ok"}""" + else -> error("Unexpected command ${command.cmd}") + } + return RelayJson.decodeFromString(deserializer, json) + } +} diff --git a/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/bitfun/mobile/core/feature/generalchat/GeneralChatStoreTest.kt b/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/bitfun/mobile/core/feature/generalchat/GeneralChatStoreTest.kt index 651c63246d..12f96eb5b9 100644 --- a/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/bitfun/mobile/core/feature/generalchat/GeneralChatStoreTest.kt +++ b/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/bitfun/mobile/core/feature/generalchat/GeneralChatStoreTest.kt @@ -85,6 +85,52 @@ class GeneralChatStoreTest { assertFalse(store.state.value.configured) } + @Test + fun secureReadFailureDuringConstructionIsFailClosedWithoutMutatingStorage() = runTest { + val sessionId = "existing-session" + val chats = MemoryChats().apply { + sessions += PersistedChatSession( + sessionId = sessionId, + title = "Existing conversation", + agentType = "general_chat", + status = "ready", + updatedAt = "2026-01-02T00:00:00Z", + createdAt = "2026-01-01T00:00:00Z", + messageCount = 1, + pinned = false, + ) + messages += PersistedChatMessage( + messageId = "message-1", + sessionId = sessionId, + role = "user", + text = "restored message", + status = "sent", + timestamp = "2026-01-01T00:00:00Z", + thinking = null, + payloadJson = "{}", + ) + } + val drafts = MemoryDrafts( + mutableMapOf("general-chat-composer:$sessionId" to "restored draft"), + ) + val secure = FailingReadSecure() + + val store = store(this, drafts = drafts, chats = chats, secure = secure) + + val state = store.state.value + assertFalse(state.configured) + assertEquals(GeneralChatConfigUi("", "", false), state.config) + assertEquals(GeneralChatConfigFailure.SECURE_STORAGE, state.configFailure) + assertEquals(emptyList(), state.models) + assertEquals("", state.activeModelId) + assertEquals(sessionId, state.sessionId) + assertEquals("restored draft", state.draft) + assertEquals(listOf("restored message"), state.timeline.persistedMessages.map { it.text }) + assertEquals(1, secure.reads) + assertEquals(0, secure.writes) + assertEquals(0, secure.deletes) + } + @Test fun sendPersistsComposerStateAndProjectsStreamedReply() = runTest { val drafts = MemoryDrafts() @@ -564,6 +610,25 @@ private class MemoryDrafts( } } +private class FailingReadSecure : SecureStore { + var reads = 0 + var writes = 0 + var deletes = 0 + + override fun read(key: String): ByteArray? { + reads += 1 + error("keystore unavailable") + } + + override fun write(key: String, value: ByteArray) { + writes += 1 + } + + override fun delete(key: String) { + deletes += 1 + } +} + private class MemorySecure(var failWrites: Boolean = false) : SecureStore { private val values = mutableMapOf() diff --git a/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/bitfun/mobile/core/feature/session/RemoteSessionPersistenceTest.kt b/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/bitfun/mobile/core/feature/session/RemoteSessionPersistenceTest.kt index 300cdb4cd7..06eaf5b446 100644 --- a/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/bitfun/mobile/core/feature/session/RemoteSessionPersistenceTest.kt +++ b/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/bitfun/mobile/core/feature/session/RemoteSessionPersistenceTest.kt @@ -1,5 +1,6 @@ package com.bitfun.mobile.core.feature.session +import com.bitfun.mobile.core.domain.RemoteSession import com.bitfun.mobile.core.feature.connection.ConnectionPhase import com.bitfun.mobile.core.persistence.ChatLocalStore import com.bitfun.mobile.core.persistence.DraftStore @@ -17,12 +18,16 @@ import com.bitfun.mobile.core.protocol.RemoteCommand import com.bitfun.mobile.core.transport.RelayFailure import com.bitfun.mobile.core.transport.RelayTransportException import com.bitfun.mobile.core.transport.RemoteCommandTransport +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import kotlinx.serialization.DeserializationStrategy +import kotlin.coroutines.Continuation +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertIs @@ -42,6 +47,75 @@ class RemoteSessionPersistenceTest { store.dispatch(RemoteSessionIntent.Stop) } + @Test + fun confirmedCreateReconcilePersistsByDeviceAndRebuildRestoresIt() = runTest { + val stores = MemoryPersistence() + stores.sessions.byDevice["device-b"] = listOf(PersistedRemoteSession(sessionId = "other", title = "Other")) + val first = RemoteSessionStore.create(this, PersistenceTransport(), "device-a", stores.stores) + val confirmed = RemoteSession( + id = "created", title = "Created", agentType = "cowork", status = "active", + updatedAt = "now", createdAt = "now", messageCount = 1, + workspacePath = "/assistant", workspaceName = "Assistant", + ) + + assertEquals(true, first.reconcileConfirmedCreatedSession(confirmed)) + assertEquals(listOf("created"), stores.sessions.byDevice.getValue("device-a").map { it.sessionId }) + assertEquals(listOf("other"), stores.sessions.byDevice.getValue("device-b").map { it.sessionId }) + assertEquals("/assistant", stores.sessions.byDevice.getValue("device-a").single().workspacePath) + + assertEquals(true, stores.sessions.byDevice.getValue("device-a").single().pendingConfirmed) + + val laggingTransport = PersistenceTransport().apply { sessionsJson = "[]" } + val rebuilt = RemoteSessionStore.create(this, laggingTransport, "device-a", stores.stores) + rebuilt.dispatch(RemoteSessionIntent.Load) + advanceUntilIdle() + val afterLaggingList = assertIs(rebuilt.state.value).sessions.single() + assertEquals("created", afterLaggingList.id) + assertEquals("/assistant", afterLaggingList.workspacePath) + assertEquals(true, stores.sessions.byDevice.getValue("device-a").single().pendingConfirmed) + + laggingTransport.sessionsJson = + """[{"id":"created","title":"Server calibrated","agent_type":"cowork","status":"idle","workspace_path":"/assistant","workspace_name":"Server assistant"}]""" + rebuilt.dispatch(RemoteSessionIntent.Refresh) + advanceUntilIdle() + val calibrated = assertIs(rebuilt.state.value).sessions.single() + assertEquals("Server calibrated", calibrated.title) + assertEquals("Server assistant", calibrated.workspaceName) + assertEquals(false, stores.sessions.byDevice.getValue("device-a").single().pendingConfirmed) + first.stop() + rebuilt.stop() + } + + @Test + fun validCreateIdIsDurableBeforeGatedModelInitializationAndSurvivesStop() = runTest { + val stores = MemoryPersistence() + val transport = PersistenceTransport() + transport.commandGates["set_session_model"] = CompletableDeferred() + val store = RemoteSessionStore.create(this, transport, "device-a", stores.stores) + store.dispatch( + RemoteSessionIntent.CreateSessionOperation( + "durable-create", "cowork", "Created", "", "model-primary", "/assistant", + ), + ) + runCurrent() + + assertIs(store.createOperation.value) + val persisted = stores.sessions.byDevice.getValue("device-a").single() + assertEquals("created", persisted.sessionId) + assertEquals("/assistant", persisted.workspacePath) + assertEquals(true, persisted.pendingConfirmed) + store.stop() + runCurrent() + assertIs(store.createOperation.value) + + val rebuiltTransport = PersistenceTransport().apply { sessionsJson = "[]" } + val rebuilt = RemoteSessionStore.create(this, rebuiltTransport, "device-a", stores.stores) + rebuilt.dispatch(RemoteSessionIntent.Load) + advanceUntilIdle() + assertEquals("created", assertIs(rebuilt.state.value).sessions.single().id) + rebuilt.stop() + } + @Test fun draftIsSavedRestoredAndClearedAfterSend() = runTest { val stores = MemoryPersistence() @@ -127,6 +201,28 @@ class RemoteSessionPersistenceTest { store.dispatch(RemoteSessionIntent.Stop) } + @Test + fun staleLoadMoreDoesNotWriteSessionPersistence() = runTest { + val stores = MemoryPersistence() + val transport = PersistenceTransport().apply { hasMore = true } + val store = RemoteSessionStore.create(this, transport, "device-a", stores.stores) + store.dispatch(RemoteSessionIntent.Load) + advanceUntilIdle() + val savesBeforeLatePage = stores.sessions.saveCount + + transport.nonCancellableCommands += "list_sessions" + store.dispatch(RemoteSessionIntent.LoadMore) + runCurrent() + val lateLoadMore = transport.lateCommandContinuations.remove("list_sessions")!! + store.dispatch(RemoteSessionIntent.Open("server")) + runCurrent() + lateLoadMore.resume(Unit) + runCurrent() + + assertEquals(savesBeforeLatePage, stores.sessions.saveCount) + store.stop() + } + @Test fun corruptedPayloadIsRetainedAsDegradedMessage() = runTest { val stores = MemoryPersistence() @@ -168,9 +264,16 @@ private class NoOpChats : ChatLocalStore { private class MemorySessions : RemoteSessionListStore { var rows = emptyList() + val byDevice = mutableMapOf>() var more = false - override fun load(deviceKey: String): List = rows - override fun save(deviceKey: String, sessions: List, hasMore: Boolean) { rows = sessions; more = hasMore } + var saveCount = 0 + override fun load(deviceKey: String): List = byDevice[deviceKey] ?: rows + override fun save(deviceKey: String, sessions: List, hasMore: Boolean) { + saveCount += 1 + rows = sessions + byDevice[deviceKey] = sessions + more = hasMore + } override fun hasMore(deviceKey: String): Boolean = more } @@ -185,6 +288,10 @@ private class MemoryTranscripts : RemoteTranscriptStore { } private class PersistenceTransport : RemoteCommandTransport { + val commandGates = mutableMapOf>() + val nonCancellableCommands = mutableSetOf() + val lateCommandContinuations = mutableMapOf>() + var sessionsJson: String = """[{"id":"server","title":"Server","agent_type":"code"}]""" var messagesJson: String = "[]" var hasMore: Boolean = false var polls: List = listOf("""{"resp":"ok","version":1,"changed":false,"session_state":"idle"}""") @@ -192,13 +299,19 @@ private class PersistenceTransport : RemoteCommandTransport { var pollFailure: RelayFailure? = null val sinceVersions = mutableListOf() override suspend fun send(deserializer: DeserializationStrategy, command: RemoteCommand, timeoutMs: Long): T { + commandGates[command.cmd]?.await() + if (nonCancellableCommands.remove(command.cmd)) { + suspendCoroutine { continuation -> lateCommandContinuations[command.cmd] = continuation } + } if (command.cmd == "poll_session") { sinceVersions += command.sinceVersion ?: 0 pollFailure?.let { throw RelayTransportException(it) } } val json = when (command.cmd) { "get_workspace_info" -> "{\"resp\":\"ok\",\"path\":\"/repo\"}" - "list_sessions" -> "{\"resp\":\"ok\",\"sessions\":[{\"id\":\"server\",\"title\":\"Server\",\"agent_type\":\"code\"}]}" + "create_session" -> "{\"resp\":\"ok\",\"session_id\":\"created\",\"title\":\"Created\"}" + "set_session_model" -> "{\"resp\":\"ok\",\"model_id\":\"model-primary\"}" + "list_sessions" -> "{\"resp\":\"ok\",\"sessions\":$sessionsJson,\"has_more\":$hasMore}" "get_session_messages" -> "{\"resp\":\"ok\",\"messages\":$messagesJson,\"has_more\":$hasMore}" "get_permission_mode" -> "{\"resp\":\"ok\",\"mode\":\"ask\"}" "get_model_catalog" -> "{\"resp\":\"ok\",\"catalog\":{\"version\":0,\"models\":[],\"default_models\":{}}}" diff --git a/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/bitfun/mobile/core/feature/session/RemoteSessionStoreTest.kt b/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/bitfun/mobile/core/feature/session/RemoteSessionStoreTest.kt index f45a81856b..f6e82cba2b 100644 --- a/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/bitfun/mobile/core/feature/session/RemoteSessionStoreTest.kt +++ b/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/bitfun/mobile/core/feature/session/RemoteSessionStoreTest.kt @@ -1,5 +1,6 @@ package com.bitfun.mobile.core.feature.session +import com.bitfun.mobile.core.domain.RemoteSession import com.bitfun.mobile.core.protocol.CommandStatus import com.bitfun.mobile.core.protocol.RelayJson import com.bitfun.mobile.core.protocol.RemoteCommand @@ -7,12 +8,20 @@ import com.bitfun.mobile.core.feature.connection.ConnectionPhase import com.bitfun.mobile.core.transport.RelayFailure import com.bitfun.mobile.core.transport.RelayTransportException import com.bitfun.mobile.core.transport.RemoteCommandTransport +import com.bitfun.mobile.core.feature.workspace.RemoteWorkspaceIntent +import com.bitfun.mobile.core.feature.workspace.RemoteWorkspaceStore +import com.bitfun.mobile.core.feature.workspace.RemoteWorkspaceUiState +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import kotlinx.serialization.DeserializationStrategy +import kotlin.coroutines.Continuation +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -22,6 +31,17 @@ import kotlin.test.assertTrue @OptIn(ExperimentalCoroutinesApi::class) class RemoteSessionStoreTest { + @Test + fun additiveRevisionConstructorsKeepLegacySourceShape() { + val succeeded = CreateSessionOperationState.Succeeded("request", "session", null) + assertEquals(0, succeeded.commitRevision) + val ready = RemoteSessionUiState.Ready( + emptyList(), null, null, false, null, null, "", SessionAgentFilter.ALL, + false, false, null, null, "", + ) + assertEquals(0, ready.revision) + } + @Test fun listsSessionsForTheWorkspaceTheDesktopHasOpen() = runTest { val transport = FakeSessionTransport() @@ -238,9 +258,432 @@ class RemoteSessionStoreTest { // session that create_session just opened, not fall back to "agentic". assertEquals("code", transport.commands.first { it.cmd == "send_message" }.agentType) assertEquals("s-new", assertIs(store.state.value).selectedSessionId) + val outcome = assertIs(store.createOperation.value) + assertEquals("s-new", outcome.createdSessionId) + assertEquals("/repo", outcome.confirmedSession?.workspacePath) + assertTrue(outcome.requestId.isNotBlank()) + store.dispatch(RemoteSessionIntent.Open("s-code")) + runCurrent() + assertIs(store.createOperation.value) store.dispatch(RemoteSessionIntent.Stop) } + @Test + fun createPreemptsBlockedRefreshAndPublishesRevisionLinkedCommit() = runTest { + val transport = FakeSessionTransport() + val store = RemoteSessionStore.create(this, transport) + store.dispatch(RemoteSessionIntent.Load) + advanceUntilIdle() + val beforeRefreshReady = assertIs(store.state.value) + val beforeRefresh = beforeRefreshReady.revision + + transport.nonCancellableCommands += "list_sessions" + store.dispatch(RemoteSessionIntent.Refresh) + runCurrent() + val lateRefresh = transport.lateCommandContinuations.remove("list_sessions")!! + store.dispatch( + RemoteSessionIntent.CreateSessionOperation( + "create-preempts-refresh", "code", "", "", null, "/repo", + ), + ) + runCurrent() + + val succeeded = assertIs(store.createOperation.value) + val ready = assertIs(store.state.value) + assertTrue(succeeded.commitRevision > beforeRefresh) + assertTrue(ready.revision >= succeeded.commitRevision) + assertTrue(ready.sessions.any { it.id == succeeded.createdSessionId }) + assertEquals("s-new", ready.selectedSessionId) + + lateRefresh.resume(Unit) + runCurrent() + val afterLateRefresh = assertIs(store.state.value) + assertEquals(ready.revision, afterLateRefresh.revision) + assertEquals(ready.sessions, afterLateRefresh.sessions) + assertEquals("s-new", afterLateRefresh.selectedSessionId) + store.stop() + } + + @Test + fun staleCommittedCreateCancellationDoesNotClearNewerBusyState() = runTest { + val transport = FakeSessionTransport() + val store = RemoteSessionStore.create(this, transport) + transport.commandGates["get_session_messages"] = CompletableDeferred() + store.dispatch(RemoteSessionIntent.CreateSessionOperation("committed-cancel", "code", "", "", null, "/repo")) + runCurrent() + assertIs(store.createOperation.value) + + transport.commandGates["list_sessions"] = CompletableDeferred() + store.dispatch(RemoteSessionIntent.Refresh) + runCurrent() + + assertTrue(assertIs(store.state.value).busy) + transport.commandGates.remove("list_sessions")?.complete(Unit) + runCurrent() + store.stop() + } + + @Test + fun lateNonCancellableLoadMoreCannotOverwriteNewerRefresh() = runTest { + val transport = FakeSessionTransport().apply { + paged = true + pagedLimit = 40 + } + val store = RemoteSessionStore.create(this, transport) + store.dispatch(RemoteSessionIntent.Load) + advanceUntilIdle() + + transport.nonCancellableCommands += "list_sessions" + store.dispatch(RemoteSessionIntent.LoadMore) + runCurrent() + val lateLoadMore = transport.lateCommandContinuations.remove("list_sessions")!! + store.dispatch(RemoteSessionIntent.Refresh) + runCurrent() + val refreshed = assertIs(store.state.value) + + lateLoadMore.resume(Unit) + runCurrent() + val afterLatePage = assertIs(store.state.value) + assertEquals(refreshed.revision, afterLatePage.revision) + assertEquals(refreshed.sessions, afterLatePage.sessions) + store.stop() + } + + @Test + fun staleLoadMoreCannotReconcileAwayLocallyCreatedSession() = runTest { + val transport = FakeSessionTransport().apply { + paged = true + pagedLimit = 40 + } + val store = RemoteSessionStore.create(this, transport) + store.dispatch(RemoteSessionIntent.Load) + advanceUntilIdle() + store.reconcileConfirmedCreatedSession(RemoteSession( + "local-created", "Local", "code", "active", "", "", 0, "/repo", null, + )) + + transport.listSessionsOverride = { + """{"resp":"ok","has_more":false,"sessions":[{"id":"local-created","title":"Confirmed","agent_type":"code"}]}""" + } + transport.nonCancellableCommands += "list_sessions" + store.dispatch(RemoteSessionIntent.LoadMore) + runCurrent() + val lateLoadMore = transport.lateCommandContinuations.remove("list_sessions")!! + + transport.listSessionsOverride = { + """{"resp":"ok","has_more":false,"sessions":[{"id":"server-only","title":"Server","agent_type":"code"}]}""" + } + store.dispatch(RemoteSessionIntent.Refresh) + runCurrent() + lateLoadMore.resume(Unit) + runCurrent() + store.dispatch(RemoteSessionIntent.Refresh) + runCurrent() + + assertTrue(assertIs(store.state.value).sessions.any { it.id == "local-created" }) + store.stop() + } + + @Test + fun staleDeleteCannotResetNewlyOpenedTimeline() = runTest { + val transport = FakeSessionTransport() + val store = RemoteSessionStore.create(this, transport) + store.dispatch(RemoteSessionIntent.Load) + advanceUntilIdle() + store.dispatch(RemoteSessionIntent.Open("s-code")) + runCurrent() + + transport.nonCancellableCommands += "delete_session" + store.dispatch(RemoteSessionIntent.DeleteSession("s-code")) + runCurrent() + val lateDelete = transport.lateCommandContinuations.remove("delete_session")!! + store.dispatch(RemoteSessionIntent.Open("s-cowork")) + runCurrent() + lateDelete.resume(Unit) + runCurrent() + + val ready = assertIs(store.state.value) + assertEquals("s-cowork", ready.selectedSessionId) + assertEquals("s-cowork", ready.timeline?.sessionId) + store.dispatch(RemoteSessionIntent.SendMessage("s-cowork", "still active")) + runCurrent() + assertTrue(transport.commands.any { it.cmd == "send_message" && it.sessionId == "s-cowork" }) + store.stop() + } + + @Test + fun createTransportFailureIsTypedAndRetryable() = runTest { + val transport = FakeSessionTransport() + val store = RemoteSessionStore.create(this, transport) + store.dispatch(RemoteSessionIntent.Load) + advanceUntilIdle() + transport.createFailure = RelayFailure.Timeout + + store.dispatch( + RemoteSessionIntent.CreateSessionOperation( + requestId = "request-timeout", + agentType = "code", + title = "", + instruction = "", + modelId = null, + ), + ) + advanceUntilIdle() + + val failed = assertIs(store.createOperation.value) + assertEquals("request-timeout", failed.requestId) + assertEquals(CreateSessionOperationFailure.TRANSPORT, failed.reason) + assertTrue(failed.retryable) + assertFalse(failed.unsupported) + } + + @Test + fun malformedCreateResponseIsUnsupported() = runTest { + val transport = FakeSessionTransport() + transport.createFailure = RelayFailure.MalformedResponse + val store = RemoteSessionStore.create(this, transport) + store.dispatch(RemoteSessionIntent.CreateSessionOperation("malformed-create", "code", "", "", null)) + runCurrent() + val failed = assertIs(store.createOperation.value) + assertEquals(CreateSessionOperationFailure.UNSUPPORTED, failed.reason) + assertTrue(failed.unsupported) + } + + @Test + fun rejectedCreateIsNotUnsupported() = runTest { + val transport = FakeSessionTransport() + transport.createFailure = RelayFailure.RemoteRejected("denied") + val store = RemoteSessionStore.create(this, transport) + store.dispatch(RemoteSessionIntent.CreateSessionOperation("rejected-create", "code", "", "", null)) + runCurrent() + val failed = assertIs(store.createOperation.value) + assertEquals(CreateSessionOperationFailure.TRANSPORT, failed.reason) + assertFalse(failed.unsupported) + } + + @Test + fun repeatedCreateRequestIdUsesLatestInternalGeneration() = runTest { + val transport = FakeSessionTransport() + val store = RemoteSessionStore.create(this, transport) + transport.createFailure = RelayFailure.RemoteRejected("first") + store.dispatch(RemoteSessionIntent.CreateSessionOperation("reused", "code", "", "", null)) + runCurrent() + assertIs(store.createOperation.value) + transport.createFailure = null + store.dispatch(RemoteSessionIntent.CreateSessionOperation("reused", "code", "", "", null)) + runCurrent() + val succeeded = assertIs(store.createOperation.value) + assertEquals("reused", succeeded.requestId) + assertEquals("s-new", succeeded.createdSessionId) + store.stop() + } + + @Test + fun stopWhileModelInitializationIsGatedKeepsCommittedCreateSucceeded() = runTest { + val transport = FakeSessionTransport() + transport.commandGates["set_session_model"] = CompletableDeferred() + val store = RemoteSessionStore.create(this, transport) + store.dispatch(RemoteSessionIntent.CreateSessionOperation("model-gate", "code", "", "", "model-primary", "/repo")) + runCurrent() + + val committed = assertIs(store.createOperation.value) + assertEquals("s-new", committed.createdSessionId) + val committedReady = assertIs(store.state.value) + assertEquals(committed.commitRevision, committedReady.revision) + assertTrue(committedReady.sessions.any { it.id == committed.createdSessionId }) + store.stop() + runCurrent() + assertIs(store.createOperation.value) + } + + @Test + fun stopWhileOpenInitializationIsGatedKeepsCommittedProjection() = runTest { + val transport = FakeSessionTransport() + transport.commandGates["get_session_messages"] = CompletableDeferred() + val store = RemoteSessionStore.create(this, transport) + store.dispatch(RemoteSessionIntent.CreateSessionOperation("open-gate", "code", "", "", null, "/repo")) + runCurrent() + + assertIs(store.createOperation.value) + assertEquals(listOf("s-new"), assertIs(store.state.value).sessions.map { it.id }) + store.stop() + runCurrent() + assertIs(store.createOperation.value) + } + + @Test + fun stopWhileInitialMessageIsGatedKeepsCommittedCreateSucceeded() = runTest { + val transport = FakeSessionTransport() + transport.commandGates["send_message"] = CompletableDeferred() + val store = RemoteSessionStore.create(this, transport) + store.dispatch(RemoteSessionIntent.CreateSessionOperation("send-gate", "code", "", "hello", null, "/repo")) + runCurrent() + + assertIs(store.createOperation.value) + store.stop() + runCurrent() + assertIs(store.createOperation.value) + } + + @Test + fun postCreateInitializationFailureDoesNotRollBackSucceededOutcome() = runTest { + val transport = FakeSessionTransport().apply { sendMessageFailure = RelayFailure.Timeout } + val store = RemoteSessionStore.create(this, transport) + store.dispatch(RemoteSessionIntent.CreateSessionOperation("send-fails", "code", "", "hello", null, "/repo")) + runCurrent() + + val succeeded = assertIs(store.createOperation.value) + assertEquals("s-new", succeeded.createdSessionId) + assertTrue(assertIs(store.state.value).sessions.any { it.id == "s-new" }) + store.stop() + } + + @Test + fun sessionStoreStopCancelsCreateOperation() = runTest { + val transport = FakeSessionTransport() + val store = RemoteSessionStore.create(this, transport) + store.dispatch(RemoteSessionIntent.CreateSessionOperation("stop-create", "code", "", "", null)) + store.stop() + assertIs(store.createOperation.value) + } + + @Test + fun ordinaryOpenAndWorkspaceSelectionDoNotChangeCreateOperation() = runTest { + val sessionTransport = FakeSessionTransport() + val workspaceTransport = AssistantWorkspaceTransport() + val session = RemoteSessionStore.create(this, sessionTransport, "device-a", null) + val workspace = RemoteWorkspaceStore.create(this, workspaceTransport, StandardTestDispatcher(testScheduler), "device-a") + session.dispatch(RemoteSessionIntent.CreateSessionOperation("stable-create", "code", "", "", null)) + runCurrent() + val succeeded = assertIs(session.createOperation.value) + + session.dispatch(RemoteSessionIntent.Open("s-code")) + workspace.dispatch(RemoteWorkspaceIntent.Load) + runCurrent() + workspace.dispatch(RemoteWorkspaceIntent.SelectAssistant("/assistant")) + runCurrent() + + assertEquals(succeeded, session.createOperation.value) + session.stop() + } + + @Test + fun assistantCreateLoadsIdleSelectsAssistantAndCreates() = runTest { + val sessionTransport = FakeSessionTransport() + val workspaceTransport = AssistantWorkspaceTransport() + val session = RemoteSessionStore.create(this, sessionTransport, "device-a", null) + val workspace = RemoteWorkspaceStore.create(this, workspaceTransport, StandardTestDispatcher(testScheduler), "device-a") + + session.createAssistantSession(workspace, "assistant-1", "/assistant", "", "", null) + runCurrent() + + assertIs(session.createOperation.value) + assertEquals(listOf("list_recent_workspaces", "list_assistants", "get_workspace_info", "set_assistant", "get_workspace_info"), workspaceTransport.commands.map { it.cmd }) + assertTrue(sessionTransport.commands.any { it.cmd == "create_session" && it.workspacePath == "/assistant" }) + session.stop() + } + + @Test + fun assistantCreateLoadFailureIsTypedWithoutSelecting() = runTest { + val sessionTransport = FakeSessionTransport() + val workspaceTransport = AssistantWorkspaceTransport(loadFailure = true) + val session = RemoteSessionStore.create(this, sessionTransport, "device-a", null) + val workspace = RemoteWorkspaceStore.create(this, workspaceTransport, StandardTestDispatcher(testScheduler), "device-a") + + session.createAssistantSession(workspace, "assistant-load-fail", "/assistant", "", "", null) + advanceUntilIdle() + + assertEquals(CreateSessionOperationFailure.WORKSPACE, assertIs(session.createOperation.value).reason) + assertTrue(workspaceTransport.commands.none { it.cmd == "set_assistant" }) + assertTrue(sessionTransport.commands.none { it.cmd == "create_session" }) + } + + @Test + fun unknownAssistantFailsImmediatelyWithoutSelection() = runTest { + val sessionTransport = FakeSessionTransport() + val workspaceTransport = AssistantWorkspaceTransport() + val session = RemoteSessionStore.create(this, sessionTransport, "device-a", null) + val workspace = RemoteWorkspaceStore.create(this, workspaceTransport, StandardTestDispatcher(testScheduler), "device-a") + workspace.dispatch(RemoteWorkspaceIntent.Load) + advanceUntilIdle() + workspaceTransport.commands.clear() + + session.createAssistantSession(workspace, "assistant-unknown", "/missing", "", "", null) + runCurrent() + + assertEquals(CreateSessionOperationFailure.WORKSPACE, assertIs(session.createOperation.value).reason) + assertTrue(workspaceTransport.commands.isEmpty()) + assertTrue(sessionTransport.commands.none { it.cmd == "create_session" }) + } + + @Test + fun assistantCreateWaitsWhileSameSelectionIsBusy() = runTest { + val gate = CompletableDeferred() + val sessionTransport = FakeSessionTransport() + val workspaceTransport = AssistantWorkspaceTransport(selectionGate = gate, initialSelectedPath = "/assistant") + val session = RemoteSessionStore.create(this, sessionTransport, "device-a", null) + val workspace = RemoteWorkspaceStore.create(this, workspaceTransport, StandardTestDispatcher(testScheduler), "device-a") + workspace.dispatch(RemoteWorkspaceIntent.Load) + advanceUntilIdle() + workspace.dispatch(RemoteWorkspaceIntent.SelectAssistant("/assistant")) + runCurrent() + + session.createAssistantSession(workspace, "assistant-busy", "/assistant", "", "", null) + runCurrent() + assertIs(session.createOperation.value) + assertTrue(assertIs(workspace.state.value).busy) + assertTrue(sessionTransport.commands.none { it.cmd == "create_session" }) + + gate.complete(Unit) + runCurrent() + assertIs(session.createOperation.value) + session.stop() + } + + @Test + fun assistantSelectionFailureNeverCreatesInOldWorkspace() = runTest { + val sessionTransport = FakeSessionTransport() + val workspaceTransport = AssistantWorkspaceTransport(selectionFailure = true) + val session = RemoteSessionStore.create(this, sessionTransport, "device-a", null) + val workspace = RemoteWorkspaceStore.create(this, workspaceTransport, StandardTestDispatcher(testScheduler), "device-a") + + session.createAssistantSession(workspace, "assistant-select-fail", "/assistant", "", "", null) + advanceUntilIdle() + + assertEquals(CreateSessionOperationFailure.WORKSPACE, assertIs(session.createOperation.value).reason) + assertTrue(sessionTransport.commands.none { it.cmd == "create_session" }) + } + + @Test + fun assistantDeviceMismatchSendsNoCommands() = runTest { + val sessionTransport = FakeSessionTransport() + val workspaceTransport = AssistantWorkspaceTransport() + val session = RemoteSessionStore.create(this, sessionTransport, "device-a", null) + val workspace = RemoteWorkspaceStore.create(this, workspaceTransport, StandardTestDispatcher(testScheduler), "device-b") + + session.createAssistantSession(workspace, "assistant-mismatch", "/assistant", "", "", null) + + assertEquals(CreateSessionOperationFailure.DEVICE_MISMATCH, assertIs(session.createOperation.value).reason) + assertTrue(workspaceTransport.commands.isEmpty()) + assertTrue(sessionTransport.commands.isEmpty()) + } + + @Test + fun workspaceStoppedBeforeAssistantCoroutineRunsCancelsWithoutCommands() = runTest { + val sessionTransport = FakeSessionTransport() + val workspaceTransport = AssistantWorkspaceTransport() + val session = RemoteSessionStore.create(this, sessionTransport, "device-a", null) + val workspace = RemoteWorkspaceStore.create(this, workspaceTransport, StandardTestDispatcher(testScheduler), "device-a") + + session.createAssistantSession(workspace, "assistant-stop", "/assistant", "", "", null) + workspace.stop() + runCurrent() + + assertIs(session.createOperation.value) + assertTrue(workspaceTransport.commands.isEmpty()) + assertTrue(sessionTransport.commands.none { it.cmd == "create_session" }) + } + @Test fun loadOlderMessagesPrependsThePreviousTranscriptPage() = runTest { val transport = FakeSessionTransport() @@ -675,6 +1118,38 @@ class RemoteSessionStoreTest { } } +private class AssistantWorkspaceTransport( + private val loadFailure: Boolean = false, + private val selectionFailure: Boolean = false, + val selectionGate: CompletableDeferred? = null, + initialSelectedPath: String = "/repo", +) : RemoteCommandTransport { + val commands = mutableListOf() + private var selectedPath: String = initialSelectedPath + + override suspend fun send( + deserializer: DeserializationStrategy, + command: RemoteCommand, + timeoutMs: Long, + ): T { + commands += command + if (loadFailure && command.cmd == "list_recent_workspaces") error("load failed") + if (selectionFailure && command.cmd == "set_assistant") error("selection failed") + val json = when (command.cmd) { + "list_recent_workspaces" -> """{"resp":"ok","workspaces":[{"path":"/repo","name":"Repo"}]}""" + "list_assistants" -> """{"resp":"ok","assistants":[{"path":"/assistant","name":"Assistant","assistant_id":"a1"}]}""" + "set_assistant" -> { + selectionGate?.await() + selectedPath = command.path.orEmpty() + """{"resp":"ok","success":true,"path":"$selectedPath"}""" + } + "get_workspace_info" -> """{"resp":"ok","has_workspace":true,"path":"$selectedPath","workspace_kind":"${if (selectedPath == "/assistant") "assistant" else "code"}"}""" + else -> error("Unexpected command ${command.cmd}") + } + return RelayJson.decodeFromString(deserializer, json) + } +} + private class FakeSessionTransport : RemoteCommandTransport { val commands = mutableListOf() var workspacePath: String = "/repo" @@ -686,6 +1161,8 @@ private class FakeSessionTransport : RemoteCommandTransport { /** When set, `list_sessions` serves one row per offset so paging is observable. */ var paged: Boolean = false + var pagedLimit: Int = 1 + var listSessionsOverride: ((RemoteCommand) -> String)? = null /** When set, `list_sessions` is refused the way a desktop refuses it. */ var rejection: String? = null @@ -702,6 +1179,16 @@ private class FakeSessionTransport : RemoteCommandTransport { /** When set, `send_message` fails below the desktop while the draft is kept. */ var sendMessageFailure: RelayFailure? = null + /** When set, `create_session` fails below the desktop. */ + var createFailure: RelayFailure? = null + + /** Optional command-stage gates used to exercise post-create cancellation races. */ + val commandGates = mutableMapOf>() + + /** Commands suspended by a primitive continuation, so cancellation cannot consume their late result. */ + val nonCancellableCommands = mutableSetOf() + val lateCommandContinuations = mutableMapOf>() + /** Poll payloads served in order; the last one repeats, as a quiet desktop does. */ var polls: List = listOf(IDLE_POLL) private var pollIndex = 0 @@ -718,6 +1205,15 @@ private class FakeSessionTransport : RemoteCommandTransport { timeoutMs: Long, ): T { commands += command + val preparedListSessions = if (command.cmd == "list_sessions") { + listSessionsOverride?.invoke(command) + } else { + null + } + commandGates[command.cmd]?.await() + if (nonCancellableCommands.remove(command.cmd)) { + suspendCoroutine { continuation -> lateCommandContinuations[command.cmd] = continuation } + } if (command.cmd == "list_sessions") { rejection?.let { throw RelayTransportException(RelayFailure.RemoteRejected(it)) } failure?.let { throw RelayTransportException(it) } @@ -728,6 +1224,9 @@ private class FakeSessionTransport : RemoteCommandTransport { if (command.cmd == "send_message") { sendMessageFailure?.let { throw RelayTransportException(it) } } + if (command.cmd == "create_session") { + createFailure?.let { throw RelayTransportException(it) } + } if (command.cmd == "get_permission_mode" || command.cmd == "set_permission_mode") { permissionFailure?.let { throw RelayTransportException(it) } } @@ -748,7 +1247,7 @@ private class FakeSessionTransport : RemoteCommandTransport { "default_models":{"primary":"model-primary"} } }""".trimIndent() - "list_sessions" -> if (paged) pagedSessions(command.offset ?: 0) else allSessions() + "list_sessions" -> preparedListSessions ?: if (paged) pagedSessions(command.offset ?: 0) else allSessions() "get_session_messages" -> if (command.beforeMessageId != null) { """{"resp":"ok","messages":${olderMessages ?: "[]"},"has_more":false}""" } else { @@ -757,6 +1256,7 @@ private class FakeSessionTransport : RemoteCommandTransport { "get_permission_mode" -> permissionModeJson ?: """{"resp":"ok","mode":"ask"}""" "poll_session" -> polls[minOf(pollIndex++, polls.lastIndex)] "create_session" -> """{"resp":"ok","session_id":"s-new"}""" + "set_session_model" -> """{"resp":"ok","model_id":"model-primary"}""" "send_message" -> """{"resp":"ok","turn_id":"t-1"}""" "delete_session", "update_session_title", "answer_question", "set_permission_mode", "confirm_tool" -> """{"resp":"ok"}""" @@ -775,7 +1275,7 @@ private class FakeSessionTransport : RemoteCommandTransport { """.trimIndent() private fun pagedSessions(offset: Int): String = - """{"resp":"ok","has_more":${offset < 1},"sessions":[{"id":"page-$offset","title":"Page $offset","agent_type":"code"}]}""" + """{"resp":"ok","has_more":${offset < pagedLimit},"sessions":[{"id":"page-$offset","title":"Page $offset","agent_type":"code"}]}""" private companion object { const val IDLE_POLL = """{"resp":"ok","version":1,"changed":false,"session_state":"idle"}""" diff --git a/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/bitfun/mobile/core/feature/workspace/RemoteWorkspaceStoreTest.kt b/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/bitfun/mobile/core/feature/workspace/RemoteWorkspaceStoreTest.kt index ce69700427..95bcc935c6 100644 --- a/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/bitfun/mobile/core/feature/workspace/RemoteWorkspaceStoreTest.kt +++ b/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/bitfun/mobile/core/feature/workspace/RemoteWorkspaceStoreTest.kt @@ -4,9 +4,13 @@ import com.bitfun.mobile.core.protocol.CommandStatus import com.bitfun.mobile.core.protocol.RelayJson import com.bitfun.mobile.core.protocol.RemoteCommand import com.bitfun.mobile.core.transport.RemoteCommandTransport +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.withContext import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest -import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.serialization.DeserializationStrategy import kotlin.test.Test @@ -50,13 +54,17 @@ class RemoteWorkspaceStoreTest { @Test fun loadsBoundedTextPreviewThroughCommandTransport() = runTest { val transport = FakeWorkspaceTransport() - val store = RemoteWorkspaceStore.create(this, transport, StandardTestDispatcher(testScheduler)) + val store = RemoteWorkspaceStore.create(this, transport, StandardTestDispatcher(testScheduler), "device-a") store.dispatch(RemoteWorkspaceIntent.Load) advanceUntilIdle() - store.dispatch(RemoteWorkspaceIntent.OpenFile("computer://src/main.rs#L2", "main.rs", "session-1")) + store.dispatch(RemoteWorkspaceIntent.OpenFile("computer://src/main.rs#L2", "main.rs", "session-1", "ios-preview-1")) advanceUntilIdle() val ready = assertIs(store.state.value) val preview = assertIs(ready.preview) + assertEquals("ios-preview-1", preview.identity.requestId) + assertEquals("device-a", preview.identity.deviceKey) + assertEquals("session-1", preview.identity.sessionId) + assertEquals("src/main.rs", preview.identity.path) assertEquals("fn main() {}", preview.content) assertFalse(preview.truncated) val read = transport.commands.first { it.cmd == "read_file_chunk" } @@ -98,11 +106,12 @@ class RemoteWorkspaceStoreTest { val store = RemoteWorkspaceStore.create(this, transport, StandardTestDispatcher(testScheduler)) store.dispatch(RemoteWorkspaceIntent.Load) advanceUntilIdle() - store.dispatch(RemoteWorkspaceIntent.OpenFile("computer://src/main.rs", "main.rs", "session-1")) + store.dispatch(RemoteWorkspaceIntent.OpenFile("computer://src/main.rs", "main.rs", "session-1", "failed-preview")) advanceUntilIdle() val ready = assertIs(store.state.value) val failed = assertIs(ready.preview) + assertEquals("failed-preview", failed.identity.requestId) assertEquals("", failed.mimeType) assertEquals(0, failed.sizeBytes) } @@ -152,11 +161,12 @@ class RemoteWorkspaceStoreTest { val store = RemoteWorkspaceStore.create(this, transport, StandardTestDispatcher(testScheduler)) store.dispatch(RemoteWorkspaceIntent.Load) advanceUntilIdle() - store.dispatch(RemoteWorkspaceIntent.OpenFile("computer://src/main.rs", "main.rs", "session-1")) + store.dispatch(RemoteWorkspaceIntent.OpenFile("computer://src/main.rs", "main.rs", "session-1", "unsupported-preview")) advanceUntilIdle() val ready = assertIs(store.state.value) val unsupported = assertIs(ready.preview) + assertEquals("unsupported-preview", unsupported.identity.requestId) assertEquals("text/plain", unsupported.mimeType) } @@ -197,6 +207,78 @@ class RemoteWorkspaceStoreTest { assertFalse(failed.retryable) } + @Test + fun samePathRapidReopenRejectsFirstLateResponse() = runTest { + val transport = DelayedPreviewTransport() + val store = RemoteWorkspaceStore.create(this, transport, StandardTestDispatcher(testScheduler), "device-a") + store.dispatch(RemoteWorkspaceIntent.Load) + advanceUntilIdle() + store.dispatch(RemoteWorkspaceIntent.OpenFile("computer://same.txt", "same.txt", "session-1", "reused-request")) + runCurrent() + store.dispatch(RemoteWorkspaceIntent.OpenFile("computer://same.txt", "same.txt", "session-1", "reused-request")) + runCurrent() + transport.release(1) + runCurrent() + transport.release(0) + runCurrent() + assertEquals("reused-request", assertIs(assertIs(store.state.value).preview).identity.requestId) + } + + @Test + fun differentPathLateResponseCannotReplaceCurrentPreview() = runTest { + val transport = DelayedPreviewTransport() + val store = RemoteWorkspaceStore.create(this, transport, StandardTestDispatcher(testScheduler), "device-a") + store.dispatch(RemoteWorkspaceIntent.Load) + advanceUntilIdle() + store.dispatch(RemoteWorkspaceIntent.OpenFile("computer://old.txt", "old.txt", "session-1", "old")) + runCurrent() + store.dispatch(RemoteWorkspaceIntent.OpenFile("computer://new.txt", "new.txt", "session-1", "new")) + runCurrent() + transport.release(1) + runCurrent() + transport.release(0) + runCurrent() + val preview = assertIs(assertIs(store.state.value).preview) + assertEquals("new", preview.identity.requestId) + assertEquals("new.txt", preview.identity.path) + } + + @Test + fun dismissAndStopRejectLatePreviewResponses() = runTest { + val transport = DelayedPreviewTransport() + val store = RemoteWorkspaceStore.create(this, transport, StandardTestDispatcher(testScheduler), "device-a") + store.dispatch(RemoteWorkspaceIntent.Load) + advanceUntilIdle() + store.dispatch(RemoteWorkspaceIntent.OpenFile("computer://late.txt", "late.txt", "session-1", "dismissed")) + runCurrent() + store.dispatch(RemoteWorkspaceIntent.DismissPreview) + transport.release(0) + runCurrent() + assertIs(assertIs(store.state.value).preview) + + store.dispatch(RemoteWorkspaceIntent.OpenFile("computer://stop.txt", "stop.txt", "session-1", "stopped")) + runCurrent() + store.stop() + transport.release(1) + runCurrent() + assertIs(assertIs(store.state.value).preview) + } + + @Test + fun workspaceLoadInvalidatesLatePreviewResponse() = runTest { + val transport = DelayedPreviewTransport() + val store = RemoteWorkspaceStore.create(this, transport, StandardTestDispatcher(testScheduler), "device-a") + store.dispatch(RemoteWorkspaceIntent.Load) + advanceUntilIdle() + store.dispatch(RemoteWorkspaceIntent.OpenFile("computer://late.txt", "late.txt", "session-1", "before-load")) + runCurrent() + store.dispatch(RemoteWorkspaceIntent.Load) + runCurrent() + transport.release(0) + advanceUntilIdle() + assertIs(assertIs(store.state.value).preview) + } + @Test fun downloadsAFileInChunksAndWaitsForThePlatformSaver() = runTest { val transport = FakeWorkspaceTransport(downloadChunks = true) @@ -233,6 +315,36 @@ class RemoteWorkspaceStoreTest { } } +private class DelayedPreviewTransport : RemoteCommandTransport { + private val gates = mutableListOf>() + private var readIndex: Int = 0 + + fun release(index: Int) { + gates[index].complete(Unit) + } + + override suspend fun send( + deserializer: DeserializationStrategy, + command: RemoteCommand, + timeoutMs: Long, + ): T { + val json = when (command.cmd) { + "list_recent_workspaces" -> """{"resp":"ok","workspaces":[{"path":"/repo","name":"Repo"}]}""" + "list_assistants" -> """{"resp":"ok","assistants":[]}""" + "get_workspace_info" -> """{"resp":"ok","has_workspace":true,"path":"/repo"}""" + "get_file_info" -> """{"resp":"ok","name":"${command.path}","size":4,"mime_type":"text/plain"}""" + "read_file_chunk" -> { + val index = readIndex++ + val gate = CompletableDeferred().also(gates::add) + withContext(NonCancellable) { gate.await() } + """{"resp":"ok","name":"${command.path}","chunk_base64":"dGV4dA==","offset":0,"chunk_size":4,"total_size":4,"mime_type":"text/plain"}""" + } + else -> error("Unexpected command ${command.cmd}") + } + return RelayJson.decodeFromString(deserializer, json) + } +} + private class FakeWorkspaceTransport( private val downloadChunks: Boolean = false, private val readFileUnsupported: Boolean = false, diff --git a/src/apps/mobile/shared/core-persistence/src/commonMain/kotlin/com/bitfun/mobile/core/persistence/ChatLocalStore.kt b/src/apps/mobile/shared/core-persistence/src/commonMain/kotlin/com/bitfun/mobile/core/persistence/ChatLocalStore.kt index 64baba42f0..ee4a730dd8 100644 --- a/src/apps/mobile/shared/core-persistence/src/commonMain/kotlin/com/bitfun/mobile/core/persistence/ChatLocalStore.kt +++ b/src/apps/mobile/shared/core-persistence/src/commonMain/kotlin/com/bitfun/mobile/core/persistence/ChatLocalStore.kt @@ -151,6 +151,8 @@ public data class PersistedRemoteSession public constructor( public val lastMessageId: String = "", public val workspacePath: String? = null, public val workspaceName: String? = null, + /** True until a later server list observes this confirmed-created session id. */ + public val pendingConfirmed: Boolean = false, ) @Serializable @@ -196,7 +198,7 @@ public class SqlDelightRemoteSessionListStore public constructor( queries.selectRemoteSessions(deviceKey).executeAsList().map { row -> PersistedRemoteSession(row.session_id, row.title, row.agent_type, row.status, row.updated_at, row.created_at, row.message_count.toInt(), row.last_message_id, - row.workspace_path, row.workspace_name) + row.workspace_path, row.workspace_name, row.pending_confirmed == 1L) } override fun hasMore(deviceKey: String): Boolean = @@ -205,14 +207,15 @@ public class SqlDelightRemoteSessionListStore public constructor( override fun save(deviceKey: String, sessions: List, hasMore: Boolean) { if (deviceKey.isBlank()) return val kept = sessions.take(20) - val signature = "$deviceKey|${hasMore}|${kept.joinToString { it.sessionId + ":" + it.updatedAt + ":" + it.messageCount }}" + val signature = "$deviceKey|${hasMore}|${kept.joinToString { it.sessionId + ":" + it.updatedAt + ":" + it.messageCount + ":" + it.pendingConfirmed }}" if (signature == lastSignature) return queries.transaction { queries.deleteRemoteSessionsForDevice(deviceKey) kept.forEach { session -> queries.upsertRemoteSession( deviceKey, session.sessionId, session.title, session.agentType, session.status, session.updatedAt, session.createdAt, session.messageCount.toLong(), session.lastMessageId, - session.workspacePath, session.workspaceName, if (hasMore) 1L else 0L) + session.workspacePath, session.workspaceName, if (hasMore) 1L else 0L, + if (session.pendingConfirmed) 1L else 0L) } } lastSignature = signature diff --git a/src/apps/mobile/shared/core-persistence/src/commonMain/sqldelight/com/bitfun/mobile/core/persistence/db/3.sqm b/src/apps/mobile/shared/core-persistence/src/commonMain/sqldelight/com/bitfun/mobile/core/persistence/db/3.sqm new file mode 100644 index 0000000000..ade490d03c --- /dev/null +++ b/src/apps/mobile/shared/core-persistence/src/commonMain/sqldelight/com/bitfun/mobile/core/persistence/db/3.sqm @@ -0,0 +1,3 @@ +-- v3 -> v4: retain a confirmed create until list_sessions observes its id. +-- Existing rows came from server lists and therefore are not pending. +ALTER TABLE remote_session_list ADD COLUMN pending_confirmed INTEGER NOT NULL DEFAULT 0; diff --git a/src/apps/mobile/shared/core-persistence/src/commonMain/sqldelight/com/bitfun/mobile/core/persistence/db/Mobile.sq b/src/apps/mobile/shared/core-persistence/src/commonMain/sqldelight/com/bitfun/mobile/core/persistence/db/Mobile.sq index 72379d00c4..fe95379337 100644 --- a/src/apps/mobile/shared/core-persistence/src/commonMain/sqldelight/com/bitfun/mobile/core/persistence/db/Mobile.sq +++ b/src/apps/mobile/shared/core-persistence/src/commonMain/sqldelight/com/bitfun/mobile/core/persistence/db/Mobile.sq @@ -92,6 +92,7 @@ CREATE TABLE remote_session_list ( workspace_path TEXT, workspace_name TEXT, has_more INTEGER NOT NULL DEFAULT 0, + pending_confirmed INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (device_key, session_id) ); @@ -128,8 +129,8 @@ selectRemoteSessions: SELECT * FROM remote_session_list WHERE device_key = ? ORDER BY updated_at DESC, session_id ASC; upsertRemoteSession: -INSERT OR REPLACE INTO remote_session_list(device_key, session_id, title, agent_type, status, updated_at, created_at, message_count, last_message_id, workspace_path, workspace_name, has_more) -VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?); +INSERT OR REPLACE INTO remote_session_list(device_key, session_id, title, agent_type, status, updated_at, created_at, message_count, last_message_id, workspace_path, workspace_name, has_more, pending_confirmed) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?); deleteRemoteSessionsForDevice: DELETE FROM remote_session_list WHERE device_key = ?; diff --git a/src/apps/mobile/shared/core-persistence/src/iosMain/kotlin/com/bitfun/mobile/core/persistence/IosSecureStore.kt b/src/apps/mobile/shared/core-persistence/src/iosMain/kotlin/com/bitfun/mobile/core/persistence/IosSecureStore.kt index ea012522ba..2d558342fe 100644 --- a/src/apps/mobile/shared/core-persistence/src/iosMain/kotlin/com/bitfun/mobile/core/persistence/IosSecureStore.kt +++ b/src/apps/mobile/shared/core-persistence/src/iosMain/kotlin/com/bitfun/mobile/core/persistence/IosSecureStore.kt @@ -38,6 +38,14 @@ import platform.Security.kSecReturnData import platform.Security.kSecValueData import platform.Security.errSecItemNotFound +internal enum class KeychainReadResult { FOUND, MISSING } + +internal fun classifyKeychainReadStatus(status: Int): KeychainReadResult = when (status) { + 0 -> KeychainReadResult.FOUND + errSecItemNotFound -> KeychainReadResult.MISSING + else -> error("Keychain value could not be read (status=$status).") +} + /** Keychain-backed secret storage for iOS account credentials and keys. */ @OptIn(ExperimentalForeignApi::class) private class IosSecureStore(private val service: String) : SecureStore { @@ -47,8 +55,12 @@ private class IosSecureStore(private val service: String) : SecureStore { set(query, kSecReturnData, kCFBooleanTrue) set(query, kSecMatchLimit, kSecMatchLimitOne) val result = alloc() - if (SecItemCopyMatching(query, result.ptr) != 0) return@memScoped null - val data = result.value as? CFDataRef ?: return@memScoped null + when (classifyKeychainReadStatus(SecItemCopyMatching(query, result.ptr))) { + KeychainReadResult.MISSING -> return@memScoped null + KeychainReadResult.FOUND -> Unit + } + val data = result.value as? CFDataRef + ?: error("Keychain returned success without data.") try { val length = CFDataGetLength(data).toInt() if (length == 0) return@memScoped ByteArray(0) diff --git a/src/apps/mobile/shared/core-persistence/src/iosTest/kotlin/com/bitfun/mobile/core/persistence/IosSecureStoreTest.kt b/src/apps/mobile/shared/core-persistence/src/iosTest/kotlin/com/bitfun/mobile/core/persistence/IosSecureStoreTest.kt index a45066be2b..eba7dfe4e6 100644 --- a/src/apps/mobile/shared/core-persistence/src/iosTest/kotlin/com/bitfun/mobile/core/persistence/IosSecureStoreTest.kt +++ b/src/apps/mobile/shared/core-persistence/src/iosTest/kotlin/com/bitfun/mobile/core/persistence/IosSecureStoreTest.kt @@ -2,10 +2,20 @@ package com.bitfun.mobile.core.persistence import kotlin.test.Test import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertNull +import platform.Security.errSecItemNotFound /** Runs against the real Keychain on an iOS simulator/device, not a memory fake. */ class IosSecureStoreTest { + @Test + fun readStatusDistinguishesMissingFromFailure() { + assertEquals(KeychainReadResult.FOUND, classifyKeychainReadStatus(0)) + assertEquals(KeychainReadResult.MISSING, classifyKeychainReadStatus(errSecItemNotFound)) + assertFailsWith { classifyKeychainReadStatus(-50) } + } + @Test fun roundTripsUpdatesAndDeletesSecret() { val store = iosSecureStore("com.bitfun.mobile.tests") diff --git a/src/apps/mobile/shared/core-persistence/src/jvmTest/kotlin/com/bitfun/mobile/core/persistence/RemotePersistenceStoreTest.kt b/src/apps/mobile/shared/core-persistence/src/jvmTest/kotlin/com/bitfun/mobile/core/persistence/RemotePersistenceStoreTest.kt index 17c05ab53d..8a13db79aa 100644 --- a/src/apps/mobile/shared/core-persistence/src/jvmTest/kotlin/com/bitfun/mobile/core/persistence/RemotePersistenceStoreTest.kt +++ b/src/apps/mobile/shared/core-persistence/src/jvmTest/kotlin/com/bitfun/mobile/core/persistence/RemotePersistenceStoreTest.kt @@ -1,11 +1,14 @@ package com.bitfun.mobile.core.persistence +import app.cash.sqldelight.db.QueryResult import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver import com.bitfun.mobile.core.persistence.db.MobileDatabase import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue import kotlinx.coroutines.test.runTest +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json class RemotePersistenceStoreTest { private suspend fun stores(): Pair { @@ -24,6 +27,99 @@ class RemotePersistenceStoreTest { assertEquals(listOf("zero", "one"), transcript.load("device-a", "s1").map { it.text }) } + @Test + fun pendingConfirmedMarkerRoundTripsAndLegacySerializationRemainsCompatible() = runTest { + val (sessions, _) = stores() + sessions.save( + "device-a", + listOf(session("pending", "2026-01-01").copy(pendingConfirmed = true)), + ) + assertTrue(sessions.load("device-a").single().pendingConfirmed) + + val legacyPayload = """{"sessionId":"legacy","title":"Legacy"}""" + val decoded = Json.decodeFromString(legacyPayload) + assertEquals(false, decoded.pendingConfirmed) + val currentPayload = Json.encodeToString(decoded) + val redecoded = Json.decodeFromString(currentPayload) + assertEquals(decoded, redecoded) + assertEquals(false, redecoded.pendingConfirmed) + } + + @Test + fun migratesV3RemoteSessionRowToV4AndCurrentStoreCanLoadAndSaveIt() = runTest { + val driver = JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY) + driver.execute( + identifier = null, + sql = """ + CREATE TABLE remote_session_list ( + device_key TEXT NOT NULL, + session_id TEXT NOT NULL, + title TEXT NOT NULL, + agent_type TEXT NOT NULL, + status TEXT NOT NULL, + updated_at TEXT NOT NULL, + created_at TEXT NOT NULL, + message_count INTEGER NOT NULL, + last_message_id TEXT NOT NULL, + workspace_path TEXT, + workspace_name TEXT, + has_more INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (device_key, session_id) + ) + """.trimIndent(), + parameters = 0, + ).await() + driver.execute( + identifier = null, + sql = """ + INSERT INTO remote_session_list( + device_key, session_id, title, agent_type, status, updated_at, created_at, + message_count, last_message_id, workspace_path, workspace_name, has_more + ) VALUES ( + 'device-v3', 'session-v3', 'Legacy title', 'remote', 'ready', + '2026-02-03', '2026-01-02', 7, 'message-7', '/legacy/workspace', 'Legacy workspace', 1 + ) + """.trimIndent(), + parameters = 0, + ).await() + + MobileDatabase.Schema.migrate(driver, 3, 4).await() + val migratedPendingValue = driver.executeQuery( + identifier = null, + sql = "SELECT pending_confirmed FROM remote_session_list WHERE session_id = 'session-v3'", + mapper = { cursor -> + check(cursor.next().value) + QueryResult.Value(cursor.getLong(0)) + }, + parameters = 0, + ).await() + assertEquals(0L, migratedPendingValue) + + val sessions = SqlDelightRemoteSessionListStore(driver) + val migrated = sessions.load("device-v3").single() + assertEquals("session-v3", migrated.sessionId) + assertEquals("Legacy title", migrated.title) + assertEquals("remote", migrated.agentType) + assertEquals("ready", migrated.status) + assertEquals("2026-02-03", migrated.updatedAt) + assertEquals("2026-01-02", migrated.createdAt) + assertEquals(7, migrated.messageCount) + assertEquals("message-7", migrated.lastMessageId) + assertEquals("/legacy/workspace", migrated.workspacePath) + assertEquals("Legacy workspace", migrated.workspaceName) + assertEquals(false, migrated.pendingConfirmed) + assertTrue(sessions.hasMore("device-v3")) + + sessions.save( + "device-v3", + listOf(migrated.copy(title = "Current title", pendingConfirmed = true)), + ) + assertEquals( + migrated.copy(title = "Current title", pendingConfirmed = true), + sessions.load("device-v3").single(), + ) + } + @Test fun emptyServerListClearsCachedSessionsOnColdStart() = runTest { val (sessions, _) = stores() From 63f19e96a3e2932ce31f8daca068c4dc8a49609c Mon Sep 17 00:00:00 2001 From: wgqqqqq Date: Fri, 28 Aug 2026 11:04:35 +0800 Subject: [PATCH 4/5] fix(ios): align compact drawer motion Match the HarmonyOS layered drawer transition and remove the unused local-home prompt shortcuts. Co-authored-by: BitFun <318544290+bitfun-ai@users.noreply.github.com> --- .../Features/Shell/MobileShellView.swift | 86 ++++++++----------- .../BitFun/Features/Shell/SidebarView.swift | 2 +- 2 files changed, 38 insertions(+), 50 deletions(-) diff --git a/src/apps/mobile/ios/BitFun/Features/Shell/MobileShellView.swift b/src/apps/mobile/ios/BitFun/Features/Shell/MobileShellView.swift index a5b64749ec..2393ba8015 100644 --- a/src/apps/mobile/ios/BitFun/Features/Shell/MobileShellView.swift +++ b/src/apps/mobile/ios/BitFun/Features/Shell/MobileShellView.swift @@ -79,7 +79,6 @@ struct MobileShellView: View { } } } - .animation(.easeOut(duration: 0.24), value: model.drawerOpen) .animation(.easeInOut(duration: 0.22), value: wideSidebarCollapsed) .overlay(alignment: .bottom) { if let message = model.toastMessage { @@ -178,8 +177,20 @@ struct MobileShellView: View { let sidebarWidth = triplePane ? CGFloat(previewLayout.masterPaneWidth) : CGFloat(geometry.masterPaneWidth) + let compactSidebarWidth = min(280, max(220, viewportWidth * 0.68)) ZStack(alignment: .leading) { + if !sidebarVisible { + SidebarView(model: model) + .frame(width: compactSidebarWidth) + .opacity(model.drawerOpen ? 1 : 0) + .offset(x: model.drawerOpen ? 0 : -compactSidebarWidth * 0.1) + .animation( + .easeOut(duration: model.drawerOpen ? 0.30 : 0.22), + value: model.drawerOpen + ) + } + HStack(spacing: 0) { if sidebarVisible { SidebarView( @@ -212,15 +223,30 @@ struct MobileShellView: View { } } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) - - if !sidebarVisible && model.drawerOpen { - Color.black.opacity(0.24) - .ignoresSafeArea() - .onTapGesture { model.drawerOpen = false } - SidebarView(model: model) - .transition(.move(edge: .leading).combined(with: .opacity)) - .shadow(color: .black.opacity(0.18), radius: 26, x: 10, y: 0) + .overlay { + if !sidebarVisible && model.drawerOpen { + BitFunTheme.page.opacity(0.62) + .transition(.opacity.animation(.easeOut(duration: 0.21))) + .onTapGesture { model.drawerOpen = false } + } } + .clipShape(RoundedRectangle(cornerRadius: !sidebarVisible && model.drawerOpen ? 28 : 0)) + .shadow( + color: !sidebarVisible && model.drawerOpen ? .black.opacity(0.14) : .clear, + radius: !sidebarVisible && model.drawerOpen ? 34 : 0, + x: !sidebarVisible && model.drawerOpen ? -10 : 0 + ) + .blur(radius: !sidebarVisible && model.drawerOpen ? 1.1 : 0) + .scaleEffect( + x: !sidebarVisible && model.drawerOpen ? 0.985 : 1, + y: !sidebarVisible && model.drawerOpen ? 0.992 : 1, + anchor: .leading + ) + .offset(x: !sidebarVisible && model.drawerOpen ? compactSidebarWidth : 0) + .animation( + .easeOut(duration: model.drawerOpen ? 0.32 : 0.25), + value: model.drawerOpen + ) } .sheet(item: previewForSheet, onDismiss: model.dismissFilePreview) { preview in RemoteFilePreviewSheet(model: model, preview: preview) @@ -325,7 +351,7 @@ struct MobileShellView: View { RemoteConnectedHomeView(model: model) ComposerBar(model: model) } else if model.surface == .local && !model.localSessionSelected { - LocalHomeView(model: model) + LocalHomeView() ComposerBar(model: model) } else { ChatTimelineView(model: model) @@ -358,46 +384,8 @@ struct MobileShellView: View { private struct LocalHomeView: View { - @ObservedObject var model: MobileAppModel - - private let prompts: [(String, String)] = [ - ("Aa", "帮我写点内容"), - ("≡", "梳理一个问题"), - ("✓", "制定行动计划") - ] - var body: some View { - VStack(spacing: 0) { - Spacer(minLength: 0) - VStack(spacing: 12) { - ForEach(prompts, id: \.1) { icon, title in - let promptText = model.localized(title) - Button { - model.draft = promptText - model.send() - } label: { - HStack(spacing: 20) { - Text(icon) - .font(.system(size: 29, weight: .regular)) - .foregroundStyle(BitFunTheme.muted) - .frame(width: 32) - .fixedSize() - Text(promptText) - .font(.system(size: 20, weight: .medium)) - .foregroundStyle(BitFunTheme.muted) - Spacer(minLength: 0) - } - .frame(height: 48) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - } - } - .padding(.horizontal, 20) - .padding(.bottom, 12) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(BitFunTheme.page) + BitFunTheme.page } } diff --git a/src/apps/mobile/ios/BitFun/Features/Shell/SidebarView.swift b/src/apps/mobile/ios/BitFun/Features/Shell/SidebarView.swift index 3a512c339e..8dbeef7b1d 100644 --- a/src/apps/mobile/ios/BitFun/Features/Shell/SidebarView.swift +++ b/src/apps/mobile/ios/BitFun/Features/Shell/SidebarView.swift @@ -87,7 +87,7 @@ struct SidebarView: View { .padding(.top, 4) .padding(.bottom, 16) .frame( - width: permanent ? proxy.size.width : min(320, proxy.size.width * 0.68), + width: proxy.size.width, height: proxy.size.height, alignment: .topLeading ) From f6bb123d7a19c1f6574161c717de82c11f2ed982 Mon Sep 17 00:00:00 2001 From: wgqqqqq Date: Fri, 28 Aug 2026 11:57:06 +0800 Subject: [PATCH 5/5] refactor(ios): organize mobile app structure Co-authored-by: BitFun <318544290+bitfun-ai@users.noreply.github.com> --- .../ios/BitFun.xcodeproj/project.pbxproj | 41 +- .../mobile/ios/BitFun/App/BitFunApp.swift | 17 +- .../App/MobileLaunchConfiguration.swift | 330 +++++++++ .../AccountSettingsView.swift} | 333 --------- .../Features/Chat/ConversationHomeViews.swift | 7 + .../Features/Pairing/PairingSheet.swift | 257 +++++++ .../Features/Remote/RemoteHomeViews.swift | 130 ++++ .../Features/Settings/AppSettingsView.swift | 238 ++++++ .../Features/Shell/MobileShellView.swift | 404 ----------- .../Infrastructure/MobileAppModel.swift | 680 +----------------- .../Infrastructure/PairingFailureCopy.swift | 31 + .../Platform/QRCodeScannerView.swift | 80 +++ .../Models/MobilePresentationModels.swift | 286 ++++++++ src/apps/mobile/ios/README.md | 13 +- 14 files changed, 1403 insertions(+), 1444 deletions(-) create mode 100644 src/apps/mobile/ios/BitFun/App/MobileLaunchConfiguration.swift rename src/apps/mobile/ios/BitFun/Features/{Shell/AccountPairingViews.swift => Account/AccountSettingsView.swift} (52%) create mode 100644 src/apps/mobile/ios/BitFun/Features/Chat/ConversationHomeViews.swift create mode 100644 src/apps/mobile/ios/BitFun/Features/Pairing/PairingSheet.swift create mode 100644 src/apps/mobile/ios/BitFun/Features/Remote/RemoteHomeViews.swift create mode 100644 src/apps/mobile/ios/BitFun/Features/Settings/AppSettingsView.swift create mode 100644 src/apps/mobile/ios/BitFun/Infrastructure/PairingFailureCopy.swift create mode 100644 src/apps/mobile/ios/BitFun/Infrastructure/Platform/QRCodeScannerView.swift create mode 100644 src/apps/mobile/ios/BitFun/Presentation/Models/MobilePresentationModels.swift diff --git a/src/apps/mobile/ios/BitFun.xcodeproj/project.pbxproj b/src/apps/mobile/ios/BitFun.xcodeproj/project.pbxproj index 18bde05ac1..9851b750df 100644 --- a/src/apps/mobile/ios/BitFun.xcodeproj/project.pbxproj +++ b/src/apps/mobile/ios/BitFun.xcodeproj/project.pbxproj @@ -29,13 +29,21 @@ A10000000000000000000020 /* RemoteCreateSessionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000020 /* RemoteCreateSessionView.swift */; }; A10000000000000000000021 /* RemoteFilePreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000021 /* RemoteFilePreviewView.swift */; }; A10000000000000000000022 /* RemoteSettingsViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000022 /* RemoteSettingsViews.swift */; }; - A10000000000000000000023 /* AccountPairingViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000023 /* AccountPairingViews.swift */; }; A10000000000000000000024 /* MobileAppModel+FilePreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000024 /* MobileAppModel+FilePreview.swift */; }; A10000000000000000000025 /* MobileAppModel+Account.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000025 /* MobileAppModel+Account.swift */; }; A10000000000000000000026 /* MobileAppModel+RemoteSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000026 /* MobileAppModel+RemoteSession.swift */; }; A10000000000000000000027 /* MobileAppModel+GeneralChat.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000027 /* MobileAppModel+GeneralChat.swift */; }; A10000000000000000000028 /* RemoteAuthorityGate.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000028 /* RemoteAuthorityGate.swift */; }; A10000000000000000000029 /* AccountFailureCopy.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000029 /* AccountFailureCopy.swift */; }; + A10000000000000000000030 /* ConversationHomeViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000030 /* ConversationHomeViews.swift */; }; + A10000000000000000000031 /* RemoteHomeViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000031 /* RemoteHomeViews.swift */; }; + A10000000000000000000032 /* AppSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000032 /* AppSettingsView.swift */; }; + A10000000000000000000033 /* PairingSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000033 /* PairingSheet.swift */; }; + A10000000000000000000034 /* QRCodeScannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000034 /* QRCodeScannerView.swift */; }; + A10000000000000000000035 /* AccountSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000035 /* AccountSettingsView.swift */; }; + A10000000000000000000036 /* MobilePresentationModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000036 /* MobilePresentationModels.swift */; }; + A10000000000000000000037 /* MobileLaunchConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000037 /* MobileLaunchConfiguration.swift */; }; + A10000000000000000000038 /* PairingFailureCopy.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000038 /* PairingFailureCopy.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -62,13 +70,21 @@ B10000000000000000000020 /* RemoteCreateSessionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteCreateSessionView.swift; sourceTree = ""; }; B10000000000000000000021 /* RemoteFilePreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteFilePreviewView.swift; sourceTree = ""; }; B10000000000000000000022 /* RemoteSettingsViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteSettingsViews.swift; sourceTree = ""; }; - B10000000000000000000023 /* AccountPairingViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountPairingViews.swift; sourceTree = ""; }; B10000000000000000000024 /* MobileAppModel+FilePreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MobileAppModel+FilePreview.swift"; sourceTree = ""; }; B10000000000000000000025 /* MobileAppModel+Account.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MobileAppModel+Account.swift"; sourceTree = ""; }; B10000000000000000000026 /* MobileAppModel+RemoteSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MobileAppModel+RemoteSession.swift"; sourceTree = ""; }; B10000000000000000000027 /* MobileAppModel+GeneralChat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MobileAppModel+GeneralChat.swift"; sourceTree = ""; }; B10000000000000000000028 /* RemoteAuthorityGate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteAuthorityGate.swift; sourceTree = ""; }; B10000000000000000000029 /* AccountFailureCopy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountFailureCopy.swift; sourceTree = ""; }; + B10000000000000000000030 /* ConversationHomeViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationHomeViews.swift; sourceTree = ""; }; + B10000000000000000000031 /* RemoteHomeViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteHomeViews.swift; sourceTree = ""; }; + B10000000000000000000032 /* AppSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettingsView.swift; sourceTree = ""; }; + B10000000000000000000033 /* PairingSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PairingSheet.swift; sourceTree = ""; }; + B10000000000000000000034 /* QRCodeScannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRCodeScannerView.swift; sourceTree = ""; }; + B10000000000000000000035 /* AccountSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountSettingsView.swift; sourceTree = ""; }; + B10000000000000000000036 /* MobilePresentationModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MobilePresentationModels.swift; sourceTree = ""; }; + B10000000000000000000037 /* MobileLaunchConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MobileLaunchConfiguration.swift; sourceTree = ""; }; + B10000000000000000000038 /* PairingFailureCopy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PairingFailureCopy.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -77,13 +93,20 @@ /* Begin PBXGroup section */ D10000000000000000000000 = {isa = PBXGroup; children = (D10000000000000000000001 /* BitFun */, D10000000000000000000009 /* Products */); sourceTree = ""; }; - D10000000000000000000001 /* BitFun */ = {isa = PBXGroup; children = (D10000000000000000000002 /* App */, D10000000000000000000003 /* Features */, D10000000000000000000008 /* Infrastructure */, D10000000000000000000011 /* Resources */, B10000000000000000000009 /* Resources.xcassets */, B10000000000000000000011 /* BitFunMobileCore.xcframework */, B10000000000000000000012 /* libsqlite3.tbd */); path = BitFun; sourceTree = ""; }; - D10000000000000000000002 /* App */ = {isa = PBXGroup; children = (B10000000000000000000001 /* BitFunApp.swift */); path = App; sourceTree = ""; }; - D10000000000000000000003 /* Features */ = {isa = PBXGroup; children = (D10000000000000000000004 /* Chat */, D10000000000000000000005 /* Shell */, D10000000000000000000010 /* DesignSystem */); path = Features; sourceTree = ""; }; - D10000000000000000000004 /* Chat */ = {isa = PBXGroup; children = (B10000000000000000000005 /* ConversationHeader.swift */, B10000000000000000000006 /* ChatTimelineView.swift */, B10000000000000000000007 /* ComposerBar.swift */); path = Chat; sourceTree = ""; }; - D10000000000000000000005 /* Shell */ = {isa = PBXGroup; children = (B10000000000000000000003 /* BitFunTheme.swift */, B10000000000000000000004 /* SidebarView.swift */, B10000000000000000000008 /* MobileShellView.swift */, B10000000000000000000019 /* SessionActionComponents.swift */, B10000000000000000000020 /* RemoteCreateSessionView.swift */, B10000000000000000000021 /* RemoteFilePreviewView.swift */, B10000000000000000000022 /* RemoteSettingsViews.swift */, B10000000000000000000023 /* AccountPairingViews.swift */); path = Shell; sourceTree = ""; }; - D10000000000000000000008 /* Infrastructure */ = {isa = PBXGroup; children = (B10000000000000000000002 /* MobileAppModel.swift */, B10000000000000000000010 /* MobileCoreAdapter.swift */, B10000000000000000000017 /* MobileLocalization.swift */, B10000000000000000000024 /* MobileAppModel+FilePreview.swift */, B10000000000000000000025 /* MobileAppModel+Account.swift */, B10000000000000000000026 /* MobileAppModel+RemoteSession.swift */, B10000000000000000000027 /* MobileAppModel+GeneralChat.swift */, B10000000000000000000028 /* RemoteAuthorityGate.swift */, B10000000000000000000029 /* AccountFailureCopy.swift */); path = Infrastructure; sourceTree = ""; }; + D10000000000000000000001 /* BitFun */ = {isa = PBXGroup; children = (D10000000000000000000002 /* App */, D10000000000000000000016 /* Presentation */, D10000000000000000000003 /* Features */, D10000000000000000000008 /* Infrastructure */, D10000000000000000000011 /* Resources */, B10000000000000000000009 /* Resources.xcassets */, B10000000000000000000011 /* BitFunMobileCore.xcframework */, B10000000000000000000012 /* libsqlite3.tbd */); path = BitFun; sourceTree = ""; }; + D10000000000000000000002 /* App */ = {isa = PBXGroup; children = (B10000000000000000000001 /* BitFunApp.swift */, B10000000000000000000037 /* MobileLaunchConfiguration.swift */); path = App; sourceTree = ""; }; + D10000000000000000000003 /* Features */ = {isa = PBXGroup; children = (D10000000000000000000004 /* Chat */, D10000000000000000000012 /* Remote */, D10000000000000000000013 /* Settings */, D10000000000000000000014 /* Pairing */, D10000000000000000000015 /* Account */, D10000000000000000000005 /* Shell */, D10000000000000000000010 /* DesignSystem */); path = Features; sourceTree = ""; }; + D10000000000000000000012 /* Remote */ = {isa = PBXGroup; children = (B10000000000000000000031 /* RemoteHomeViews.swift */); path = Remote; sourceTree = ""; }; + D10000000000000000000013 /* Settings */ = {isa = PBXGroup; children = (B10000000000000000000032 /* AppSettingsView.swift */); path = Settings; sourceTree = ""; }; + D10000000000000000000014 /* Pairing */ = {isa = PBXGroup; children = (B10000000000000000000033 /* PairingSheet.swift */); path = Pairing; sourceTree = ""; }; + D10000000000000000000015 /* Account */ = {isa = PBXGroup; children = (B10000000000000000000035 /* AccountSettingsView.swift */); path = Account; sourceTree = ""; }; + D10000000000000000000004 /* Chat */ = {isa = PBXGroup; children = (B10000000000000000000030 /* ConversationHomeViews.swift */, B10000000000000000000005 /* ConversationHeader.swift */, B10000000000000000000006 /* ChatTimelineView.swift */, B10000000000000000000007 /* ComposerBar.swift */); path = Chat; sourceTree = ""; }; + D10000000000000000000005 /* Shell */ = {isa = PBXGroup; children = (B10000000000000000000003 /* BitFunTheme.swift */, B10000000000000000000004 /* SidebarView.swift */, B10000000000000000000008 /* MobileShellView.swift */, B10000000000000000000019 /* SessionActionComponents.swift */, B10000000000000000000020 /* RemoteCreateSessionView.swift */, B10000000000000000000021 /* RemoteFilePreviewView.swift */, B10000000000000000000022 /* RemoteSettingsViews.swift */); path = Shell; sourceTree = ""; }; + D10000000000000000000008 /* Infrastructure */ = {isa = PBXGroup; children = (B10000000000000000000002 /* MobileAppModel.swift */, B10000000000000000000010 /* MobileCoreAdapter.swift */, B10000000000000000000017 /* MobileLocalization.swift */, B10000000000000000000024 /* MobileAppModel+FilePreview.swift */, B10000000000000000000025 /* MobileAppModel+Account.swift */, B10000000000000000000026 /* MobileAppModel+RemoteSession.swift */, B10000000000000000000027 /* MobileAppModel+GeneralChat.swift */, B10000000000000000000028 /* RemoteAuthorityGate.swift */, B10000000000000000000029 /* AccountFailureCopy.swift */, B10000000000000000000038 /* PairingFailureCopy.swift */, D10000000000000000000017 /* Platform */); path = Infrastructure; sourceTree = ""; }; D10000000000000000000011 /* Resources */ = {isa = PBXGroup; children = (B10000000000000000000016 /* Localizable.xcstrings */); path = Resources; sourceTree = ""; }; + D10000000000000000000016 /* Presentation */ = {isa = PBXGroup; children = (D10000000000000000000018 /* Models */); path = Presentation; sourceTree = ""; }; + D10000000000000000000018 /* Models */ = {isa = PBXGroup; children = (B10000000000000000000036 /* MobilePresentationModels.swift */); path = Models; sourceTree = ""; }; + D10000000000000000000017 /* Platform */ = {isa = PBXGroup; children = (B10000000000000000000034 /* QRCodeScannerView.swift */); path = Platform; sourceTree = ""; }; D10000000000000000000009 /* Products */ = {isa = PBXGroup; children = (B10000000000000000000000 /* BitFun.app */); name = Products; sourceTree = ""; }; D10000000000000000000010 /* DesignSystem */ = {isa = PBXGroup; children = (B10000000000000000000013 /* GeneratedMobileDesignTokens.swift */, B10000000000000000000014 /* GeneratedMobilePreviewScenarios.swift */, B10000000000000000000015 /* MobileDesignGallery.swift */, B10000000000000000000018 /* AdaptiveModalComponents.swift */); path = DesignSystem; sourceTree = ""; }; /* End PBXGroup section */ @@ -97,7 +120,7 @@ /* End PBXProject section */ /* Begin PBXSourcesBuildPhase section */ - C10000000000000000000002 /* Sources */ = {isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = (A10000000000000000000001, A10000000000000000000002, A10000000000000000000003, A10000000000000000000004, A10000000000000000000005, A10000000000000000000006, A10000000000000000000007, A10000000000000000000008, A10000000000000000000010, A10000000000000000000013, A10000000000000000000014, A10000000000000000000015, A10000000000000000000017, A10000000000000000000018, A10000000000000000000019, A10000000000000000000020, A10000000000000000000021, A10000000000000000000022, A10000000000000000000023, A10000000000000000000024, A10000000000000000000025, A10000000000000000000026, A10000000000000000000027, A10000000000000000000028, A10000000000000000000029); runOnlyForDeploymentPostprocessing = 0; }; + C10000000000000000000002 /* Sources */ = {isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = (A10000000000000000000001, A10000000000000000000002, A10000000000000000000003, A10000000000000000000004, A10000000000000000000005, A10000000000000000000006, A10000000000000000000007, A10000000000000000000008, A10000000000000000000010, A10000000000000000000013, A10000000000000000000014, A10000000000000000000015, A10000000000000000000017, A10000000000000000000018, A10000000000000000000019, A10000000000000000000020, A10000000000000000000021, A10000000000000000000022, A10000000000000000000024, A10000000000000000000025, A10000000000000000000026, A10000000000000000000027, A10000000000000000000028, A10000000000000000000029, A10000000000000000000030, A10000000000000000000031, A10000000000000000000032, A10000000000000000000033, A10000000000000000000034, A10000000000000000000035, A10000000000000000000036, A10000000000000000000037, A10000000000000000000038); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXSourcesBuildPhase section */ /* Begin PBXResourcesBuildPhase section */ diff --git a/src/apps/mobile/ios/BitFun/App/BitFunApp.swift b/src/apps/mobile/ios/BitFun/App/BitFunApp.swift index 8c75663120..6a62052750 100644 --- a/src/apps/mobile/ios/BitFun/App/BitFunApp.swift +++ b/src/apps/mobile/ios/BitFun/App/BitFunApp.swift @@ -2,9 +2,9 @@ import SwiftUI @main struct BitFunApp: App { - @StateObject private var model = MobileAppModel.launchConfigured + @StateObject private var model = MobileLaunchConfiguration.makeModel() @Environment(\.scenePhase) private var scenePhase - private let designPreviewScenario = Self.resolveDesignPreviewScenario() + private let designPreviewScenario = MobileLaunchConfiguration.designPreviewScenario() var body: some Scene { WindowGroup { @@ -19,17 +19,4 @@ struct BitFunApp: App { } } - private static func resolveDesignPreviewScenario() -> MobilePreviewScenario? { - let arguments = ProcessInfo.processInfo.arguments - guard let marker = arguments.firstIndex(of: "--design-preview") else { return nil } - let scenarioID = arguments.indices.contains(marker + 1) ? arguments[marker + 1] : "connected-conversation" - switch scenarioID { - case MobilePreviewScenarios.streamingDark.id: - return MobilePreviewScenarios.streamingDark - case MobilePreviewScenarios.reconnectingWide.id: - return MobilePreviewScenarios.reconnectingWide - default: - return MobilePreviewScenarios.connectedConversation - } - } } diff --git a/src/apps/mobile/ios/BitFun/App/MobileLaunchConfiguration.swift b/src/apps/mobile/ios/BitFun/App/MobileLaunchConfiguration.swift new file mode 100644 index 0000000000..102d007dff --- /dev/null +++ b/src/apps/mobile/ios/BitFun/App/MobileLaunchConfiguration.swift @@ -0,0 +1,330 @@ +import Foundation + +@MainActor +enum MobileLaunchConfiguration { + static var pairingAccountPreview: Bool { ProcessInfo.processInfo.arguments.contains("--pairing-account") } + static var pairingManualPreview: Bool { ProcessInfo.processInfo.arguments.contains("--pairing-manual") } + static func makeModel() -> MobileAppModel { + let first = ChatSession(id: UUID().uuidString, title: "你好", updatedLabel: "刚刚") + let model = MobileAppModel( + sessions: [first], + selectedSessionID: first.id, + messages: [ + ChatMessage(id: UUID(), role: .user, text: "你好"), + ChatMessage(id: UUID(), role: .assistant, text: "这是 BitFun 的移动端会话界面。你可以从手机连接桌面端,查看工作区、会话和 Agent 的执行状态。") + ] + ) + return configure(model) + } + + private static func configure(_ model: MobileAppModel) -> MobileAppModel { + let arguments = ProcessInfo.processInfo.arguments + if arguments.contains("--english") { + model.setLanguage(.english) + } else if arguments.contains("--simplified-chinese") { + model.setLanguage(.simplifiedChinese) + } + if arguments.contains("--remote") { + model.surface = .remote + } + if arguments.contains("--connected") { + model.configureConnectedPreview() + } + if arguments.contains("--remote-chat-section") { + if !model.remoteConnected { model.configureConnectedPreview() } + model.remoteSessions.append( + ChatSession( + id: "preview-remote-chat", + title: "移动端体验对齐", + updatedLabel: "刚刚", + status: "idle", + agentType: "Claw", + workspacePath: nil, + workspaceName: nil + ) + ) + model.rebuildRemoteWorkspaceGroups() + } + if arguments.contains("--remote-view-settings") { + if !model.remoteConnected { model.configureConnectedPreview() } + model.remoteViewSettingsOpen = true + } + if arguments.contains("--remote-view-density") { + if !model.remoteConnected { model.configureConnectedPreview() } + let now = ISO8601DateFormatter().string(from: Date()) + for index in model.remoteSessions.indices { + model.remoteSessions[index].updatedLabel = now + } + model.remoteGroupMode = "TIME" + model.remoteShowWorkspaceMetadata = true + model.remoteShowUpdatedMetadata = true + model.remoteShowStatusMetadata = true + model.rebuildRemoteWorkspaceGroups() + } + if arguments.contains("--timeline-preview") { + model.configureTimelinePreview() + } + if arguments.contains("--file-preview") { + model.filePreview = MobileFilePreview( + id: "src/main.rs", + name: "main.rs", + content: "// Remote workspace preview\nfn main() {\n println!(\"Hello from BitFun\");\n}\n", + mimeType: "text/x-rust", + imageData: nil, + truncated: false, + failure: nil + ) + } + if arguments.contains("--download-preview") { + model.pendingDownload = MobilePendingDownload( + reference: "computer://src/main.rs", + remotePath: "src/main.rs", + name: "main.rs", + mimeType: "text/x-rust", + data: Data("fn main() {}\n".utf8) + ) + model.downloadTargetPath = "src/main.rs" + model.downloadPhase = .saving + model.downloadStatusText = model.localized("正在保存") + model.downloadExporterOpen = true + } + if let relay = arguments.value(after: "--relay-url"), + let username = arguments.value(after: "--username"), + let password = arguments.value(after: "--password") { + model.loginAccount(relayURL: relay, username: username, password: password) + } + if arguments.contains("--drawer") { + model.drawerOpen = true + } + if arguments.contains("--settings") { + model.settingsOpen = true + } + if arguments.contains("--remote-settings") { + model.surface = .remote + model.remoteControlSettingsOpen = true + } + if arguments.contains("--model-settings") { + model.settingsOpen = true + model.generalConfigOpen = true + } + if arguments.contains("--composer-model-picker") || + ProcessInfo.processInfo.environment["BITFUN_COMPOSER_MODEL_PICKER"] == "1" { + model.composerModelPickerPreview = true + model.localSessionSelected = true + model.draft = "\n" + model.modelOptions = [ + ComposerModelOption( + id: "preview-codex", + primaryLabel: "GPT-5.6 Codex", + secondaryLabel: "BitFun 账号", + source: "ACCOUNT", + selected: true + ), + ComposerModelOption( + id: "preview-local", + primaryLabel: "本机自定义模型", + secondaryLabel: "OpenAI 兼容服务", + source: "LOCAL", + selected: false + ), + ] + } + if arguments.contains("--pairing") || arguments.contains("--pairing-manual") || + arguments.contains("--pairing-account") { + model.pairingSheetOpen = true + } + if arguments.contains("--remote-create") || arguments.contains("--remote-create-workspace-picker") { + model.remoteCreatePreview = true + if !model.remoteConnected { model.configureConnectedPreview() } + model.remoteCreateOpen = true + } + if arguments.contains("--remote-home-preview") { + model.remoteSessionSelected = false + model.selectedSessionID = "" + model.timelineRows = [] + model.messages = [] + } + if arguments.contains("--local-actions") { + model.localActionPreview = true + model.surface = .local + model.localSessionSelected = true + model.remoteSessionSelected = false + if let localSession = model.sessions.first { + model.selectedSessionID = localSession.id + } + } + if arguments.contains("--account-login") { + model.accountLoginPreview = true + model.accountUser = nil + model.accountDeviceName = nil + model.accountSelectedDeviceID = nil + model.accountDevices = [] + model.accountDeviceCount = 0 + model.coreErrorMessage = nil + model.settingsOpen = false + model.accountSheetOpen = true + } + if arguments.contains("--account-profile") { + model.accountLoginPreview = true + model.accountUser = "bitfun-user" + model.accountUserID = "user-preview-7A31" + model.accountDevices = [ + MobileAccountDevice( + id: "desktop-preview", + name: "Studio Mac", + online: true, + selected: true + ), + MobileAccountDevice( + id: "desktop-offline-preview", + name: "Office PC", + online: false, + selected: false + ), + ] + model.accountDeviceName = "Studio Mac" + model.accountSelectedDeviceID = "desktop-preview" + model.accountDeviceCount = model.accountDevices.count + model.coreErrorMessage = nil + model.settingsOpen = false + model.accountSheetOpen = true + } + return model + } + + static func designPreviewScenario() -> MobilePreviewScenario? { + let arguments = ProcessInfo.processInfo.arguments + guard let marker = arguments.firstIndex(of: "--design-preview") else { return nil } + let scenarioID = arguments.indices.contains(marker + 1) ? arguments[marker + 1] : "connected-conversation" + switch scenarioID { + case MobilePreviewScenarios.streamingDark.id: + return MobilePreviewScenarios.streamingDark + case MobilePreviewScenarios.reconnectingWide.id: + return MobilePreviewScenarios.reconnectingWide + default: + return MobilePreviewScenarios.connectedConversation + } + } +} + +private extension MobileAppModel { + func configureConnectedPreview() { + directPairingConnected = true + surface = .remote + remoteConnected = true + connectionPhase = .connected + remoteSessionSelected = true + accountUser = "preview@bitfun" + accountDeviceName = "DESKTOP-KM3L4UI" + accountSelectedDeviceID = "preview-desktop" + directoryFixturePreview = true + accountDevices = [ + MobileAccountDevice(id: "preview-desktop", name: "DESKTOP-KM3L4UI", online: true, selected: true), + MobileAccountDevice(id: "preview-mac", name: "Studio Mac", online: true, selected: false), + MobileAccountDevice(id: "preview-offline", name: "Office PC", online: false, selected: false) + ] + accountDeviceCount = accountDevices.count + let session = ChatSession( + id: UUID().uuidString, + title: "你好", + updatedLabel: "刚刚", + agentType: "code", + workspacePath: "/workspace/BitFun", + workspaceName: "BitFun" + ) + remoteSessions = [session] + let extraSessions = (1...5).map { index in + ChatSession( + id: "preview-session-\(index)", title: "Review session \(index)", updatedLabel: "2026-01-01T00:00:00Z", + status: index == 1 ? "running" : "idle", agentType: "code", + workspacePath: "/workspace/BitFun", workspaceName: "BitFun", deviceKey: "preview-desktop" + ) + } + remoteSessions.append(contentsOf: extraSessions) + let cachedSession = ChatSession( + id: "preview-offline-session", title: "Cached offline session", updatedLabel: "2026-01-01T00:00:00Z", + status: "idle", agentType: "code", workspacePath: "/office/project", workspaceName: "Office project", deviceKey: "preview-offline" + ) + let failedSession = ChatSession( + id: "preview-failed-session", title: "Cached failed session", updatedLabel: "2026-01-01T00:00:00Z", + status: "idle", agentType: "code", workspacePath: "/staging/project", workspaceName: "Staging", deviceKey: "preview-mac" + ) + remoteSessions.append(contentsOf: [cachedSession, failedSession]) + let previewWorkspace = MobileWorkspaceGroup(path: "/workspace/BitFun", name: "BitFun", selected: true, sessions: remoteSessions.filter { $0.deviceKey == "preview-desktop" }, deviceKey: "preview-desktop") + let offlineWorkspace = MobileWorkspaceGroup(path: "/office/project", name: "Office project", selected: false, sessions: [cachedSession], deviceKey: "preview-offline") + let failedWorkspace = MobileWorkspaceGroup(path: "/staging/project", name: "Staging", selected: false, sessions: [failedSession], deviceKey: "preview-mac") + deviceDirectory = [ + MobileDeviceDirectoryEntry(id: "preview-desktop", name: "DESKTOP-KM3L4UI", online: true, expanded: true, status: "READY", error: nil, workspaces: [previewWorkspace], sessions: previewWorkspace.sessions), + MobileDeviceDirectoryEntry(id: "preview-mac", name: "Studio Mac", online: true, expanded: true, status: "FAILED", error: "REMOTE_UNAVAILABLE", workspaces: [failedWorkspace], sessions: [failedSession]), + MobileDeviceDirectoryEntry(id: "preview-offline", name: "Office PC", online: false, expanded: false, status: "READY", error: nil, workspaces: [offlineWorkspace], sessions: [cachedSession]) + ] + workspaceCatalog = [(path: "/workspace/BitFun", name: "BitFun", selected: true)] + remoteAssistants = [ + MobileAssistantOption(path: "/workspace/BitFun/.bitfun/assistants/review", name: "代码审查助手") + ] + remoteHasMore = true + rebuildRemoteWorkspaceGroups() + selectedSessionID = session.id + messages = [ + ChatMessage(id: UUID(), role: .user, text: "你好"), + ChatMessage(id: UUID(), role: .assistant, text: "这是 BitFun 的远程会话预览。"), + ] + timelineRows = messages.map(Self.simpleTimelineRow) + } + + func configureTimelinePreview() { + configureConnectedPreview() + let userID = UUID().uuidString + let assistantID = UUID().uuidString + let readOne = MobileTimelineTool( + id: "preview-read-1", name: "Read", phase: "COMPLETED", kind: "DOCUMENT", + operation: "READ_FILE", target: "main.rs", filePath: "computer://src/main.rs", + fileLabel: "main.rs", input: "src/main.rs", output: "读取完成", question: nil, questions: [], actions: [] + ) + let readTwo = MobileTimelineTool( + id: "preview-read-2", name: "Search", phase: "COMPLETED", kind: "SEARCH", + operation: "SEARCH_CODE", target: "MobileShellView", filePath: "", fileLabel: "", + input: "MobileShellView", output: "找到 4 处结果", question: nil, questions: [], actions: [] + ) + let approval = MobileTimelineTool( + id: "preview-approval", name: "Bash", phase: "PENDING_CONFIRMATION", kind: "COMMAND", + operation: "RUN_COMMAND", target: "pnpm test", filePath: "", fileLabel: "", + input: "pnpm test", output: "", question: nil, questions: [], actions: ["APPROVE", "REJECT"] + ) + let question = MobileTimelineTool( + id: "preview-question", name: "AskUserQuestion", phase: "PENDING_CONFIRMATION", kind: "QUESTION", + operation: "ASK_CONFIRMATION", target: "", filePath: "", fileLabel: "", input: "", output: "", + question: "要同时运行远程场景回归吗?", questions: [], actions: ["ANSWER"] + ) + timelineRows = [ + MobileConversationRow( + id: userID, kind: "USER", text: "请检查移动端的消息、工具和文件交互。", thinking: nil, + images: [], tools: [], blocks: [], streaming: false, typing: false, pending: false, showRetry: false + ), + MobileConversationRow( + id: assistantID, kind: "ASSISTANT", text: "", thinking: nil, images: [], tools: [], + blocks: [ + .thinking(id: "preview-thinking", text: "先对照 HarmonyOS 的消息顺序与工具状态,再核对 Android 的交互策略。", streaming: false), + .text( + id: "preview-text", + text: "## 检查结果\n\n消息按共享投影顺序显示,文件可直接打开:[main.rs](computer://src/main.rs)。\n\n- Markdown 与代码块\n- 思考过程与子任务\n- 工具确认、提问和取消\n\n```swift\nlet parity = true\n```", + streaming: false + ), + .tools(id: "preview-tools", tools: [readOne, readTwo, approval, question]), + ], + streaming: false, typing: false, pending: false, showRetry: false + ), + ] + messages = [ + ChatMessage(id: UUID(), role: .user, text: "请检查移动端的消息、工具和文件交互。"), + ChatMessage(id: UUID(), role: .assistant, text: "检查结果"), + ] + } +} + +private extension Array where Element == String { + func value(after flag: String) -> String? { + guard let position = firstIndex(of: flag), position < index(before: endIndex) else { return nil } + return self[index(after: position)] + } +} diff --git a/src/apps/mobile/ios/BitFun/Features/Shell/AccountPairingViews.swift b/src/apps/mobile/ios/BitFun/Features/Account/AccountSettingsView.swift similarity index 52% rename from src/apps/mobile/ios/BitFun/Features/Shell/AccountPairingViews.swift rename to src/apps/mobile/ios/BitFun/Features/Account/AccountSettingsView.swift index b480fd9f64..1dd2a2332d 100644 --- a/src/apps/mobile/ios/BitFun/Features/Shell/AccountPairingViews.swift +++ b/src/apps/mobile/ios/BitFun/Features/Account/AccountSettingsView.swift @@ -1,339 +1,6 @@ -import AVFoundation import BitFunMobileCore import SwiftUI -struct PairingSheet: View { - private enum Step { case intro, scan } - - @ObservedObject var model: MobileAppModel - @Environment(\.dismiss) private var dismiss - @State private var step: Step = .intro - @State private var pairingURL = ProcessInfo.processInfo.arguments.contains("--pairing-account") - ? "https://relay.example.com/#/pair?room=preview-room&pk=preview-key&auth=account&user=preview" - : "" - @State private var pairingUserID = "" - // Intentionally transient: pairing passwords must never enter saved scene state. - @State private var pairingPassword = "" - @State private var scannerOpen = false - @State private var manualOpen = false - @FocusState private var focused: Bool - - var body: some View { - return ZStack { - if step == .intro { introPage } else { scanPage } - if manualOpen { manualPairingOverlay } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(BitFunTheme.card) - .onAppear { - if model.pairingScanRequested { - step = .scan - scannerOpen = true - model.consumePairingScanRequest() - } else if ProcessInfo.processInfo.arguments.contains("--pairing-manual") || - ProcessInfo.processInfo.arguments.contains("--pairing-account") { - step = .scan - manualOpen = true - focused = !ProcessInfo.processInfo.arguments.contains("--pairing-account") - } - } - .fullScreenCover(isPresented: $scannerOpen) { - QRCodeScannerView { code in - pairingURL = code - scannerOpen = false - if PairingLinkHintsKt.inspectPairingLink(url: code).requiresAccount { - manualOpen = true - focused = true - } else { - model.submitPairing(url: code) - } - } - .ignoresSafeArea() - } - } - - private var introPage: some View { - VStack(spacing: 0) { - hero(height: 250) - VStack(spacing: 15) { - Image(systemName: "desktopcomputer") - .font(.system(size: 54, weight: .medium)) - .foregroundStyle(BitFunTheme.ink) - .frame(width: 88, height: 88) - .background(BitFunTheme.card) - .clipShape(RoundedRectangle(cornerRadius: 28)) - .shadow(color: BitFunTheme.line, radius: 18, y: 7) - Text(model.localized("选择连接方式")) - .font(.system(size: 24, weight: .bold)) - .foregroundStyle(BitFunTheme.ink) - } - .padding(.horizontal, 28) - .offset(y: -10) - Spacer(minLength: 12) - SignedOutConnectionActions( - scanTitle: model.localized("扫码连接"), - accountTitle: model.localized("登录 BitFun 账号"), - onScan: { - step = .scan - scannerOpen = true - }, - onOpenAccount: model.openAccountFromPairing, - enabled: !model.pairingBusy, - buttonHeight: 58, - spacing: 12, - fontSize: 20 - ) - .padding(.horizontal, 44) - .padding(.bottom, 34) - } - } - - private var scanPage: some View { - VStack(spacing: 0) { - hero(height: 252) - VStack(spacing: 22) { - Button { scannerOpen = true } label: { - Image(systemName: "qrcode.viewfinder") - .font(.system(size: 72, weight: .regular)) - .foregroundStyle(BitFunTheme.ink) - .frame(width: 176, height: 176) - .background(MobileDesignColors.connectHeroSurface) - .overlay(RoundedRectangle(cornerRadius: 34).stroke(BitFunTheme.line, lineWidth: 1.5)) - .clipShape(RoundedRectangle(cornerRadius: 34)) - } - .buttonStyle(.plain) - Text(model.localized("扫描二维码")) - .font(.system(size: 24, weight: .bold)).foregroundStyle(BitFunTheme.ink) - if let error = model.pairingError { - Text(error).font(.system(size: 13)).foregroundStyle(BitFunTheme.red) - .multilineTextAlignment(.center) - } - } - .offset(y: -50) - Spacer(minLength: 12) - Button { manualOpen = true; focused = true } label: { - Text(model.localized("手动输入配对码")) - .font(.system(size: 20, weight: .bold)) - .foregroundStyle(BitFunTheme.ink) - .frame(maxWidth: .infinity, minHeight: 58) - .background(BitFunTheme.card) - .overlay(Capsule().stroke(BitFunTheme.line, lineWidth: 1.5)) - .clipShape(Capsule()) - } - .buttonStyle(.plain) - .padding(.horizontal, 44) - .padding(.bottom, 34) - } - } - - private func hero(height: CGFloat) -> some View { - ZStack(alignment: .topLeading) { - LinearGradient( - colors: [MobileDesignColors.connectHeroBg, MobileDesignColors.connectHeroSurface], - startPoint: .topLeading, - endPoint: .bottomTrailing - ) - Button { - if step == .scan { step = .intro } else { dismiss() } - } label: { - Image(systemName: "chevron.left") - .font(.system(size: 20, weight: .medium)) - .foregroundStyle(BitFunTheme.ink) - .frame(width: 44, height: 44) - .background(BitFunTheme.card) - .clipShape(Circle()) - } - .buttonStyle(.plain) - .padding(.top, 18).padding(.leading, 18) - } - .frame(height: height) - } - - private var manualPairingOverlay: some View { - let hints = PairingLinkHintsKt.inspectPairingLink(url: pairingURL) - let effectiveUserID = pairingUserID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - ? hints.suggestedUserId - : pairingUserID.trimmingCharacters(in: .whitespacesAndNewlines) - let canSubmit = !model.pairingBusy && - !pairingURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && - (!hints.requiresAccount || (!effectiveUserID.isEmpty && !pairingPassword.isEmpty)) - - return ZStack { - MobileDesignColors.modalScrim - .ignoresSafeArea() - .onTapGesture { - if !model.pairingBusy { - pairingPassword = "" - manualOpen = false - } - } - VStack(alignment: .leading, spacing: 20) { - Text(model.localized(hints.requiresAccount ? "账号认证配对" : "手动输入配对码")) - .font(.system(size: 24, weight: .bold)).foregroundStyle(BitFunTheme.ink) - Text(model.localized( - hints.requiresAccount - ? "此桌面要求使用 BitFun 账号验证身份。" - : "输入桌面端显示的配对链接或代码。" - )) - .font(.system(size: 17)).foregroundStyle(BitFunTheme.muted).lineSpacing(5) - TextField(model.localized("配对码或连接链接"), text: $pairingURL) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - .keyboardType(.URL) - .lineLimit(1) - .font(.system(size: 20)).foregroundStyle(BitFunTheme.ink) - .padding(.horizontal, 20).frame(minHeight: 62) - .background(BitFunTheme.soft).clipShape(Capsule()) - .focused($focused) - if hints.requiresAccount { - TextField( - hints.suggestedUserId.isEmpty - ? model.localized("BitFun 用户名") - : hints.suggestedUserId, - text: $pairingUserID - ) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - .textContentType(.username) - .font(.system(size: 18)).foregroundStyle(BitFunTheme.ink) - .padding(.horizontal, 20).frame(minHeight: 56) - .background(BitFunTheme.soft).clipShape(Capsule()) - - SecureField(model.localized("BitFun 密码"), text: $pairingPassword) - .textContentType(.password) - .font(.system(size: 18)).foregroundStyle(BitFunTheme.ink) - .padding(.horizontal, 20).frame(minHeight: 56) - .background(BitFunTheme.soft).clipShape(Capsule()) - - Text(model.localized("账号凭据只用于本次加密配对,不会保存。")) - .font(.system(size: 13)) - .foregroundStyle(BitFunTheme.muted) - .lineSpacing(3) - } - if let error = model.pairingError { - Text(error).font(.system(size: 13)).foregroundStyle(BitFunTheme.red) - } - HStack(spacing: 12) { - pairingButton("取消", primary: false) { - pairingPassword = "" - manualOpen = false - focused = false - } - pairingButton(model.pairingBusy ? "正在连接" : "配对", primary: true) { - if hints.requiresAccount { - model.submitPairing( - url: pairingURL, - userID: effectiveUserID, - password: pairingPassword - ) - pairingPassword = "" - } else { - model.submitPairing(url: pairingURL) - } - focused = false - } - .disabled(!canSubmit) - } - } - .padding(.horizontal, 28).padding(.top, 30).padding(.bottom, 28) - .frame(maxWidth: 520) - .background(BitFunTheme.card) - .clipShape(RoundedRectangle(cornerRadius: 34)) - .overlay(RoundedRectangle(cornerRadius: 34).stroke(BitFunTheme.line, lineWidth: 1)) - .padding(.horizontal, 34) - } - } - - private func pairingButton(_ title: String, primary: Bool, action: @escaping () -> Void) -> some View { - Button(action: action) { - Text(model.localized(title)) - .font(.system(size: 19, weight: .bold)) - .foregroundStyle(primary ? Color.white : BitFunTheme.ink) - .frame(maxWidth: .infinity, minHeight: 58) - .background(primary ? BitFunTheme.accent : BitFunTheme.soft) - .clipShape(Capsule()) - } - .buttonStyle(.plain) - } -} - -struct QRCodeScannerView: UIViewControllerRepresentable { - let onCode: (String) -> Void - - func makeUIViewController(context: Context) -> QRScannerController { - let controller = QRScannerController() - controller.onCode = onCode - return controller - } - - func updateUIViewController(_ uiViewController: QRScannerController, context: Context) {} -} - -final class QRScannerController: UIViewController, AVCaptureMetadataOutputObjectsDelegate { - private let session = AVCaptureSession() - private var previewLayer: AVCaptureVideoPreviewLayer? - var onCode: ((String) -> Void)? - - override func viewDidLoad() { - super.viewDidLoad() - view.backgroundColor = .black - let close = UIButton(type: .system) - close.setImage(UIImage(systemName: "xmark"), for: .normal) - close.tintColor = .white - close.backgroundColor = UIColor.black.withAlphaComponent(0.55) - close.layer.cornerRadius = 22 - close.addAction(UIAction { [weak self] _ in self?.dismiss(animated: true) }, for: .touchUpInside) - close.translatesAutoresizingMaskIntoConstraints = false - view.addSubview(close) - NSLayoutConstraint.activate([ - close.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 16), - close.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20), - close.widthAnchor.constraint(equalToConstant: 44), - close.heightAnchor.constraint(equalToConstant: 44), - ]) - - guard AVCaptureDevice.authorizationStatus(for: .video) != .denied else { return } - AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in - guard granted else { return } - DispatchQueue.main.async { self?.configureCapture() } - } - } - - override func viewDidLayoutSubviews() { - super.viewDidLayoutSubviews() - previewLayer?.frame = view.bounds - } - - private func configureCapture() { - guard let device = AVCaptureDevice.default(for: .video), - let input = try? AVCaptureDeviceInput(device: device), - session.canAddInput(input) else { return } - let output = AVCaptureMetadataOutput() - guard session.canAddOutput(output) else { return } - session.addInput(input) - session.addOutput(output) - output.setMetadataObjectsDelegate(self, queue: .main) - output.metadataObjectTypes = [.qr] - let layer = AVCaptureVideoPreviewLayer(session: session) - layer.videoGravity = .resizeAspectFill - view.layer.insertSublayer(layer, at: 0) - previewLayer = layer - session.startRunning() - } - - func metadataOutput( - _ output: AVCaptureMetadataOutput, - didOutput metadataObjects: [AVMetadataObject], - from connection: AVCaptureConnection, - ) { - guard let value = (metadataObjects.first as? AVMetadataMachineReadableCodeObject)?.stringValue, - !value.isEmpty else { return } - session.stopRunning() - onCode?(value) - dismiss(animated: true) - } -} - struct AccountSettingsView: View { @ObservedObject var model: MobileAppModel var onClose: (() -> Void)? = nil diff --git a/src/apps/mobile/ios/BitFun/Features/Chat/ConversationHomeViews.swift b/src/apps/mobile/ios/BitFun/Features/Chat/ConversationHomeViews.swift new file mode 100644 index 0000000000..1878a85683 --- /dev/null +++ b/src/apps/mobile/ios/BitFun/Features/Chat/ConversationHomeViews.swift @@ -0,0 +1,7 @@ +import SwiftUI + +struct LocalHomeView: View { + var body: some View { + BitFunTheme.page + } +} diff --git a/src/apps/mobile/ios/BitFun/Features/Pairing/PairingSheet.swift b/src/apps/mobile/ios/BitFun/Features/Pairing/PairingSheet.swift new file mode 100644 index 0000000000..23b008f26c --- /dev/null +++ b/src/apps/mobile/ios/BitFun/Features/Pairing/PairingSheet.swift @@ -0,0 +1,257 @@ +import BitFunMobileCore +import SwiftUI + +struct PairingSheet: View { + private enum Step { case intro, scan } + + @ObservedObject var model: MobileAppModel + @Environment(\.dismiss) private var dismiss + @State private var step: Step = .intro + @State private var pairingURL = MobileLaunchConfiguration.pairingAccountPreview + ? "https://relay.example.com/#/pair?room=preview-room&pk=preview-key&auth=account&user=preview" + : "" + @State private var pairingUserID = "" + // Intentionally transient: pairing passwords must never enter saved scene state. + @State private var pairingPassword = "" + @State private var scannerOpen = false + @State private var manualOpen = false + @FocusState private var focused: Bool + + var body: some View { + return ZStack { + if step == .intro { introPage } else { scanPage } + if manualOpen { manualPairingOverlay } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(BitFunTheme.card) + .onAppear { + if model.pairingScanRequested { + step = .scan + scannerOpen = true + model.consumePairingScanRequest() + } else if MobileLaunchConfiguration.pairingManualPreview || + MobileLaunchConfiguration.pairingAccountPreview { + step = .scan + manualOpen = true + focused = !MobileLaunchConfiguration.pairingAccountPreview + } + } + .fullScreenCover(isPresented: $scannerOpen) { + QRCodeScannerView { code in + pairingURL = code + scannerOpen = false + if PairingLinkHintsKt.inspectPairingLink(url: code).requiresAccount { + manualOpen = true + focused = true + } else { + model.submitPairing(url: code) + } + } + .ignoresSafeArea() + } + } + + private var introPage: some View { + VStack(spacing: 0) { + hero(height: 250) + VStack(spacing: 15) { + Image(systemName: "desktopcomputer") + .font(.system(size: 54, weight: .medium)) + .foregroundStyle(BitFunTheme.ink) + .frame(width: 88, height: 88) + .background(BitFunTheme.card) + .clipShape(RoundedRectangle(cornerRadius: 28)) + .shadow(color: BitFunTheme.line, radius: 18, y: 7) + Text(model.localized("选择连接方式")) + .font(.system(size: 24, weight: .bold)) + .foregroundStyle(BitFunTheme.ink) + } + .padding(.horizontal, 28) + .offset(y: -10) + Spacer(minLength: 12) + SignedOutConnectionActions( + scanTitle: model.localized("扫码连接"), + accountTitle: model.localized("登录 BitFun 账号"), + onScan: { + step = .scan + scannerOpen = true + }, + onOpenAccount: model.openAccountFromPairing, + enabled: !model.pairingBusy, + buttonHeight: 58, + spacing: 12, + fontSize: 20 + ) + .padding(.horizontal, 44) + .padding(.bottom, 34) + } + } + + private var scanPage: some View { + VStack(spacing: 0) { + hero(height: 252) + VStack(spacing: 22) { + Button { scannerOpen = true } label: { + Image(systemName: "qrcode.viewfinder") + .font(.system(size: 72, weight: .regular)) + .foregroundStyle(BitFunTheme.ink) + .frame(width: 176, height: 176) + .background(MobileDesignColors.connectHeroSurface) + .overlay(RoundedRectangle(cornerRadius: 34).stroke(BitFunTheme.line, lineWidth: 1.5)) + .clipShape(RoundedRectangle(cornerRadius: 34)) + } + .buttonStyle(.plain) + Text(model.localized("扫描二维码")) + .font(.system(size: 24, weight: .bold)).foregroundStyle(BitFunTheme.ink) + if let error = model.pairingError { + Text(error).font(.system(size: 13)).foregroundStyle(BitFunTheme.red) + .multilineTextAlignment(.center) + } + } + .offset(y: -50) + Spacer(minLength: 12) + Button { manualOpen = true; focused = true } label: { + Text(model.localized("手动输入配对码")) + .font(.system(size: 20, weight: .bold)) + .foregroundStyle(BitFunTheme.ink) + .frame(maxWidth: .infinity, minHeight: 58) + .background(BitFunTheme.card) + .overlay(Capsule().stroke(BitFunTheme.line, lineWidth: 1.5)) + .clipShape(Capsule()) + } + .buttonStyle(.plain) + .padding(.horizontal, 44) + .padding(.bottom, 34) + } + } + + private func hero(height: CGFloat) -> some View { + ZStack(alignment: .topLeading) { + LinearGradient( + colors: [MobileDesignColors.connectHeroBg, MobileDesignColors.connectHeroSurface], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + Button { + if step == .scan { step = .intro } else { dismiss() } + } label: { + Image(systemName: "chevron.left") + .font(.system(size: 20, weight: .medium)) + .foregroundStyle(BitFunTheme.ink) + .frame(width: 44, height: 44) + .background(BitFunTheme.card) + .clipShape(Circle()) + } + .buttonStyle(.plain) + .padding(.top, 18).padding(.leading, 18) + } + .frame(height: height) + } + + private var manualPairingOverlay: some View { + let hints = PairingLinkHintsKt.inspectPairingLink(url: pairingURL) + let effectiveUserID = pairingUserID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + ? hints.suggestedUserId + : pairingUserID.trimmingCharacters(in: .whitespacesAndNewlines) + let canSubmit = !model.pairingBusy && + !pairingURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && + (!hints.requiresAccount || (!effectiveUserID.isEmpty && !pairingPassword.isEmpty)) + + return ZStack { + MobileDesignColors.modalScrim + .ignoresSafeArea() + .onTapGesture { + if !model.pairingBusy { + pairingPassword = "" + manualOpen = false + } + } + VStack(alignment: .leading, spacing: 20) { + Text(model.localized(hints.requiresAccount ? "账号认证配对" : "手动输入配对码")) + .font(.system(size: 24, weight: .bold)).foregroundStyle(BitFunTheme.ink) + Text(model.localized( + hints.requiresAccount + ? "此桌面要求使用 BitFun 账号验证身份。" + : "输入桌面端显示的配对链接或代码。" + )) + .font(.system(size: 17)).foregroundStyle(BitFunTheme.muted).lineSpacing(5) + TextField(model.localized("配对码或连接链接"), text: $pairingURL) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .keyboardType(.URL) + .lineLimit(1) + .font(.system(size: 20)).foregroundStyle(BitFunTheme.ink) + .padding(.horizontal, 20).frame(minHeight: 62) + .background(BitFunTheme.soft).clipShape(Capsule()) + .focused($focused) + if hints.requiresAccount { + TextField( + hints.suggestedUserId.isEmpty + ? model.localized("BitFun 用户名") + : hints.suggestedUserId, + text: $pairingUserID + ) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .textContentType(.username) + .font(.system(size: 18)).foregroundStyle(BitFunTheme.ink) + .padding(.horizontal, 20).frame(minHeight: 56) + .background(BitFunTheme.soft).clipShape(Capsule()) + + SecureField(model.localized("BitFun 密码"), text: $pairingPassword) + .textContentType(.password) + .font(.system(size: 18)).foregroundStyle(BitFunTheme.ink) + .padding(.horizontal, 20).frame(minHeight: 56) + .background(BitFunTheme.soft).clipShape(Capsule()) + + Text(model.localized("账号凭据只用于本次加密配对,不会保存。")) + .font(.system(size: 13)) + .foregroundStyle(BitFunTheme.muted) + .lineSpacing(3) + } + if let error = model.pairingError { + Text(error).font(.system(size: 13)).foregroundStyle(BitFunTheme.red) + } + HStack(spacing: 12) { + pairingButton("取消", primary: false) { + pairingPassword = "" + manualOpen = false + focused = false + } + pairingButton(model.pairingBusy ? "正在连接" : "配对", primary: true) { + if hints.requiresAccount { + model.submitPairing( + url: pairingURL, + userID: effectiveUserID, + password: pairingPassword + ) + pairingPassword = "" + } else { + model.submitPairing(url: pairingURL) + } + focused = false + } + .disabled(!canSubmit) + } + } + .padding(.horizontal, 28).padding(.top, 30).padding(.bottom, 28) + .frame(maxWidth: 520) + .background(BitFunTheme.card) + .clipShape(RoundedRectangle(cornerRadius: 34)) + .overlay(RoundedRectangle(cornerRadius: 34).stroke(BitFunTheme.line, lineWidth: 1)) + .padding(.horizontal, 34) + } + } + + private func pairingButton(_ title: String, primary: Bool, action: @escaping () -> Void) -> some View { + Button(action: action) { + Text(model.localized(title)) + .font(.system(size: 19, weight: .bold)) + .foregroundStyle(primary ? Color.white : BitFunTheme.ink) + .frame(maxWidth: .infinity, minHeight: 58) + .background(primary ? BitFunTheme.accent : BitFunTheme.soft) + .clipShape(Capsule()) + } + .buttonStyle(.plain) + } +} diff --git a/src/apps/mobile/ios/BitFun/Features/Remote/RemoteHomeViews.swift b/src/apps/mobile/ios/BitFun/Features/Remote/RemoteHomeViews.swift new file mode 100644 index 0000000000..bc891e35e7 --- /dev/null +++ b/src/apps/mobile/ios/BitFun/Features/Remote/RemoteHomeViews.swift @@ -0,0 +1,130 @@ +import BitFunMobileCore +import SwiftUI + +struct RemoteHomeView: View { + @ObservedObject var model: MobileAppModel + + var body: some View { + ZStack(alignment: .topTrailing) { + VStack(spacing: 12) { + Spacer() + ZStack { + Image(systemName: "desktopcomputer") + .font(.system(size: 42, weight: .medium)) + .foregroundStyle(BitFunTheme.ink) + } + .frame(width: 74, height: 74) + .background(BitFunTheme.card) + .overlay(RoundedRectangle(cornerRadius: 24).stroke(BitFunTheme.line, lineWidth: 1)) + .clipShape(RoundedRectangle(cornerRadius: 24)) + Text(model.localized("连接桌面端")) + .font(.system(size: 18, weight: .bold)) + .foregroundStyle(BitFunTheme.ink) + Text(model.localized("扫描桌面端显示的二维码,开始远程处理任务。")) + .font(.system(size: 13)) + .foregroundStyle(BitFunTheme.muted) + .multilineTextAlignment(.center) + .lineSpacing(7) + .padding(.horizontal, 20) + Button(model.localized("连接")) { model.connectRemote() } + .font(.system(size: 15, weight: .medium)) + .foregroundStyle(.white) + .frame(width: 136, height: 44) + .background(BitFunTheme.accent) + .clipShape(Capsule()) + Spacer() + } + remoteSettingsButton + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(.horizontal, 20) + .padding(.bottom, 48) + .background(BitFunTheme.page) + } + + private var remoteSettingsButton: some View { + Button { model.remoteControlSettingsOpen = true } label: { + Image(systemName: "gearshape") + .font(.system(size: 18, weight: .medium)) + .foregroundStyle(BitFunTheme.ink) + .frame(width: 44, height: 44) + .background(BitFunTheme.card) + .overlay(Circle().stroke(BitFunTheme.line, lineWidth: 1)) + .clipShape(Circle()) + } + .buttonStyle(.plain) + .accessibilityLabel(model.localized("远程控制设置")) + .padding(.top, 16).padding(.trailing, 16) + } +} + +struct RemoteConnectedHomeView: View { + @ObservedObject var model: MobileAppModel + + var body: some View { + ZStack(alignment: .topTrailing) { + VStack(spacing: 14) { + Spacer() + Image(systemName: "desktopcomputer.and.macbook") + .font(.system(size: 34, weight: .medium)).foregroundStyle(BitFunTheme.muted) + Text(model.localized("桌面端已连接")) + .font(MobileDesignTypography.titleMedium.font).foregroundStyle(BitFunTheme.ink) + Text(model.localized("选择已有会话,或在当前工作区创建一个新会话。")) + .font(MobileDesignTypography.bodySmall.font).foregroundStyle(BitFunTheme.muted) + .multilineTextAlignment(.center) + Button { model.remoteCreateOpen = true } label: { + Label(model.localized("新建远程会话"), systemImage: "plus") + .font(MobileDesignTypography.labelMedium.font).foregroundStyle(.white) + .frame(minWidth: 176, minHeight: 44).background(BitFunTheme.accent).clipShape(Capsule()) + } + .buttonStyle(.plain) + Spacer() + } + Button { model.remoteControlSettingsOpen = true } label: { + Image(systemName: "gearshape") + .font(.system(size: 18, weight: .medium)) + .foregroundStyle(BitFunTheme.ink) + .frame(width: 44, height: 44) + .background(BitFunTheme.card) + .overlay(Circle().stroke(BitFunTheme.line, lineWidth: 1)) + .clipShape(Circle()) + } + .buttonStyle(.plain) + .accessibilityLabel(model.localized("远程控制设置")) + .padding(.top, 16).padding(.trailing, 16) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(BitFunTheme.page) + } +} + +struct ConnectionStatusBar: View { + let phase: ConnectionPhase + var detail: String? + let onRetry: () -> Void + var body: some View { + HStack(spacing: 8) { + Circle().fill(phase == .reconnecting ? BitFunTheme.muted : BitFunTheme.red).frame(width: 8, height: 8) + Text(MobileLocalization.text(phase == .reconnecting ? "正在恢复连接" : "连接不可用")) + .font(.system(size: 13, weight: .medium)) + Text( + detail ?? MobileLocalization.text( + phase == .reconnecting ? "正在重新连接桌面端" : "请重新连接" + ) + ) + .font(.system(size: 12)) + .foregroundStyle(BitFunTheme.muted) + Spacer() + if phase == .disconnected { + Button(MobileLocalization.text("重试"), action: onRetry) + .font(.system(size: 13, weight: .semibold)) + .buttonStyle(.plain) + .foregroundStyle(BitFunTheme.accent) + } + } + .foregroundStyle(BitFunTheme.ink) + .padding(.horizontal, 18) + .frame(height: 48) + .background(BitFunTheme.soft) + } +} diff --git a/src/apps/mobile/ios/BitFun/Features/Settings/AppSettingsView.swift b/src/apps/mobile/ios/BitFun/Features/Settings/AppSettingsView.swift new file mode 100644 index 0000000000..c142ab5b0a --- /dev/null +++ b/src/apps/mobile/ios/BitFun/Features/Settings/AppSettingsView.swift @@ -0,0 +1,238 @@ +import SwiftUI + +struct SettingsView: View { + @ObservedObject var model: MobileAppModel + @Environment(\.dismiss) private var dismiss + @State private var accountOpen = false + + private var appVersion: String { + Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "1.0.0" + } + + private var selectedModelName: String { + model.modelOptions.first(where: \.selected)?.primaryLabel + ?? model.modelOptions.first?.primaryLabel + ?? model.localized("未配置") + } + + var body: some View { + ZStack(alignment: .topTrailing) { + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 0) { + Text(model.localized("设置")) + .font(.system(size: 28, weight: .bold)) + .foregroundStyle(BitFunTheme.ink) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.bottom, 30) + + Button { accountOpen = true } label: { + SettingsCard { + SettingsProfileRow( + subtitle: model.accountUser ?? model.localized("未登录") + ) + } + } + .buttonStyle(.plain) + .padding(.bottom, 24) + + SettingsGroup(title: "通用") { + VStack(spacing: 0) { + Button { model.languagePickerOpen = true } label: { + SettingsValueRow( + icon: "textformat", + title: "语言", + value: model.appLanguage.nativeName, + showsChevron: true + ) + } + .buttonStyle(.plain) + Divider().overlay(BitFunTheme.line).padding(.horizontal, 26) + Button { model.generalConfigOpen = true } label: { + SettingsValueRow( + icon: "square.grid.2x2", + title: "模型", + value: selectedModelName, + showsChevron: true + ) + } + .buttonStyle(.plain) + } + } + SettingsGroup(title: "关于") { + VStack(spacing: 0) { + SettingsValueRow( + icon: nil, + title: "产品", + value: "BitFun iOS版" + ) + Divider().overlay(BitFunTheme.line).padding(.horizontal, 26) + SettingsValueRow(icon: nil, title: "版本", value: appVersion) + } + } + } + .padding(.horizontal, 16) + .padding(.top, 64) + .padding(.bottom, 34) + } + + Button { dismiss() } label: { + Image(systemName: "xmark") + .font(.system(size: 18, weight: .regular)) + .foregroundStyle(BitFunTheme.ink) + .frame(width: 40, height: 40) + .background(BitFunTheme.card) + .clipShape(Circle()) + } + .buttonStyle(.plain) + .accessibilityLabel(model.localized("关闭")) + .padding(.top, 22) + .padding(.trailing, 18) + + if model.languagePickerOpen { + LanguagePickerSheet(model: model) + .transition(.move(edge: .trailing).combined(with: .opacity)) + } else if model.generalConfigOpen { + GeneralChatConfigSheet(model: model) + .transition(.move(edge: .trailing).combined(with: .opacity)) + } else if accountOpen { + AccountSettingsView(model: model, onClose: { accountOpen = false }) + .transition(.move(edge: .trailing).combined(with: .opacity)) + } + } + .background(BitFunTheme.page) + .animation(.easeInOut(duration: 0.2), value: model.languagePickerOpen) + .animation(.easeInOut(duration: 0.2), value: model.generalConfigOpen) + .animation(.easeInOut(duration: 0.2), value: accountOpen) + } +} + +private struct LanguagePickerSheet: View { + @ObservedObject var model: MobileAppModel + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + BitFunSelectionHeader(title: "选择语言", onClose: { model.languagePickerOpen = false }) + Divider().overlay(BitFunTheme.line) + + VStack(spacing: 0) { + ForEach(MobileLanguage.allCases) { language in + Button { + model.setLanguage(language) + model.languagePickerOpen = false + } label: { + HStack { + Text(language.nativeName) + .font(.system(size: 16, weight: .medium)) + .foregroundStyle(BitFunTheme.ink) + Spacer() + if model.appLanguage == language { + Image(systemName: "checkmark") + .font(.system(size: 18, weight: .medium)) + .foregroundStyle(BitFunTheme.ink) + } + } + .padding(.horizontal, 16) + .frame(height: MobileDesignGeometry.selectionRowHeight) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + } + .padding(.top, 8) + .padding(.bottom, 28) + + Spacer(minLength: 0) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .background(BitFunTheme.card) + .clipShape(RoundedRectangle(cornerRadius: MobileDesignGeometry.selectionTopRadius)) + } +} +private struct SettingsGroup: View { + let title: String + @ViewBuilder let content: () -> Content + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text(MobileLocalization.text(title)) + .font(.system(size: 18, weight: .bold)) + .foregroundStyle(BitFunTheme.muted) + .padding(.leading, 12) + SettingsCard(content: content) + } + .padding(.bottom, 24) + } +} + +struct SettingsCard: View { + @ViewBuilder let content: () -> Content + + var body: some View { + BitFunModalCard( + radius: MobileDesignGeometry.settingsCompactCardRadius, + bordered: false, + content: content + ) + } +} + +private struct SettingsProfileRow: View { + let subtitle: String + + var body: some View { + HStack(spacing: 12) { + Image(systemName: "person.crop.circle") + .font(.system(size: 24, weight: .regular)) + .foregroundStyle(BitFunTheme.muted) + .frame(width: 34, height: 34) + VStack(alignment: .leading, spacing: 2) { + Text(MobileLocalization.text("个人资料")) + .font(.system(size: 16, weight: .medium)) + .foregroundStyle(BitFunTheme.ink) + Text(MobileLocalization.text(subtitle)) + .font(.system(size: 13)) + .foregroundStyle(BitFunTheme.muted) + .lineLimit(1) + } + Spacer(minLength: 8) + Image(systemName: "chevron.right") + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(BitFunTheme.muted.opacity(0.72)) + } + .padding(.horizontal, 18) + .frame(height: 64) + } +} + +private struct SettingsValueRow: View { + let icon: String? + let title: String + let value: String + var showsChevron: Bool = false + + var body: some View { + HStack(spacing: 14) { + if let icon { + Image(systemName: icon) + .font(.system(size: 20, weight: .regular)) + .foregroundStyle(BitFunTheme.muted) + .frame(width: 23, height: 23) + } + Text(MobileLocalization.text(title)) + .font(.system(size: 16, weight: .medium)) + .foregroundStyle(BitFunTheme.ink) + Spacer(minLength: 12) + Text(MobileLocalization.text(value)) + .font(.system(size: 15)) + .foregroundStyle(BitFunTheme.muted) + .lineLimit(1) + if showsChevron { + Image(systemName: "chevron.right") + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(BitFunTheme.muted.opacity(0.72)) + } + } + .padding(.horizontal, 18) + .frame(height: 52) + } +} diff --git a/src/apps/mobile/ios/BitFun/Features/Shell/MobileShellView.swift b/src/apps/mobile/ios/BitFun/Features/Shell/MobileShellView.swift index 2393ba8015..c480fdd4ba 100644 --- a/src/apps/mobile/ios/BitFun/Features/Shell/MobileShellView.swift +++ b/src/apps/mobile/ios/BitFun/Features/Shell/MobileShellView.swift @@ -1,4 +1,3 @@ -import AVFoundation import BitFunMobileCore import SwiftUI import UniformTypeIdentifiers @@ -379,406 +378,3 @@ struct MobileShellView: View { } } } - - - - -private struct LocalHomeView: View { - var body: some View { - BitFunTheme.page - } -} - -private struct RemoteHomeView: View { - @ObservedObject var model: MobileAppModel - - var body: some View { - ZStack(alignment: .topTrailing) { - VStack(spacing: 12) { - Spacer() - ZStack { - Image(systemName: "desktopcomputer") - .font(.system(size: 42, weight: .medium)) - .foregroundStyle(BitFunTheme.ink) - } - .frame(width: 74, height: 74) - .background(BitFunTheme.card) - .overlay(RoundedRectangle(cornerRadius: 24).stroke(BitFunTheme.line, lineWidth: 1)) - .clipShape(RoundedRectangle(cornerRadius: 24)) - Text(model.localized("连接桌面端")) - .font(.system(size: 18, weight: .bold)) - .foregroundStyle(BitFunTheme.ink) - Text(model.localized("扫描桌面端显示的二维码,开始远程处理任务。")) - .font(.system(size: 13)) - .foregroundStyle(BitFunTheme.muted) - .multilineTextAlignment(.center) - .lineSpacing(7) - .padding(.horizontal, 20) - Button(model.localized("连接")) { model.connectRemote() } - .font(.system(size: 15, weight: .medium)) - .foregroundStyle(.white) - .frame(width: 136, height: 44) - .background(BitFunTheme.accent) - .clipShape(Capsule()) - Spacer() - } - remoteSettingsButton - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .padding(.horizontal, 20) - .padding(.bottom, 48) - .background(BitFunTheme.page) - } - - private var remoteSettingsButton: some View { - Button { model.remoteControlSettingsOpen = true } label: { - Image(systemName: "gearshape") - .font(.system(size: 18, weight: .medium)) - .foregroundStyle(BitFunTheme.ink) - .frame(width: 44, height: 44) - .background(BitFunTheme.card) - .overlay(Circle().stroke(BitFunTheme.line, lineWidth: 1)) - .clipShape(Circle()) - } - .buttonStyle(.plain) - .accessibilityLabel(model.localized("远程控制设置")) - .padding(.top, 16).padding(.trailing, 16) - } -} - -private struct RemoteConnectedHomeView: View { - @ObservedObject var model: MobileAppModel - - var body: some View { - ZStack(alignment: .topTrailing) { - VStack(spacing: 14) { - Spacer() - Image(systemName: "desktopcomputer.and.macbook") - .font(.system(size: 34, weight: .medium)).foregroundStyle(BitFunTheme.muted) - Text(model.localized("桌面端已连接")) - .font(MobileDesignTypography.titleMedium.font).foregroundStyle(BitFunTheme.ink) - Text(model.localized("选择已有会话,或在当前工作区创建一个新会话。")) - .font(MobileDesignTypography.bodySmall.font).foregroundStyle(BitFunTheme.muted) - .multilineTextAlignment(.center) - Button { model.remoteCreateOpen = true } label: { - Label(model.localized("新建远程会话"), systemImage: "plus") - .font(MobileDesignTypography.labelMedium.font).foregroundStyle(.white) - .frame(minWidth: 176, minHeight: 44).background(BitFunTheme.accent).clipShape(Capsule()) - } - .buttonStyle(.plain) - Spacer() - } - Button { model.remoteControlSettingsOpen = true } label: { - Image(systemName: "gearshape") - .font(.system(size: 18, weight: .medium)) - .foregroundStyle(BitFunTheme.ink) - .frame(width: 44, height: 44) - .background(BitFunTheme.card) - .overlay(Circle().stroke(BitFunTheme.line, lineWidth: 1)) - .clipShape(Circle()) - } - .buttonStyle(.plain) - .accessibilityLabel(model.localized("远程控制设置")) - .padding(.top, 16).padding(.trailing, 16) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(BitFunTheme.page) - } -} - -private struct ConnectionStatusBar: View { - let phase: ConnectionPhase - var detail: String? - let onRetry: () -> Void - var body: some View { - HStack(spacing: 8) { - Circle().fill(phase == .reconnecting ? BitFunTheme.muted : BitFunTheme.red).frame(width: 8, height: 8) - Text(MobileLocalization.text(phase == .reconnecting ? "正在恢复连接" : "连接不可用")) - .font(.system(size: 13, weight: .medium)) - Text( - detail ?? MobileLocalization.text( - phase == .reconnecting ? "正在重新连接桌面端" : "请重新连接" - ) - ) - .font(.system(size: 12)) - .foregroundStyle(BitFunTheme.muted) - Spacer() - if phase == .disconnected { - Button(MobileLocalization.text("重试"), action: onRetry) - .font(.system(size: 13, weight: .semibold)) - .buttonStyle(.plain) - .foregroundStyle(BitFunTheme.accent) - } - } - .foregroundStyle(BitFunTheme.ink) - .padding(.horizontal, 18) - .frame(height: 48) - .background(BitFunTheme.soft) - } -} - -private struct SettingsView: View { - @ObservedObject var model: MobileAppModel - @Environment(\.dismiss) private var dismiss - @State private var accountOpen = false - - private var appVersion: String { - Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "1.0.0" - } - - private var selectedModelName: String { - model.modelOptions.first(where: \.selected)?.primaryLabel - ?? model.modelOptions.first?.primaryLabel - ?? model.localized("未配置") - } - - var body: some View { - ZStack(alignment: .topTrailing) { - ScrollView(showsIndicators: false) { - VStack(alignment: .leading, spacing: 0) { - Text(model.localized("设置")) - .font(.system(size: 28, weight: .bold)) - .foregroundStyle(BitFunTheme.ink) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.bottom, 30) - - Button { accountOpen = true } label: { - SettingsCard { - SettingsProfileRow( - subtitle: model.accountUser ?? model.localized("未登录") - ) - } - } - .buttonStyle(.plain) - .padding(.bottom, 24) - - SettingsGroup(title: "通用") { - VStack(spacing: 0) { - Button { model.languagePickerOpen = true } label: { - SettingsValueRow( - icon: "textformat", - title: "语言", - value: model.appLanguage.nativeName, - showsChevron: true - ) - } - .buttonStyle(.plain) - Divider().overlay(BitFunTheme.line).padding(.horizontal, 26) - Button { model.generalConfigOpen = true } label: { - SettingsValueRow( - icon: "square.grid.2x2", - title: "模型", - value: selectedModelName, - showsChevron: true - ) - } - .buttonStyle(.plain) - } - } - SettingsGroup(title: "关于") { - VStack(spacing: 0) { - SettingsValueRow( - icon: nil, - title: "产品", - value: "BitFun iOS版" - ) - Divider().overlay(BitFunTheme.line).padding(.horizontal, 26) - SettingsValueRow(icon: nil, title: "版本", value: appVersion) - } - } - } - .padding(.horizontal, 16) - .padding(.top, 64) - .padding(.bottom, 34) - } - - Button { dismiss() } label: { - Image(systemName: "xmark") - .font(.system(size: 18, weight: .regular)) - .foregroundStyle(BitFunTheme.ink) - .frame(width: 40, height: 40) - .background(BitFunTheme.card) - .clipShape(Circle()) - } - .buttonStyle(.plain) - .accessibilityLabel(model.localized("关闭")) - .padding(.top, 22) - .padding(.trailing, 18) - - if model.languagePickerOpen { - LanguagePickerSheet(model: model) - .transition(.move(edge: .trailing).combined(with: .opacity)) - } else if model.generalConfigOpen { - GeneralChatConfigSheet(model: model) - .transition(.move(edge: .trailing).combined(with: .opacity)) - } else if accountOpen { - AccountSettingsView(model: model, onClose: { accountOpen = false }) - .transition(.move(edge: .trailing).combined(with: .opacity)) - } - } - .background(BitFunTheme.page) - .animation(.easeInOut(duration: 0.2), value: model.languagePickerOpen) - .animation(.easeInOut(duration: 0.2), value: model.generalConfigOpen) - .animation(.easeInOut(duration: 0.2), value: accountOpen) - } -} - -private struct LanguagePickerSheet: View { - @ObservedObject var model: MobileAppModel - - var body: some View { - VStack(alignment: .leading, spacing: 0) { - BitFunSelectionHeader(title: "选择语言", onClose: { model.languagePickerOpen = false }) - Divider().overlay(BitFunTheme.line) - - VStack(spacing: 0) { - ForEach(MobileLanguage.allCases) { language in - Button { - model.setLanguage(language) - model.languagePickerOpen = false - } label: { - HStack { - Text(language.nativeName) - .font(.system(size: 16, weight: .medium)) - .foregroundStyle(BitFunTheme.ink) - Spacer() - if model.appLanguage == language { - Image(systemName: "checkmark") - .font(.system(size: 18, weight: .medium)) - .foregroundStyle(BitFunTheme.ink) - } - } - .padding(.horizontal, 16) - .frame(height: MobileDesignGeometry.selectionRowHeight) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - } - } - .padding(.top, 8) - .padding(.bottom, 28) - - Spacer(minLength: 0) - } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) - .background(BitFunTheme.card) - .clipShape(RoundedRectangle(cornerRadius: MobileDesignGeometry.selectionTopRadius)) - } -} - - - - - -private struct PermissionModeRow: View { - @ObservedObject var model: MobileAppModel - let mode: String - let title: String - let detail: String - - var body: some View { - Button { model.setRemotePermissionMode(mode) } label: { - HStack(spacing: 12) { - VStack(alignment: .leading, spacing: 3) { - Text(model.localized(title)).font(MobileDesignTypography.bodyMedium.font).foregroundStyle(BitFunTheme.ink) - Text(model.localized(detail)).font(MobileDesignTypography.labelSmall.font).foregroundStyle(BitFunTheme.muted) - } - Spacer() - if model.remotePermissionMode == mode { - Image(systemName: "checkmark.circle.fill").foregroundStyle(BitFunTheme.green) - } - } - .padding(.horizontal, 20).frame(minHeight: 62) - } - .buttonStyle(.plain).disabled(model.busy) - } -} - -private struct SettingsGroup: View { - let title: String - @ViewBuilder let content: () -> Content - - var body: some View { - VStack(alignment: .leading, spacing: 8) { - Text(MobileLocalization.text(title)) - .font(.system(size: 18, weight: .bold)) - .foregroundStyle(BitFunTheme.muted) - .padding(.leading, 12) - SettingsCard(content: content) - } - .padding(.bottom, 24) - } -} - -struct SettingsCard: View { - @ViewBuilder let content: () -> Content - - var body: some View { - BitFunModalCard( - radius: MobileDesignGeometry.settingsCompactCardRadius, - bordered: false, - content: content - ) - } -} - -private struct SettingsProfileRow: View { - let subtitle: String - - var body: some View { - HStack(spacing: 12) { - Image(systemName: "person.crop.circle") - .font(.system(size: 24, weight: .regular)) - .foregroundStyle(BitFunTheme.muted) - .frame(width: 34, height: 34) - VStack(alignment: .leading, spacing: 2) { - Text(MobileLocalization.text("个人资料")) - .font(.system(size: 16, weight: .medium)) - .foregroundStyle(BitFunTheme.ink) - Text(MobileLocalization.text(subtitle)) - .font(.system(size: 13)) - .foregroundStyle(BitFunTheme.muted) - .lineLimit(1) - } - Spacer(minLength: 8) - Image(systemName: "chevron.right") - .font(.system(size: 14, weight: .medium)) - .foregroundStyle(BitFunTheme.muted.opacity(0.72)) - } - .padding(.horizontal, 18) - .frame(height: 64) - } -} - -private struct SettingsValueRow: View { - let icon: String? - let title: String - let value: String - var showsChevron: Bool = false - - var body: some View { - HStack(spacing: 14) { - if let icon { - Image(systemName: icon) - .font(.system(size: 20, weight: .regular)) - .foregroundStyle(BitFunTheme.muted) - .frame(width: 23, height: 23) - } - Text(MobileLocalization.text(title)) - .font(.system(size: 16, weight: .medium)) - .foregroundStyle(BitFunTheme.ink) - Spacer(minLength: 12) - Text(MobileLocalization.text(value)) - .font(.system(size: 15)) - .foregroundStyle(BitFunTheme.muted) - .lineLimit(1) - if showsChevron { - Image(systemName: "chevron.right") - .font(.system(size: 14, weight: .medium)) - .foregroundStyle(BitFunTheme.muted.opacity(0.72)) - } - } - .padding(.horizontal, 18) - .frame(height: 52) - } -} diff --git a/src/apps/mobile/ios/BitFun/Infrastructure/MobileAppModel.swift b/src/apps/mobile/ios/BitFun/Infrastructure/MobileAppModel.swift index 85ddc64eb7..ec27db0767 100644 --- a/src/apps/mobile/ios/BitFun/Infrastructure/MobileAppModel.swift +++ b/src/apps/mobile/ios/BitFun/Infrastructure/MobileAppModel.swift @@ -2,291 +2,6 @@ import Foundation import SwiftUI import BitFunMobileCore -enum ConnectionPhase { - case connected - case reconnecting - case disconnected -} - -enum MobileSurface: String { - case local - case remote -} - -struct ChatMessage: Identifiable, Equatable { - let id: UUID - let role: Role - let text: String - - enum Role { case user, assistant } -} - -struct MobileTimelineImage: Identifiable, Equatable { - var id: String { dataURL } - let name: String - let dataURL: String -} - -struct MobileTimelineOption: Identifiable, Equatable { - let label: String - let description: String? - var id: String { label } -} - -struct MobileTimelineQuestion: Identifiable, Equatable { - let index: Int - let header: String - let question: String - let options: [MobileTimelineOption] - let multiSelect: Bool - var id: Int { index } -} - -struct MobileTimelineTool: Identifiable, Equatable { - let id: String - let name: String - let phase: String - let kind: String - let operation: String - let target: String - let filePath: String - let fileLabel: String - let input: String - let output: String - let question: String? - let questions: [MobileTimelineQuestion] - let actions: Set -} - -indirect enum MobileTimelineBlock: Identifiable, Equatable { - case text(id: String, text: String, streaming: Bool) - case thinking(id: String, text: String, streaming: Bool) - case tools(id: String, tools: [MobileTimelineTool]) - case subagent( - id: String, - title: String, - running: Bool, - text: String, - children: [MobileTimelineBlock] - ) - - var id: String { - switch self { - case let .text(id, _, _), let .thinking(id, _, _), let .tools(id, _), - let .subagent(id, _, _, _, _): - return id - } - } -} - -struct MobileConversationRow: Identifiable, Equatable { - let id: String - let kind: String - let text: String - let thinking: String? - let images: [MobileTimelineImage] - let tools: [MobileTimelineTool] - let blocks: [MobileTimelineBlock] - let streaming: Bool - let typing: Bool - let pending: Bool - let showRetry: Bool -} - -enum MobileFilePreviewFailureKind: String { - case notFound, unavailable, accessDenied, tooLarge, connection, loadFailed -} - -struct MobileFilePreview: Identifiable, Equatable { - let id: String - let sessionID: String - let controlTargetEpoch: Int32 - let name: String - let content: String - let mimeType: String - let imageData: Data? - let truncated: Bool - let loadedBytes: Int64 - let sizeBytes: Int64 - let markdown: Bool - let lineStart: Int32 - let failure: String? - let failureKind: MobileFilePreviewFailureKind? - let retryable: Bool - let unsupported: Bool - - init( - id: String, - sessionID: String = "", - controlTargetEpoch: Int32 = 0, - name: String, - content: String, - mimeType: String, - imageData: Data?, - truncated: Bool, - loadedBytes: Int64 = 0, - sizeBytes: Int64 = 0, - markdown: Bool = false, - lineStart: Int32 = 0, - failure: String?, - failureKind: MobileFilePreviewFailureKind? = nil, - retryable: Bool = false, - unsupported: Bool = false - ) { - self.id = id - self.sessionID = sessionID - self.controlTargetEpoch = controlTargetEpoch - self.name = name - self.content = content - self.mimeType = mimeType - self.imageData = imageData - self.truncated = truncated - self.loadedBytes = loadedBytes - self.sizeBytes = sizeBytes - self.markdown = markdown - self.lineStart = lineStart - self.failure = failure - self.failureKind = failureKind - self.retryable = retryable - self.unsupported = unsupported - } -} - -struct MobilePendingDownload: Identifiable, Equatable { - var id: String { reference } - let reference: String - let remotePath: String - let name: String - let mimeType: String - let data: Data - let sessionID: String - let controlTargetEpoch: Int32 - - init(reference: String, remotePath: String, name: String, mimeType: String, data: Data, - sessionID: String = "", controlTargetEpoch: Int32 = 0) { - self.reference = reference - self.remotePath = remotePath - self.name = name - self.mimeType = mimeType - self.data = data - self.sessionID = sessionID - self.controlTargetEpoch = controlTargetEpoch - } -} - -struct ChatSession: Identifiable, Equatable { - let id: String - var title: String - var updatedLabel: String - var pinned: Bool = false - var status: String = "active" - var agentType: String = "general_chat" - var workspacePath: String? - var workspaceName: String? - var deviceKey: String? = nil - var createdAt: String = "" - var messageCount: Int = 0 -} - -struct CommittedRemoteCreate { - let targetKey: String - let epoch: UInt64 - let session: ChatSession - /// First authoritative Ready revision guaranteed to contain this commit. - let minimumAuthorityRevision: Int64 -} - -struct PendingDirectoryRemoteDraft { - let targetKey: String - let rawDeviceKey: String - let workspacePath: String - let normalizedWorkspacePath: String - let epoch: UInt64 - var selectionRequested: Bool -} - -struct MobileAccountDevice: Identifiable, Equatable { - let id: String - let name: String - let online: Bool - let selected: Bool -} - -struct MobileDeviceDirectoryEntry: Identifiable, Equatable { - let id: String - let name: String - let online: Bool - let expanded: Bool - let status: String - let error: String? - let workspaces: [MobileWorkspaceGroup] - let sessions: [ChatSession] -} - -struct MobileWorkspaceGroup: Identifiable, Equatable { - var id: String { (deviceKey ?? "") + ":" + path } - let path: String - let name: String - let selected: Bool - let sessions: [ChatSession] - var deviceKey: String? = nil -} - -enum MobileSessionListSectionKind: Equatable { - case chat - case project - case today - case yesterday - case earlier -} - -struct MobileSessionListSectionProjection: Identifiable { - let id: String - let kind: MobileSessionListSectionKind - let path: String - let name: String - let sessions: [ChatSession] -} - -struct MobileSessionWorkspaceOption: Identifiable { - var id: String { path } - let path: String - let name: String -} - -struct MobileAssistantOption: Identifiable, Equatable { - var id: String { path } - let path: String - let name: String -} - -struct ComposerAttachment: Identifiable, Equatable { - let id: String - let data: Data - let mimeType: String - - var dataURL: String { - "data:\(mimeType);base64,\(data.base64EncodedString())" - } -} - -struct ComposerModelOption: Identifiable, Equatable { - let id: String - let primaryLabel: String - let secondaryLabel: String - let source: String - let selected: Bool -} - -enum MobileDownloadPhase { - case idle - case preparing - case downloading - case saving - case saved - case failed -} - @MainActor final class MobileAppModel: ObservableObject { @Published var appLanguage: MobileLanguage = MobileLocalization.restoredLanguage() @@ -442,194 +157,6 @@ final class MobileAppModel: ObservableObject { self.localDeviceID = adapter.deviceID } - static let preview: MobileAppModel = { - let first = ChatSession(id: UUID().uuidString, title: "你好", updatedLabel: "刚刚") - return MobileAppModel( - sessions: [first], - selectedSessionID: first.id, - messages: [ - ChatMessage(id: UUID(), role: .user, text: "你好"), - ChatMessage(id: UUID(), role: .assistant, text: "这是 BitFun 的移动端会话界面。你可以从手机连接桌面端,查看工作区、会话和 Agent 的执行状态。") - ] - ) - }() - - static var launchConfigured: MobileAppModel { - let model = preview - let arguments = ProcessInfo.processInfo.arguments - if arguments.contains("--english") { - model.setLanguage(.english) - } else if arguments.contains("--simplified-chinese") { - model.setLanguage(.simplifiedChinese) - } - if arguments.contains("--remote") { - model.surface = .remote - } - if arguments.contains("--connected") { - model.configureConnectedPreview() - } - if arguments.contains("--remote-chat-section") { - if !model.remoteConnected { model.configureConnectedPreview() } - model.remoteSessions.append( - ChatSession( - id: "preview-remote-chat", - title: "移动端体验对齐", - updatedLabel: "刚刚", - status: "idle", - agentType: "Claw", - workspacePath: nil, - workspaceName: nil - ) - ) - model.rebuildRemoteWorkspaceGroups() - } - if arguments.contains("--remote-view-settings") { - if !model.remoteConnected { model.configureConnectedPreview() } - model.remoteViewSettingsOpen = true - } - if arguments.contains("--remote-view-density") { - if !model.remoteConnected { model.configureConnectedPreview() } - let now = ISO8601DateFormatter().string(from: Date()) - for index in model.remoteSessions.indices { - model.remoteSessions[index].updatedLabel = now - } - model.remoteGroupMode = "TIME" - model.remoteShowWorkspaceMetadata = true - model.remoteShowUpdatedMetadata = true - model.remoteShowStatusMetadata = true - model.rebuildRemoteWorkspaceGroups() - } - if arguments.contains("--timeline-preview") { - model.configureTimelinePreview() - } - if arguments.contains("--file-preview") { - model.filePreview = MobileFilePreview( - id: "src/main.rs", - name: "main.rs", - content: "// Remote workspace preview\nfn main() {\n println!(\"Hello from BitFun\");\n}\n", - mimeType: "text/x-rust", - imageData: nil, - truncated: false, - failure: nil - ) - } - if arguments.contains("--download-preview") { - model.pendingDownload = MobilePendingDownload( - reference: "computer://src/main.rs", - remotePath: "src/main.rs", - name: "main.rs", - mimeType: "text/x-rust", - data: Data("fn main() {}\n".utf8) - ) - model.downloadTargetPath = "src/main.rs" - model.downloadPhase = .saving - model.downloadStatusText = model.localized("正在保存") - model.downloadExporterOpen = true - } - if let relay = arguments.value(after: "--relay-url"), - let username = arguments.value(after: "--username"), - let password = arguments.value(after: "--password") { - model.loginAccount(relayURL: relay, username: username, password: password) - } - if arguments.contains("--drawer") { - model.drawerOpen = true - } - if arguments.contains("--settings") { - model.settingsOpen = true - } - if arguments.contains("--remote-settings") { - model.surface = .remote - model.remoteControlSettingsOpen = true - } - if arguments.contains("--model-settings") { - model.settingsOpen = true - model.generalConfigOpen = true - } - if arguments.contains("--composer-model-picker") || - ProcessInfo.processInfo.environment["BITFUN_COMPOSER_MODEL_PICKER"] == "1" { - model.composerModelPickerPreview = true - model.localSessionSelected = true - model.draft = "\n" - model.modelOptions = [ - ComposerModelOption( - id: "preview-codex", - primaryLabel: "GPT-5.6 Codex", - secondaryLabel: "BitFun 账号", - source: "ACCOUNT", - selected: true - ), - ComposerModelOption( - id: "preview-local", - primaryLabel: "本机自定义模型", - secondaryLabel: "OpenAI 兼容服务", - source: "LOCAL", - selected: false - ), - ] - } - if arguments.contains("--pairing") || arguments.contains("--pairing-manual") || - arguments.contains("--pairing-account") { - model.pairingSheetOpen = true - } - if arguments.contains("--remote-create") || arguments.contains("--remote-create-workspace-picker") { - model.remoteCreatePreview = true - if !model.remoteConnected { model.configureConnectedPreview() } - model.remoteCreateOpen = true - } - if arguments.contains("--remote-home-preview") { - model.remoteSessionSelected = false - model.selectedSessionID = "" - model.timelineRows = [] - model.messages = [] - } - if arguments.contains("--local-actions") { - model.localActionPreview = true - model.surface = .local - model.localSessionSelected = true - model.remoteSessionSelected = false - if let localSession = model.sessions.first { - model.selectedSessionID = localSession.id - } - } - if arguments.contains("--account-login") { - model.accountLoginPreview = true - model.accountUser = nil - model.accountDeviceName = nil - model.accountSelectedDeviceID = nil - model.accountDevices = [] - model.accountDeviceCount = 0 - model.coreErrorMessage = nil - model.settingsOpen = false - model.accountSheetOpen = true - } - if arguments.contains("--account-profile") { - model.accountLoginPreview = true - model.accountUser = "bitfun-user" - model.accountUserID = "user-preview-7A31" - model.accountDevices = [ - MobileAccountDevice( - id: "desktop-preview", - name: "Studio Mac", - online: true, - selected: true - ), - MobileAccountDevice( - id: "desktop-offline-preview", - name: "Office PC", - online: false, - selected: false - ), - ] - model.accountDeviceName = "Studio Mac" - model.accountSelectedDeviceID = "desktop-preview" - model.accountDeviceCount = model.accountDevices.count - model.coreErrorMessage = nil - model.settingsOpen = false - model.accountSheetOpen = true - } - return model - } - var selectedSession: ChatSession? { guard (surface == .local && localSessionSelected) || (surface == .remote && remoteSessionSelected) else { return nil @@ -641,8 +168,6 @@ final class MobileAppModel: ObservableObject { surface == .local ? sessions : remoteSessions } - - func switchSurface(_ next: MobileSurface) { surface = next drawerOpen = false @@ -748,30 +273,6 @@ final class MobileAppModel: ObservableObject { drawerOpen = false } - - - - - - - - - - - - - - - - - - - - - - - - func showSessionDetails(_ session: ChatSession) { sessionDetails = session } @@ -780,120 +281,6 @@ final class MobileAppModel: ObservableObject { sessionDetails = nil } - - private func configureConnectedPreview() { - directPairingConnected = true - surface = .remote - remoteConnected = true - connectionPhase = .connected - remoteSessionSelected = true - accountUser = "preview@bitfun" - accountDeviceName = "DESKTOP-KM3L4UI" - accountSelectedDeviceID = "preview-desktop" - directoryFixturePreview = true - accountDevices = [ - MobileAccountDevice(id: "preview-desktop", name: "DESKTOP-KM3L4UI", online: true, selected: true), - MobileAccountDevice(id: "preview-mac", name: "Studio Mac", online: true, selected: false), - MobileAccountDevice(id: "preview-offline", name: "Office PC", online: false, selected: false) - ] - accountDeviceCount = accountDevices.count - let session = ChatSession( - id: UUID().uuidString, - title: "你好", - updatedLabel: "刚刚", - agentType: "code", - workspacePath: "/workspace/BitFun", - workspaceName: "BitFun" - ) - remoteSessions = [session] - let extraSessions = (1...5).map { index in - ChatSession( - id: "preview-session-\(index)", title: "Review session \(index)", updatedLabel: "2026-01-01T00:00:00Z", - status: index == 1 ? "running" : "idle", agentType: "code", - workspacePath: "/workspace/BitFun", workspaceName: "BitFun", deviceKey: "preview-desktop" - ) - } - remoteSessions.append(contentsOf: extraSessions) - let cachedSession = ChatSession( - id: "preview-offline-session", title: "Cached offline session", updatedLabel: "2026-01-01T00:00:00Z", - status: "idle", agentType: "code", workspacePath: "/office/project", workspaceName: "Office project", deviceKey: "preview-offline" - ) - let failedSession = ChatSession( - id: "preview-failed-session", title: "Cached failed session", updatedLabel: "2026-01-01T00:00:00Z", - status: "idle", agentType: "code", workspacePath: "/staging/project", workspaceName: "Staging", deviceKey: "preview-mac" - ) - remoteSessions.append(contentsOf: [cachedSession, failedSession]) - let previewWorkspace = MobileWorkspaceGroup(path: "/workspace/BitFun", name: "BitFun", selected: true, sessions: remoteSessions.filter { $0.deviceKey == "preview-desktop" }, deviceKey: "preview-desktop") - let offlineWorkspace = MobileWorkspaceGroup(path: "/office/project", name: "Office project", selected: false, sessions: [cachedSession], deviceKey: "preview-offline") - let failedWorkspace = MobileWorkspaceGroup(path: "/staging/project", name: "Staging", selected: false, sessions: [failedSession], deviceKey: "preview-mac") - deviceDirectory = [ - MobileDeviceDirectoryEntry(id: "preview-desktop", name: "DESKTOP-KM3L4UI", online: true, expanded: true, status: "READY", error: nil, workspaces: [previewWorkspace], sessions: previewWorkspace.sessions), - MobileDeviceDirectoryEntry(id: "preview-mac", name: "Studio Mac", online: true, expanded: true, status: "FAILED", error: "REMOTE_UNAVAILABLE", workspaces: [failedWorkspace], sessions: [failedSession]), - MobileDeviceDirectoryEntry(id: "preview-offline", name: "Office PC", online: false, expanded: false, status: "READY", error: nil, workspaces: [offlineWorkspace], sessions: [cachedSession]) - ] - workspaceCatalog = [(path: "/workspace/BitFun", name: "BitFun", selected: true)] - remoteAssistants = [ - MobileAssistantOption(path: "/workspace/BitFun/.bitfun/assistants/review", name: "代码审查助手") - ] - remoteHasMore = true - rebuildRemoteWorkspaceGroups() - selectedSessionID = session.id - messages = [ - ChatMessage(id: UUID(), role: .user, text: "你好"), - ChatMessage(id: UUID(), role: .assistant, text: "这是 BitFun 的远程会话预览。"), - ] - timelineRows = messages.map(Self.simpleTimelineRow) - } - - private func configureTimelinePreview() { - configureConnectedPreview() - let userID = UUID().uuidString - let assistantID = UUID().uuidString - let readOne = MobileTimelineTool( - id: "preview-read-1", name: "Read", phase: "COMPLETED", kind: "DOCUMENT", - operation: "READ_FILE", target: "main.rs", filePath: "computer://src/main.rs", - fileLabel: "main.rs", input: "src/main.rs", output: "读取完成", question: nil, questions: [], actions: [] - ) - let readTwo = MobileTimelineTool( - id: "preview-read-2", name: "Search", phase: "COMPLETED", kind: "SEARCH", - operation: "SEARCH_CODE", target: "MobileShellView", filePath: "", fileLabel: "", - input: "MobileShellView", output: "找到 4 处结果", question: nil, questions: [], actions: [] - ) - let approval = MobileTimelineTool( - id: "preview-approval", name: "Bash", phase: "PENDING_CONFIRMATION", kind: "COMMAND", - operation: "RUN_COMMAND", target: "pnpm test", filePath: "", fileLabel: "", - input: "pnpm test", output: "", question: nil, questions: [], actions: ["APPROVE", "REJECT"] - ) - let question = MobileTimelineTool( - id: "preview-question", name: "AskUserQuestion", phase: "PENDING_CONFIRMATION", kind: "QUESTION", - operation: "ASK_CONFIRMATION", target: "", filePath: "", fileLabel: "", input: "", output: "", - question: "要同时运行远程场景回归吗?", questions: [], actions: ["ANSWER"] - ) - timelineRows = [ - MobileConversationRow( - id: userID, kind: "USER", text: "请检查移动端的消息、工具和文件交互。", thinking: nil, - images: [], tools: [], blocks: [], streaming: false, typing: false, pending: false, showRetry: false - ), - MobileConversationRow( - id: assistantID, kind: "ASSISTANT", text: "", thinking: nil, images: [], tools: [], - blocks: [ - .thinking(id: "preview-thinking", text: "先对照 HarmonyOS 的消息顺序与工具状态,再核对 Android 的交互策略。", streaming: false), - .text( - id: "preview-text", - text: "## 检查结果\n\n消息按共享投影顺序显示,文件可直接打开:[main.rs](computer://src/main.rs)。\n\n- Markdown 与代码块\n- 思考过程与子任务\n- 工具确认、提问和取消\n\n```swift\nlet parity = true\n```", - streaming: false - ), - .tools(id: "preview-tools", tools: [readOne, readTwo, approval, question]), - ], - streaming: false, typing: false, pending: false, showRetry: false - ), - ] - messages = [ - ChatMessage(id: UUID(), role: .user, text: "请检查移动端的消息、工具和文件交互。"), - ChatMessage(id: UUID(), role: .assistant, text: "检查结果"), - ] - } - func submitPairing(url: String) { prepareProjectionForPairingSubmission() pairingIntentInFlight = true @@ -983,11 +370,6 @@ final class MobileAppModel: ObservableObject { connectionPhase = .reconnecting } - - - - - func stopSending() { if surface == .remote { guard remoteSessionSelected else { return } @@ -997,11 +379,6 @@ final class MobileAppModel: ObservableObject { } } - - - - - func retryMessage(_ text: String) { let normalized = text.trimmingCharacters(in: .whitespacesAndNewlines) guard !normalized.isEmpty, !busy, !isSending else { return } @@ -1016,11 +393,6 @@ final class MobileAppModel: ObservableObject { } } - - - - - func renameSelectedSession(_ title: String) { let normalized = title.trimmingCharacters(in: .whitespacesAndNewlines) guard !normalized.isEmpty, selectedSession != nil else { return } @@ -1050,7 +422,6 @@ final class MobileAppModel: ObservableObject { localSessionSelected = false } - func showUploadedFiles() { let count = composerImages.count showToast( @@ -1069,7 +440,6 @@ final class MobileAppModel: ObservableObject { } } - private func apply(pairingState state: PairingUiState, generation: UInt64) { guard !localActionPreview, generation == pairingGeneration, remoteExpectedDeviceKey == nil || remoteExpectedDeviceKey == "pairing" || pairingIntentInFlight else { return } @@ -1077,7 +447,7 @@ final class MobileAppModel: ObservableObject { if let failed = state as? PairingUiStateFailed { pairingBusy = false pairingIntentInFlight = false - pairingError = pairingErrorMessage(failed.failure) + pairingError = PairingFailureCopy.message(failed.failure, localized: localized) let healthyConnected: Bool switch connectionPhase { case .connected: healthyConnected = remoteConnected @@ -1151,52 +521,4 @@ final class MobileAppModel: ObservableObject { pairingSheetOpen = false } } - - - - - - - - - - - - - private func pairingErrorMessage(_ failure: PairingFailure) -> String { - if let remote = failure.remoteMessage?.trimmingCharacters(in: .whitespacesAndNewlines), !remote.isEmpty { - return remote - } - switch failure.reason.name { - case "PAIRING_LINK_EMPTY", "PAIRING_LINK_INCOMPLETE", "PAIRING_LINK_UNDECODABLE", "PAIRING_LINK_KEY_UNUSABLE": - return localized("连接链接无效,请重新扫描或粘贴桌面端链接") - case "ACCOUNT_USERNAME_REQUIRED": - return localized("请输入桌面端账号") - case "ACCOUNT_PASSWORD_REQUIRED": - return localized("请输入桌面端密码") - case "REJECTED", "DESKTOP_REJECTED": - return localized("桌面端拒绝了这次连接") - case "ROOM_NOT_FOUND": - return localized("找不到桌面端房间,请确认桌面端仍在等待连接") - case "RATE_LIMITED", "TOO_MANY_ATTEMPTS": - return localized("尝试次数过多,请稍后再试") - case "RELAY_UNAVAILABLE", "NETWORK_UNREACHABLE": - return localized("网络不可用,请检查手机与桌面端的网络") - case "TIMEOUT": - return localized("连接超时,请重新尝试") - case "PROTOCOL_MISMATCH": - return localized("桌面端版本不兼容,请升级后重试") - default: - return localized("连接失败,请检查桌面端链接") - } - } - -} - - -private extension Array where Element == String { - func value(after flag: String) -> String? { - guard let position = firstIndex(of: flag), position < self.index(before: endIndex) else { return nil } - return self[self.index(after: position)] - } } diff --git a/src/apps/mobile/ios/BitFun/Infrastructure/PairingFailureCopy.swift b/src/apps/mobile/ios/BitFun/Infrastructure/PairingFailureCopy.swift new file mode 100644 index 0000000000..0c710daa32 --- /dev/null +++ b/src/apps/mobile/ios/BitFun/Infrastructure/PairingFailureCopy.swift @@ -0,0 +1,31 @@ +import BitFunMobileCore + +enum PairingFailureCopy { + static func message(_ failure: PairingFailure, localized: (String) -> String) -> String { + if let remote = failure.remoteMessage?.trimmingCharacters(in: .whitespacesAndNewlines), !remote.isEmpty { + return remote + } + switch failure.reason.name { + case "PAIRING_LINK_EMPTY", "PAIRING_LINK_INCOMPLETE", "PAIRING_LINK_UNDECODABLE", "PAIRING_LINK_KEY_UNUSABLE": + return localized("连接链接无效,请重新扫描或粘贴桌面端链接") + case "ACCOUNT_USERNAME_REQUIRED": + return localized("请输入桌面端账号") + case "ACCOUNT_PASSWORD_REQUIRED": + return localized("请输入桌面端密码") + case "REJECTED", "DESKTOP_REJECTED": + return localized("桌面端拒绝了这次连接") + case "ROOM_NOT_FOUND": + return localized("找不到桌面端房间,请确认桌面端仍在等待连接") + case "RATE_LIMITED", "TOO_MANY_ATTEMPTS": + return localized("尝试次数过多,请稍后再试") + case "RELAY_UNAVAILABLE", "NETWORK_UNREACHABLE": + return localized("网络不可用,请检查手机与桌面端的网络") + case "TIMEOUT": + return localized("连接超时,请重新尝试") + case "PROTOCOL_MISMATCH": + return localized("桌面端版本不兼容,请升级后重试") + default: + return localized("连接失败,请检查桌面端链接") + } + } +} diff --git a/src/apps/mobile/ios/BitFun/Infrastructure/Platform/QRCodeScannerView.swift b/src/apps/mobile/ios/BitFun/Infrastructure/Platform/QRCodeScannerView.swift new file mode 100644 index 0000000000..60345439c4 --- /dev/null +++ b/src/apps/mobile/ios/BitFun/Infrastructure/Platform/QRCodeScannerView.swift @@ -0,0 +1,80 @@ +import AVFoundation +import SwiftUI +import UIKit + +struct QRCodeScannerView: UIViewControllerRepresentable { + let onCode: (String) -> Void + + func makeUIViewController(context: Context) -> QRScannerController { + let controller = QRScannerController() + controller.onCode = onCode + return controller + } + + func updateUIViewController(_ uiViewController: QRScannerController, context: Context) {} +} + +final class QRScannerController: UIViewController, AVCaptureMetadataOutputObjectsDelegate { + private let session = AVCaptureSession() + private var previewLayer: AVCaptureVideoPreviewLayer? + var onCode: ((String) -> Void)? + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .black + let close = UIButton(type: .system) + close.setImage(UIImage(systemName: "xmark"), for: .normal) + close.tintColor = .white + close.backgroundColor = UIColor.black.withAlphaComponent(0.55) + close.layer.cornerRadius = 22 + close.addAction(UIAction { [weak self] _ in self?.dismiss(animated: true) }, for: .touchUpInside) + close.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(close) + NSLayoutConstraint.activate([ + close.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 16), + close.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20), + close.widthAnchor.constraint(equalToConstant: 44), + close.heightAnchor.constraint(equalToConstant: 44), + ]) + + guard AVCaptureDevice.authorizationStatus(for: .video) != .denied else { return } + AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in + guard granted else { return } + DispatchQueue.main.async { self?.configureCapture() } + } + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + previewLayer?.frame = view.bounds + } + + private func configureCapture() { + guard let device = AVCaptureDevice.default(for: .video), + let input = try? AVCaptureDeviceInput(device: device), + session.canAddInput(input) else { return } + let output = AVCaptureMetadataOutput() + guard session.canAddOutput(output) else { return } + session.addInput(input) + session.addOutput(output) + output.setMetadataObjectsDelegate(self, queue: .main) + output.metadataObjectTypes = [.qr] + let layer = AVCaptureVideoPreviewLayer(session: session) + layer.videoGravity = .resizeAspectFill + view.layer.insertSublayer(layer, at: 0) + previewLayer = layer + session.startRunning() + } + + func metadataOutput( + _ output: AVCaptureMetadataOutput, + didOutput metadataObjects: [AVMetadataObject], + from connection: AVCaptureConnection, + ) { + guard let value = (metadataObjects.first as? AVMetadataMachineReadableCodeObject)?.stringValue, + !value.isEmpty else { return } + session.stopRunning() + onCode?(value) + dismiss(animated: true) + } +} diff --git a/src/apps/mobile/ios/BitFun/Presentation/Models/MobilePresentationModels.swift b/src/apps/mobile/ios/BitFun/Presentation/Models/MobilePresentationModels.swift new file mode 100644 index 0000000000..860e8b3f47 --- /dev/null +++ b/src/apps/mobile/ios/BitFun/Presentation/Models/MobilePresentationModels.swift @@ -0,0 +1,286 @@ +import Foundation + +enum ConnectionPhase { + case connected + case reconnecting + case disconnected +} + +enum MobileSurface: String { + case local + case remote +} + +struct ChatMessage: Identifiable, Equatable { + let id: UUID + let role: Role + let text: String + + enum Role { case user, assistant } +} + +struct MobileTimelineImage: Identifiable, Equatable { + var id: String { dataURL } + let name: String + let dataURL: String +} + +struct MobileTimelineOption: Identifiable, Equatable { + let label: String + let description: String? + var id: String { label } +} + +struct MobileTimelineQuestion: Identifiable, Equatable { + let index: Int + let header: String + let question: String + let options: [MobileTimelineOption] + let multiSelect: Bool + var id: Int { index } +} + +struct MobileTimelineTool: Identifiable, Equatable { + let id: String + let name: String + let phase: String + let kind: String + let operation: String + let target: String + let filePath: String + let fileLabel: String + let input: String + let output: String + let question: String? + let questions: [MobileTimelineQuestion] + let actions: Set +} + +indirect enum MobileTimelineBlock: Identifiable, Equatable { + case text(id: String, text: String, streaming: Bool) + case thinking(id: String, text: String, streaming: Bool) + case tools(id: String, tools: [MobileTimelineTool]) + case subagent( + id: String, + title: String, + running: Bool, + text: String, + children: [MobileTimelineBlock] + ) + + var id: String { + switch self { + case let .text(id, _, _), let .thinking(id, _, _), let .tools(id, _), + let .subagent(id, _, _, _, _): + return id + } + } +} + +struct MobileConversationRow: Identifiable, Equatable { + let id: String + let kind: String + let text: String + let thinking: String? + let images: [MobileTimelineImage] + let tools: [MobileTimelineTool] + let blocks: [MobileTimelineBlock] + let streaming: Bool + let typing: Bool + let pending: Bool + let showRetry: Bool +} + +enum MobileFilePreviewFailureKind: String { + case notFound, unavailable, accessDenied, tooLarge, connection, loadFailed +} + +struct MobileFilePreview: Identifiable, Equatable { + let id: String + let sessionID: String + let controlTargetEpoch: Int32 + let name: String + let content: String + let mimeType: String + let imageData: Data? + let truncated: Bool + let loadedBytes: Int64 + let sizeBytes: Int64 + let markdown: Bool + let lineStart: Int32 + let failure: String? + let failureKind: MobileFilePreviewFailureKind? + let retryable: Bool + let unsupported: Bool + + init( + id: String, + sessionID: String = "", + controlTargetEpoch: Int32 = 0, + name: String, + content: String, + mimeType: String, + imageData: Data?, + truncated: Bool, + loadedBytes: Int64 = 0, + sizeBytes: Int64 = 0, + markdown: Bool = false, + lineStart: Int32 = 0, + failure: String?, + failureKind: MobileFilePreviewFailureKind? = nil, + retryable: Bool = false, + unsupported: Bool = false + ) { + self.id = id + self.sessionID = sessionID + self.controlTargetEpoch = controlTargetEpoch + self.name = name + self.content = content + self.mimeType = mimeType + self.imageData = imageData + self.truncated = truncated + self.loadedBytes = loadedBytes + self.sizeBytes = sizeBytes + self.markdown = markdown + self.lineStart = lineStart + self.failure = failure + self.failureKind = failureKind + self.retryable = retryable + self.unsupported = unsupported + } +} + +struct MobilePendingDownload: Identifiable, Equatable { + var id: String { reference } + let reference: String + let remotePath: String + let name: String + let mimeType: String + let data: Data + let sessionID: String + let controlTargetEpoch: Int32 + + init(reference: String, remotePath: String, name: String, mimeType: String, data: Data, + sessionID: String = "", controlTargetEpoch: Int32 = 0) { + self.reference = reference + self.remotePath = remotePath + self.name = name + self.mimeType = mimeType + self.data = data + self.sessionID = sessionID + self.controlTargetEpoch = controlTargetEpoch + } +} + +struct ChatSession: Identifiable, Equatable { + let id: String + var title: String + var updatedLabel: String + var pinned: Bool = false + var status: String = "active" + var agentType: String = "general_chat" + var workspacePath: String? + var workspaceName: String? + var deviceKey: String? = nil + var createdAt: String = "" + var messageCount: Int = 0 +} + +struct CommittedRemoteCreate { + let targetKey: String + let epoch: UInt64 + let session: ChatSession + /// First authoritative Ready revision guaranteed to contain this commit. + let minimumAuthorityRevision: Int64 +} + +struct PendingDirectoryRemoteDraft { + let targetKey: String + let rawDeviceKey: String + let workspacePath: String + let normalizedWorkspacePath: String + let epoch: UInt64 + var selectionRequested: Bool +} + +struct MobileAccountDevice: Identifiable, Equatable { + let id: String + let name: String + let online: Bool + let selected: Bool +} + +struct MobileDeviceDirectoryEntry: Identifiable, Equatable { + let id: String + let name: String + let online: Bool + let expanded: Bool + let status: String + let error: String? + let workspaces: [MobileWorkspaceGroup] + let sessions: [ChatSession] +} + +struct MobileWorkspaceGroup: Identifiable, Equatable { + var id: String { (deviceKey ?? "") + ":" + path } + let path: String + let name: String + let selected: Bool + let sessions: [ChatSession] + var deviceKey: String? = nil +} + +enum MobileSessionListSectionKind: Equatable { + case chat + case project + case today + case yesterday + case earlier +} + +struct MobileSessionListSectionProjection: Identifiable { + let id: String + let kind: MobileSessionListSectionKind + let path: String + let name: String + let sessions: [ChatSession] +} + +struct MobileSessionWorkspaceOption: Identifiable { + var id: String { path } + let path: String + let name: String +} + +struct MobileAssistantOption: Identifiable, Equatable { + var id: String { path } + let path: String + let name: String +} + +struct ComposerAttachment: Identifiable, Equatable { + let id: String + let data: Data + let mimeType: String + + var dataURL: String { + "data:\(mimeType);base64,\(data.base64EncodedString())" + } +} + +struct ComposerModelOption: Identifiable, Equatable { + let id: String + let primaryLabel: String + let secondaryLabel: String + let source: String + let selected: Bool +} + +enum MobileDownloadPhase { + case idle + case preparing + case downloading + case saving + case saved + case failed +} diff --git a/src/apps/mobile/ios/README.md b/src/apps/mobile/ios/README.md index 61f28c55a7..136ee6fd2d 100644 --- a/src/apps/mobile/ios/README.md +++ b/src/apps/mobile/ios/README.md @@ -8,10 +8,15 @@ floating composer with 52pt collapsed height. ## Project layout -- `BitFun/App/`: lifecycle and composition root. -- `BitFun/Features/Chat/`: header, timeline bubbles, and composer. -- `BitFun/Features/Shell/`: theme tokens, drawer, settings, and shell layout. -- `BitFun/Infrastructure/`: observable state and the platform seam. +- `BitFun/App/`: lifecycle, launch configuration, and composition root. +- `BitFun/Features/Chat/`: conversation home, header, timeline bubbles, and composer. +- `BitFun/Features/Remote/`: remote conversation home surfaces. +- `BitFun/Features/Settings/`: app settings composition and reusable settings cards. +- `BitFun/Features/Pairing/`: pairing sheet flow. +- `BitFun/Features/Account/`: account settings and device rows. +- `BitFun/Features/Shell/`: theme tokens, drawer, shell layout, and remote supporting surfaces. +- `BitFun/Infrastructure/`: observable state, failure copy, and platform adapters. +- `BitFun/Presentation/Models/`: SwiftUI-facing presentation DTOs. - `BitFun/Resources.xcassets/`: app icon and future native assets. ## Build and run