From d4e96d430e887d9f875bf6967dd9e51c7a7f4221 Mon Sep 17 00:00:00 2001 From: wgqqqqq Date: Mon, 28 Sep 2026 15:01:18 +0800 Subject: [PATCH] fix(mobile): align remote session lifecycle across platforms --- .../android/app/src/main/AndroidManifest.xml | 7 + .../com/openbitfun/mobile/app/MainActivity.kt | 6 + .../mobile/app/platform/WindowMetrics.kt | 22 +- .../mobile/app/ui/account/AccountScreen.kt | 5 +- .../mobile/app/ui/chat/ConversationView.kt | 4 +- .../mobile/app/ui/chat/tool/ToolStatusList.kt | 80 +++++++ .../app/ui/remote/CreateSessionScreen.kt | 10 +- .../mobile/app/ui/remote/PairingScreen.kt | 8 +- .../mobile/app/ui/shell/MobileScreen.kt | 34 ++- .../mobile/app/viewmodel/AccountViewModel.kt | 4 + .../app/src/main/res/values-zh/strings.xml | 2 + .../app/src/main/res/values/strings.xml | 2 + .../mobile/docs/remote-session-lifecycle.md | 38 ++++ src/apps/mobile/harmonyos/build-profile.json5 | 15 ++ .../components/RemoteConnectionPanel.ets | 62 ++++++ .../components/SidebarWorkspaceSection.ets | 2 +- .../ets/pages/components/ToolStatusList.ets | 70 ++++++ .../components/WorkspaceTerminalView.ets | 2 +- .../pages/components/WorkspaceToolsPanel.ets | 202 ++++++++++++------ .../runtime/AppRootRuntimeComposition.ets | 4 +- .../main/ets/services/CloudAccountClient.ets | 14 +- .../harmonyos/entry/src/main/module.json5 | 15 ++ .../ios/OpenBitFun/App/OpenBitFunApp.swift | 7 + .../Features/Chat/ChatTimelineView.swift | 88 ++++++++ src/apps/mobile/ios/OpenBitFun/Info.plist | 13 ++ .../MobileAppModel+Account.swift | 14 +- .../MobileAppModel+RemoteSession.swift | 59 ++++- .../Infrastructure/MobileAppModel.swift | 4 + .../Infrastructure/MobileCoreAdapter.swift | 4 + .../core/feature/account/AccountStore.kt | 30 ++- .../feature/session/RemoteSessionStore.kt | 47 +++- .../core/feature/account/AccountStoreTest.kt | 15 ++ .../feature/session/RemoteSessionStoreTest.kt | 20 ++ 33 files changed, 819 insertions(+), 90 deletions(-) create mode 100644 src/apps/mobile/docs/remote-session-lifecycle.md create mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteConnectionPanel.ets diff --git a/src/apps/mobile/android/app/src/main/AndroidManifest.xml b/src/apps/mobile/android/app/src/main/AndroidManifest.xml index 6e6f48f512..1b8fb00db7 100644 --- a/src/apps/mobile/android/app/src/main/AndroidManifest.xml +++ b/src/apps/mobile/android/app/src/main/AndroidManifest.xml @@ -19,11 +19,18 @@ + + + + + + diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/MainActivity.kt b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/MainActivity.kt index 9213866ec3..268df5d878 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/MainActivity.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/MainActivity.kt @@ -91,6 +91,12 @@ class MainActivity : ComponentActivity() { if (!intent.getBooleanExtra(DESIGN_PREVIEW_EXTRA, false)) accountModel().setBackground(false) } + override fun onNewIntent(intent: android.content.Intent) { + super.onNewIntent(intent) + setIntent(intent) + accountModel().notifyAuthorizationCallback() + } + override fun onStop() { showStartupBrand = false showColdStart = false diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/platform/WindowMetrics.kt b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/platform/WindowMetrics.kt index 870b212661..bbad7443ee 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/platform/WindowMetrics.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/platform/WindowMetrics.kt @@ -103,6 +103,13 @@ internal fun rememberWindowMetrics(): WindowMetrics { val hasHingeSensor = remember(context) { context.packageManager.hasSystemFeature(FEATURE_SENSOR_HINGE_ANGLE) } + //卓易通在鸿蒙上会向 Android 兼容层暴露一个“垂直折痕”,但它不是 + //Android WindowManager 的真实折叠区域。把它当成铰链会把普通宽屏窗口 + //错误拆成左右两套页面。通过鸿蒙系统文件管理器包识别兼容层;普通 + //Android 平板和真实 Android 折叠屏不会安装这个包,因此保持原行为。 + val isHarmonyCompatibilityLayer = remember(context) { + context.packageManager.hasPackage("com.huawei.hmos.filemanager") + } val activity = remember(context) { context.findActivity() } // No activity means no window to track — a @Preview, or a composable hosted @@ -154,9 +161,14 @@ internal fun rememberWindowMetrics(): WindowMetrics { } } } - val foldInfo by foldInfoFlow.collectAsStateWithLifecycle( + val reportedFoldInfo by foldInfoFlow.collectAsStateWithLifecycle( AndroidFoldInfo(emptyList(), emptyList(), reduceFoldFacts(false, emptyList())), ) + val foldInfo = if (isHarmonyCompatibilityLayer) { + AndroidFoldInfo(emptyList(), emptyList(), reduceFoldFacts(false, emptyList())) + } else { + reportedFoldInfo + } return WindowMetrics( widthDp = widthDp, @@ -176,6 +188,14 @@ internal fun rememberWindowMetrics(): WindowMetrics { private const val FEATURE_SENSOR_HINGE_ANGLE = "android.hardware.sensor.hinge_angle" +private fun android.content.pm.PackageManager.hasPackage(packageName: String): Boolean = + try { + getApplicationInfo(packageName, 0) + true + } catch (_: android.content.pm.PackageManager.NameNotFoundException) { + false + } + private fun Context.findActivity(): Activity? { var current: Context? = this while (current is ContextWrapper) { diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/account/AccountScreen.kt b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/account/AccountScreen.kt index e4799108a0..2811e6851f 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/account/AccountScreen.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/account/AccountScreen.kt @@ -109,7 +109,10 @@ internal fun AccountLoginPage( val authorizationUrl = (state as? AccountUiState.Authorizing)?.authorizationUrl?.let { value -> val uri = android.net.Uri.parse(value) if (uri.scheme == "https" && uri.host == "auth.openbitfun.com") { - uri.buildUpon().appendQueryParameter("locale", locale).build().toString() + uri.buildUpon() + .appendQueryParameter("locale", locale) + .appendQueryParameter("returnTo", "openbitfun://auth/callback") + .build().toString() } else value } val uriHandler = androidx.compose.ui.platform.LocalUriHandler.current diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/chat/ConversationView.kt b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/chat/ConversationView.kt index 594ee827f8..09e71d9ce2 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/chat/ConversationView.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/chat/ConversationView.kt @@ -271,7 +271,9 @@ internal fun ConversationView( Box(modifier = Modifier.fillMaxSize().testTag(CONVERSATION_TEST_TAG)) { if (timeline == null) { Box(Modifier.fillMaxSize()) { - if (loadingVisible) ConversationLoadingState(Modifier.fillMaxSize()) + if (loadingVisible) ConversationLoadingState( + Modifier.fillMaxSize().padding(top = topInset, bottom = bottomInset), + ) } } else if (visibleRows.isEmpty() && !state.hasMoreMessages) { ConversationEmptyState(modifier = Modifier.fillMaxSize()) diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/chat/tool/ToolStatusList.kt b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/chat/tool/ToolStatusList.kt index 293ec6cac3..0d9f69a723 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/chat/tool/ToolStatusList.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/chat/tool/ToolStatusList.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -31,6 +32,7 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import org.json.JSONObject import com.openbitfun.mobile.app.R import com.openbitfun.mobile.core.feature.session.ToolAction import com.openbitfun.mobile.core.feature.session.ToolCard @@ -212,6 +214,11 @@ internal fun ToolStatusRow( } return } + if (tool.kind == com.openbitfun.mobile.core.feature.session.ToolKind.TODO || + tool.operation == ToolOperation.UPDATE_TODOS || tool.name.equals("TodoWrite", ignoreCase = true)) { + TodoToolCard(tool = tool, enabled = enabled, modifier = modifier) + return + } var localExpanded by rememberSaveable(tool.id) { mutableStateOf(false) } val disclosure = LocalToolDisclosure.current val expanded = disclosure?.let { it.selected == tool.id } ?: localExpanded @@ -358,6 +365,79 @@ internal fun ToolStatusRow( } } + +private data class TodoItem(val content: String, val status: String) + +private fun parseTodoItems(value: String): List = runCatching { + val root = JSONObject(value) + val todos = root.optJSONArray("todos") ?: return@runCatching emptyList() + buildList { + for (index in 0 until todos.length()) { + val item = todos.optJSONObject(index) ?: continue + val content = item.optString("content").trim() + if (content.isNotEmpty()) add(TodoItem(content, item.optString("status", "pending"))) + } + } +}.getOrDefault(emptyList()) + +@Composable +private fun TodoToolCard(tool: ToolCard, enabled: Boolean, modifier: Modifier) { + var expanded by rememberSaveable(tool.id) { mutableStateOf(tool.phase != ToolPhase.COMPLETED) } + val items = remember(tool.input, tool.output) { + parseTodoItems(tool.input).ifEmpty { parseTodoItems(tool.output) } + } + val done = items.count { it.status.equals("completed", true) } + val title = stringResource(R.string.tool_op_update_todos) + val active = items.firstOrNull { it.status.equals("in_progress", true) } + ?: items.firstOrNull { it.status.equals("pending", true) } + val summary = when { + items.isEmpty() -> tool.target.ifBlank { stringResource(R.string.tool_todo_fallback) } + done == items.size -> stringResource(R.string.tool_todo_all_completed) + else -> active?.content ?: items.first().content + } + Column( + modifier = modifier.fillMaxWidth() + .background(MaterialTheme.colorScheme.surfaceVariant, RoundedCornerShape(16.dp)) + .border(1.dp, MaterialTheme.colorScheme.outlineVariant, RoundedCornerShape(16.dp)) + .padding(horizontal = 12.dp, vertical = 10.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth().clickable { expanded = !expanded }, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(9.dp), + ) { + ToolStatusIcon(tool, Modifier) + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(title, style = MaterialTheme.typography.bodyMedium, fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold) + Text(summary, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, overflow = TextOverflow.Ellipsis) + } + if (items.isNotEmpty()) Text("$done/${items.size}", style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant) + Chevron(if (expanded) R.drawable.ic_symbol_chevron_down else R.drawable.ic_symbol_chevron_right) + } + if (expanded && items.isNotEmpty()) { + Column(Modifier.fillMaxWidth().padding(start = 29.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Box(Modifier.fillMaxWidth().height(2.dp).background(MaterialTheme.colorScheme.outlineVariant, RoundedCornerShape(2.dp))) { + Box(Modifier.fillMaxWidth(done.toFloat() / items.size.toFloat()).height(2.dp).background(MaterialTheme.colorScheme.onSurfaceVariant, RoundedCornerShape(2.dp))) + } + items.forEach { item -> + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.Top) { + Text(if (item.status.equals("completed", true)) "✓" else if (item.status.equals("cancelled", true)) "×" else if (item.status.equals("in_progress", true)) "•" else "○", + color = MaterialTheme.colorScheme.onSurfaceVariant) + Text(item.content, style = MaterialTheme.typography.bodySmall, + color = if (item.status.equals("in_progress", true)) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant) + } + } + } + } + if (expanded && items.isEmpty() && tool.output.isNotBlank()) { + ToolDetail(stringResource(R.string.tool_output), tool.output, tool.phase == ToolPhase.FAILED) + } + } +} + /** * The row's one line, ported from `toolLineLabel`. * diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/remote/CreateSessionScreen.kt b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/remote/CreateSessionScreen.kt index f74b7db134..03e0cdbe00 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/remote/CreateSessionScreen.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/remote/CreateSessionScreen.kt @@ -99,9 +99,13 @@ internal fun CreateSessionRoute( 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 - LaunchedEffect(created, hasTimeline) { - if (created != null && created != baseline.value && hasTimeline) onCreated(created) + LaunchedEffect(created) { + // The shared store publishes the new id only after the remote create + // has committed. Do not wait for a non-null timeline here: an empty + // session has no records, and a delayed stream must not strand the + // user on the creation surface. ConversationView owns that loading + // state once the id has been routed. + if (created != null && created != baseline.value) onCreated(created) } CreateSessionScreen( diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/remote/PairingScreen.kt b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/remote/PairingScreen.kt index 60353653b9..31a274d6ef 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/remote/PairingScreen.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/remote/PairingScreen.kt @@ -75,6 +75,10 @@ internal fun AccountRemoteScreen( requestedSessionId: String? = null, creatingSession: Boolean = false, onOpenSession: (String) -> Unit = {}, + // CreateSession is already opened by the shared store. Keep its route + // callback separate from the sidebar callback, which also dispatches an + // Open intent for an existing row. + onCreatedSession: (String) -> Unit = onOpenSession, onCreateSession: () -> Unit = {}, onRemoteHome: () -> Unit = {}, modifier: Modifier, @@ -99,6 +103,7 @@ internal fun AccountRemoteScreen( requestedSessionId = requestedSessionId, creatingSession = creatingSession, onOpenSession = onOpenSession, + onCreatedSession = onCreatedSession, onCreateSession = onCreateSession, onRemoteHome = onRemoteHome, connectionDetails = { @@ -129,6 +134,7 @@ private fun RemoteConnectedScreen( requestedSessionId: String?, creatingSession: Boolean, onOpenSession: (String) -> Unit, + onCreatedSession: (String) -> Unit, onCreateSession: () -> Unit, onRemoteHome: () -> Unit, connectionDetails: @Composable () -> Unit, @@ -207,7 +213,7 @@ private fun RemoteConnectedScreen( compact = compact, onDevicePick = onCreateDevicePick, onBack = onRemoteHome, - onCreated = onOpenSession, + onCreated = onCreatedSession, onWorkspaceIntent = onWorkspaceIntent, onIntent = onSessionIntent, modifier = modifier, diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/shell/MobileScreen.kt b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/shell/MobileScreen.kt index 29456ece9e..40ee0fe815 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/shell/MobileScreen.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/shell/MobileScreen.kt @@ -137,6 +137,16 @@ internal fun MobileScreen(onAccountRestored: (Boolean) -> Unit = {}) { val accountPhase by accountViewModel.connectionPhase.collectAsStateWithLifecycle() val accountWorkspaceDirectory by accountViewModel.workspaceDirectory.collectAsStateWithLifecycle() val readyAccount = accountState as? AccountUiState.Ready + LaunchedEffect(accountPhase, shell.remoteCreating) { + // A create failure is terminal for the creation route. The shared + // store keeps the last valid session projection so returning to the + // remote home here restores the same pre-create route as Harmony and + // iOS, while the store's typed create error remains available to the + // surface-level error/toast handling. + if (shell.remoteCreating && accountPhase == ConnectionPhase.FAILED) { + shell.closeRemoteSession() + } + } LaunchedEffect(accountState) { if (accountState !is AccountUiState.Idle && accountState !is AccountUiState.Restoring) { onAccountRestored(readyAccount?.userId?.isNotBlank() == true) @@ -188,6 +198,13 @@ internal fun MobileScreen(onAccountRestored: (Boolean) -> Unit = {}) { RemoteControlSource.ACCOUNT_DEVICE -> accountRemoteState RemoteControlSource.NONE -> RemoteSessionUiState.Idle } + val committedRemoteSessions = (activeRemoteState as? RemoteSessionUiState.Ready)?.sessions.orEmpty() + // Shell navigation is only a transient request while the shared store + // catches up. Once a delete succeeds the row disappears, which invalidates + // a stale shell request and lets the store's selection take over. + val routedSessionId = shell.remoteSessionId + ?.takeIf { requested -> committedRemoteSessions.any { it.id == requested } } + ?: (activeRemoteState as? RemoteSessionUiState.Ready)?.selectedSessionId val activeWorkspaceDirectory = when (controlSummary.source) { RemoteControlSource.ACCOUNT_DEVICE -> accountWorkspaceDirectory RemoteControlSource.NONE -> WorkspaceSessionDirectoryUiState(emptyList()) @@ -330,7 +347,10 @@ internal fun MobileScreen(onAccountRestored: (Boolean) -> Unit = {}) { workspaceState = activeWorkspaceState, workspaceDirectory = activeWorkspaceDirectory, remoteActive = shell.surface == MobileSurface.REMOTE, - remoteSelectedSessionId = shell.remoteSessionId, + // The session store owns selection. The shell only owns the + // container route, so deleting the active row clears this value + // without leaving a stale requested session behind. + remoteSelectedSessionId = routedSessionId, query = shell.sidebarQuery, searchOpen = shell.searchOpen, onQueryChange = shell::search, @@ -361,6 +381,11 @@ internal fun MobileScreen(onAccountRestored: (Boolean) -> Unit = {}) { dispatchActiveSession(RemoteSessionIntent.Open(sessionId)) }, onCreateRemoteInWorkspace = { workspace, agentType -> + // Workspace quick-create used to dispatch directly while the + // shell still rendered the home surface, so its committed + // session had no creation route callback. Enter the same + // creation route as the global new-session action first. + shell.createRemoteSession() // With an ID the create carries only the ID; the legacy triple is for pre-ID rows. dispatchActiveSession( RemoteSessionIntent.CreateSession( @@ -374,7 +399,6 @@ internal fun MobileScreen(onAccountRestored: (Boolean) -> Unit = {}) { workspaceId = workspace.workspaceId, ), ) - shell.show(MobileSurface.REMOTE) closeDrawer() }, onWorkspaceTool = { path, connectionId, terminal -> @@ -467,9 +491,13 @@ internal fun MobileScreen(onAccountRestored: (Boolean) -> Unit = {}) { null }, compact = !wide, - requestedSessionId = shell.remoteSessionId, + requestedSessionId = routedSessionId, creatingSession = shell.remoteCreating, onOpenSession = shell::openRemoteSession, + onCreatedSession = { id -> + shell.openRemoteSession(id) + closeDrawer() + }, onCreateSession = shell::createRemoteSession, onRemoteHome = shell::closeRemoteSession, modifier = Modifier, diff --git a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/viewmodel/AccountViewModel.kt b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/viewmodel/AccountViewModel.kt index e75f31b8ec..3dfa7cc3ef 100644 --- a/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/viewmodel/AccountViewModel.kt +++ b/src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/viewmodel/AccountViewModel.kt @@ -77,6 +77,10 @@ internal class AccountViewModel(application: Application) : AndroidViewModel(app store.dispatch(intent) } + fun notifyAuthorizationCallback() { + store.notifyAuthorizationCallback() + } + /** The handle General Chat reads the account's synced models through. */ fun dispatchSession(intent: RemoteSessionIntent) { 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 c0edb5f862..343b4d4948 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 @@ -526,4 +526,6 @@ 电脑上的文件已被修改,当前草稿仍保留。请先复制草稿,再重新打开文件以读取最新内容。 加载更早消息失败,点按重试。 正在加载更早消息… + 任务列表 + 全部任务已完成 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 ad4c6fc18e..97b29187b4 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 @@ -541,4 +541,6 @@ File changed on the computer. Your draft is preserved. Copy it before reopening the file to load the latest version. Could not load earlier messages. Tap to retry. Loading earlier messages… + Task list + All tasks completed diff --git a/src/apps/mobile/docs/remote-session-lifecycle.md b/src/apps/mobile/docs/remote-session-lifecycle.md new file mode 100644 index 0000000000..0ffc01ab8e --- /dev/null +++ b/src/apps/mobile/docs/remote-session-lifecycle.md @@ -0,0 +1,38 @@ +# Remote session lifecycle guidance + +Android, iOS, and HarmonyOS should follow the same MVVM shape for remote +sessions. The native surfaces may use different language and UI primitives, but +the ViewModel layer should keep the same responsibilities and update order. + +## Create + +Every create entry point dispatches one create operation with the selected +device and workspace identity. After success, the ViewModel should generally: + +1. select the returned session id and route to its conversation surface; +2. discard the previous session's draft, transcript, active turn, and file + preview state; +3. open or subscribe to the returned session on the remote host; +4. publish the hydrated timeline, including an empty timeline for a new session; +5. send the optional first instruction and start live polling/stream replay. + +The route should not wait for a non-empty transcript. An empty session is a +successful open and the conversation View owns its loading state. + +## Open an existing session + +Opening an existing row follows the same steps from selection onward. The +selected session id is the navigation identity; a stale timeline from another +session should not be rendered while the open request is pending. + +## Ownership by platform + +- Android and iOS receive most session state from the shared KMP session store. + Shell state controls containers, drawers, and transient route presentation; + it should not become a second session store. +- HarmonyOS keeps its native ViewModel and controller, but their responsibilities + should mirror the KMP store and use the same remote command shapes. +- Transport, relay, and desktop behavior are not owned by a mobile surface. + +Failures after the create commit should leave the created session visible and +route to its conversation surface with an explicit loading or error state. diff --git a/src/apps/mobile/harmonyos/build-profile.json5 b/src/apps/mobile/harmonyos/build-profile.json5 index b8ba85d4f3..d6fea37185 100644 --- a/src/apps/mobile/harmonyos/build-profile.json5 +++ b/src/apps/mobile/harmonyos/build-profile.json5 @@ -22,6 +22,21 @@ { "name": "release" } + ], + "signingConfigs": [ + { + "name": "default", + "type": "HarmonyOS", + "material": { + "certpath": "/Users/user/.ohos/config/default_harmonyos_B3rUxpnSPfGXXWmdMOYVoc7TUzJDbROQp3p6n39Q8qQ=.cer", + "keyAlias": "debugKey", + "keyPassword": "0000001A391869B09E80CE6A66CFA3F3864C8F321A69398D55D1CD0AD84D6EA98C4F835AF200151DB198", + "profile": "/Users/user/.ohos/config/default_harmonyos_B3rUxpnSPfGXXWmdMOYVoc7TUzJDbROQp3p6n39Q8qQ=.p7b", + "signAlg": "SHA256withECDSA", + "storeFile": "/Users/user/.ohos/config/default_harmonyos_B3rUxpnSPfGXXWmdMOYVoc7TUzJDbROQp3p6n39Q8qQ=.p12", + "storePassword": "0000001A975FC127975033425E725D1E55324204FF1C28601D5B38A9E066DFDD16F320DA9D97F3D83071" + } + } ] }, "modules": [ diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteConnectionPanel.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteConnectionPanel.ets new file mode 100644 index 0000000000..0a19975d07 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/RemoteConnectionPanel.ets @@ -0,0 +1,62 @@ +import { RemoteI18n } from '../../i18n/RemoteI18n'; +import { MobileDesignTypography } from '../../generated/MobileDesignTokens'; +import { BrandMark } from './BrandMark'; +import { INK, MUTED, TRANSPARENT } from './Theme'; + +/** Shared welcome composition for phone home and an empty wide master pane. */ +@ComponentV2 +export struct RemoteConnectionPanel { + @Param title: string = ''; + @Param detail: string = ''; + @Param busy: boolean = false; + @BuilderParam primaryAction: () => void = this.EmptyAction; + @Event onMiniApps: () => void = () => {}; + + build() { + Column() { + Scroll() { + Column({ space: 16 }) { + BrandMark({ markSize: 104 }) + .margin({ bottom: 12 }) + Text(this.title) + .fontSize(MobileDesignTypography.headlineLarge.size).fontWeight(FontWeight.Medium).fontColor(INK) + .textAlign(TextAlign.Center) + Text(this.detail) + .fontSize(MobileDesignTypography.bodyMedium.size) + .lineHeight(23).fontColor(MUTED).textAlign(TextAlign.Center) + .constraintSize({ maxWidth: 300 }) + if (this.busy) { + LoadingProgress().width(20).height(20).color(MUTED) + } + } + .width('100%').padding({ top: 32, bottom: 32, left: 12, right: 12 }) + } + .width('100%').layoutWeight(1).scrollBar(BarState.Off) + .align(Alignment.Center) + + Column({ space: 10 }) { + this.primaryAction() + Button({ type: ButtonType.Normal }) { + Row({ space: 9 }) { + SymbolGlyph($r('sys.symbol.square_grid_2x2')).fontSize(18).fontColor([MUTED]) + Text(RemoteI18n.t('home.exploreMiniApps')) + .fontSize(MobileDesignTypography.bodyMedium.size).fontColor(MUTED) + } + .justifyContent(FlexAlign.Center) + } + .width('100%').height(48).backgroundColor(TRANSPARENT) + .borderRadius(24).onClick(this.onMiniApps) + Text(RemoteI18n.t('home.miniAppsHint')) + .fontSize(MobileDesignTypography.labelSmall.size).fontColor(MUTED) + .textAlign(TextAlign.Center) + } + .width('100%').constraintSize({ maxWidth: 360 }) + .padding({ top: 12, bottom: 24 }) + } + .width('100%').height('100%').constraintSize({ maxWidth: 560 }) + .padding({ left: 24, right: 24 }).alignItems(HorizontalAlign.Center) + } + + @Builder + private EmptyAction() { Row() {} } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SidebarWorkspaceSection.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SidebarWorkspaceSection.ets index 85ecdb1e90..7739c78a7a 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SidebarWorkspaceSection.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SidebarWorkspaceSection.ets @@ -94,7 +94,7 @@ export struct SidebarWorkspaceSection { .onClick(() => { void this.onRefreshDevices(); }) } Row() {}.height(0) - .bindSheet($$this.workspaceTools.visible, this.WorkspaceTools(), { height: SheetSize.LARGE, onWillDismiss: (action: DismissSheetAction): void => { + .bindSheet($$this.workspaceTools.visible, this.WorkspaceTools(), { height: SheetSize.LARGE, showClose: false, onWillDismiss: (action: DismissSheetAction): void => { if (this.workspaceTools.dirty) this.onWorkspaceTools({ type: 'close' }); else { this.onWorkspaceTools({ type: 'close' }); action.dismiss(); } }, onDisappear: () => this.onWorkspaceTools({ type: 'close' }) }) diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ToolStatusList.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ToolStatusList.ets index 288541df90..8ed9d4932f 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ToolStatusList.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ToolStatusList.ets @@ -29,6 +29,7 @@ import { ToolConfirmationPanel, ToolQuestionAnswerPanel } from './ToolInteractio interface TodoPreviewItem { content?: string; + status?: string; } interface ToolInputSummaryPayload { @@ -216,6 +217,9 @@ export struct ToolStatusList { @Builder ToolRow(tool: ConversationUiToolStatus, index: number) { + if (this.isTodoTool(tool)) { + this.TodoCard(tool, index) + } else { Column({ space: 4 }) { Row({ space: 8 }) { this.ToolStatusIcon(tool) @@ -307,6 +311,72 @@ export struct ToolStatusList { .backgroundColor(this.toolRowBg(tool, index)) .borderRadius(this.toolRowRadius(tool, index)) .border({ width: this.toolRowBorderWidth(tool, index), color: LINE }) + } + } + + @Builder + TodoCard(tool: ConversationUiToolStatus, index: number) { + Column({ space: 8 }) { + Row({ space: 9 }) { + Stack({ alignContent: Alignment.Center }) { + ToolGlyph({ kind: 'todo', color: STATUS_SUCCESS }) + }.width(22).height(22).backgroundColor(STATUS_SUCCESS).opacity(0.14).borderRadius(6) + Column({ space: 2 }) { + Text(this.operationLabel(tool)).fontSize(MobileDesignTypography.bodySmall.size).fontWeight(FontWeight.Medium).fontColor(INK) + Text(this.todoSummary(tool)).fontSize(MobileDesignTypography.labelSmall.size) + .fontColor(MUTED).maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis }) + }.layoutWeight(1).alignItems(HorizontalAlign.Start) + if (this.todoItems(tool).length > 0) { Text(`${this.todoDone(tool)}/${this.todoItems(tool).length}`).fontSize(MobileDesignTypography.labelSmall.size).fontColor(MUTED) } + this.ChevronIcon(this.expandedToolKey === this.toolKey(tool, index) ? 'down' : 'right') + }.width('100%').height(32).onClick(() => { this.expandedToolKey = this.expandedToolKey === this.toolKey(tool, index) ? '' : this.toolKey(tool, index); }) + if (this.expandedToolKey === this.toolKey(tool, index) && this.todoItems(tool).length > 0) { + Column({ space: 8 }) { + Row() { + Blank().width(`${this.todoItems(tool).length > 0 ? this.todoDone(tool) * 100 / this.todoItems(tool).length : 0}%`).height(2).backgroundColor(MUTED) + }.width('100%').height(2).backgroundColor(LINE).borderRadius(2) + + ForEach(this.todoItems(tool), (item: TodoPreviewItem, itemIndex: number) => { + Row({ space: 8 }) { + Text((item.status || '').toLowerCase() === 'completed' ? '✓' : (item.status || '').toLowerCase() === 'in_progress' ? '•' : '○') + .fontSize(MobileDesignTypography.bodySmall.size).fontColor((item.status || '').toLowerCase() === 'completed' ? STATUS_SUCCESS : MUTED) + Text(item.content || '').fontSize(MobileDesignTypography.bodySmall.size).fontColor(INK).layoutWeight(1).maxLines(2) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + } + }, (item: TodoPreviewItem, itemIndex: number) => `${this.toolKey(tool, index)}-${itemIndex}`) + }.width('100%').padding({ left: 31 }) + } + if (this.expandedToolKey === this.toolKey(tool, index) && this.todoItems(tool).length === 0 && this.toolResultPreview(tool).length > 0) { + this.ToolDetail(RemoteI18n.t('chat.output'), this.toolResultPreview(tool), this.hasToolError(tool)) + } + }.width('100%').padding({ left: 10, right: 10, top: 10, bottom: 10 }).backgroundColor(SOFT).borderRadius(14).border({ width: 1, color: LINE }) + } + + private todoSummary(tool: ConversationUiToolStatus): string { + let items = this.todoItems(tool); + let active = items.find((item: TodoPreviewItem) => (item.status || '').toLowerCase() === 'in_progress') || + items.find((item: TodoPreviewItem) => (item.status || '').toLowerCase() === 'pending'); + if (items.length === 0) return this.summaryLabel(tool); + if (this.todoDone(tool) === items.length) return RemoteI18n.t('chat.thinkingDone'); + return active?.content || items[0].content || RemoteI18n.t('chat.toolTodoFallback'); + } + + private todoDone(tool: ConversationUiToolStatus): number { + return this.todoItems(tool).filter((item: TodoPreviewItem) => (item.status || '').toLowerCase() === 'completed').length; + } + + private todoItems(tool: ConversationUiToolStatus): TodoPreviewItem[] { + let values: string[] = []; + if (tool.tool_input) values.push(JSON.stringify(tool.tool_input)); + if (tool.input_preview) values.push(tool.input_preview); + if (tool.tool_output) values.push(JSON.stringify(tool.tool_output)); + if (tool.result_preview) values.push(tool.result_preview); + for (let value of values) { + try { + let parsed = JSON.parse(value) as ToolInputSummaryPayload; + if (parsed.todos && parsed.todos.length > 0) return parsed.todos; + } catch (_error) {} + } + return []; } @Builder diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/WorkspaceTerminalView.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/WorkspaceTerminalView.ets index 1b866a0ef5..257020d8b7 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/WorkspaceTerminalView.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/WorkspaceTerminalView.ets @@ -58,7 +58,7 @@ export struct WorkspaceTerminalView { const foreground = this.color($r('app.color.ink').id); const frame: TerminalFrame = { epoch: epoch, revision: ++this.revision, reset: reset, data: reset ? output : output.slice(this.sentOutput.length), - theme: { background: this.color($r('app.color.page_bg').id), foreground: foreground, cursor: foreground } }; + theme: { background: this.color($r('app.color.media_background').id), foreground: foreground, cursor: foreground } }; await this.controller.runJavaScript(`window.OpenBitFunTerminal.accept(${JSON.stringify(frame)})`); this.sentEpoch = epoch; this.sentOutput = output; } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/WorkspaceToolsPanel.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/WorkspaceToolsPanel.ets index b822c40d21..8eb235336d 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/WorkspaceToolsPanel.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/WorkspaceToolsPanel.ets @@ -4,7 +4,8 @@ import { WorkspaceTerminalView } from './WorkspaceTerminalView'; import { WorkspaceToolsState, WorkspaceToolsIntent, WorkspaceFileEntry, WorkspaceTerminalEntry, WorkspaceToolLocation } from '../state/WorkspaceToolsState'; import { MobileDesignTypography } from '../../generated/MobileDesignTokens'; import { RemoteI18n } from '../../i18n/RemoteI18n'; -import { INK, MUTED, PAGE_BG, LINE } from './Theme'; +import { CARD, CONTENT_ON_ACTION, INK, MEDIA_BACKGROUND, MUTED, PAGE_BG, LINE, SOFT, TRANSPARENT } from './Theme'; +import { SheetCloseButton } from './SheetCloseButton'; @ComponentV2 export struct WorkspaceToolsPanel { @@ -16,15 +17,19 @@ export struct WorkspaceToolsPanel { this.filesScroller.scrollEdge(Edge.Top); } build() { - Column({ space: 8 }) { - Row() { - Text(RemoteI18n.t('workspaceTools.title')).fontSize(MobileDesignTypography.bodyLarge.size).fontColor(INK).layoutWeight(1) - Button(RemoteI18n.t('common.close')).fontColor(INK).backgroundColor(PAGE_BG).onClick(() => this.onIntent({ type: 'close' })) + Column({ space: 0 }) { + Row({ space: 12 }) { + Blank().layoutWeight(1) + SheetCloseButton({ uniformGlyph: true, onClose: () => this.onIntent({ type: 'close' }) }) } + .width('100%') + .height(56) + .alignItems(VerticalAlign.Center) if (this.state.deviceScope) { Select([{ value: RemoteI18n.t('workspaceTools.runtimeLocal') }, ...this.state.locations.map((item: WorkspaceToolLocation): SelectOption => ({ value: item.name }))]) .value(this.state.locations.find((item: WorkspaceToolLocation): boolean => item.id === this.state.connectionId)?.name || RemoteI18n.t('workspaceTools.runtimeLocal')) - .fontColor(INK).width('100%').enabled(!this.state.busy) + .fontColor(INK).width('100%').height(48).enabled(!this.state.busy) + .backgroundColor(CARD).border({ width: 1, color: LINE }).borderRadius(12) .onSelect((index: number) => this.onIntent({ type: 'open-device', connectionId: index === 0 ? '' : this.state.locations[index - 1]?.id, tab: this.state.initialTab })) } if (this.state.error.length > 0) { Text(this.state.saveConflict ? RemoteI18n.t('workspaceTools.fileConflict') : this.state.error).fontColor(MUTED).fontSize(MobileDesignTypography.bodySmall.size) } @@ -39,7 +44,7 @@ export struct WorkspaceToolsPanel { TabContent() { this.Files() }.tabBar(new SubTabBarStyle(RemoteI18n.t('workspaceTools.files')).labelStyle({ selectedColor: INK, unselectedColor: MUTED }).indicator({ color: INK })) TabContent() { this.Terminal() }.tabBar(new SubTabBarStyle(RemoteI18n.t('workspaceTools.terminal')).labelStyle({ selectedColor: INK, unselectedColor: MUTED }).indicator({ color: INK })) }.onChange((index: number) => this.onIntent({ type: 'panel', tab: index })).layoutWeight(1) - }.padding(16).width('100%').height('100%').backgroundColor(PAGE_BG) + }.padding({ left: 16, right: 16, bottom: 16 }).width('100%').height('100%').backgroundColor(PAGE_BG) } @Builder private Files() { @@ -62,45 +67,73 @@ export struct WorkspaceToolsPanel { private FileActionForm() { Column({ space: 16 }) { Row({ space: 12 }) { - Button(RemoteI18n.t('common.cancel')).fontColor(INK).backgroundColor(PAGE_BG).height(44) + Button(RemoteI18n.t('common.cancel')) + .fontColor(MUTED).backgroundColor(TRANSPARENT).height(40).borderRadius(8) .onClick(() => this.onIntent({ type: 'file-action-cancel' })) - Text(this.fileActionTitle()).fontColor(INK).fontSize(MobileDesignTypography.bodyLarge.size).layoutWeight(1).maxLines(2) - Button(this.fileActionTitle()).fontColor(PAGE_BG).backgroundColor(INK).height(44) + Text(this.fileActionTitle()).fontColor(INK).fontSize(MobileDesignTypography.titleMedium.size).fontWeight(FontWeight.Medium).layoutWeight(1).maxLines(2) + Button(this.fileActionTitle()) + .fontColor(CONTENT_ON_ACTION).backgroundColor(INK).height(40).borderRadius(8) .enabled(this.state.fileAction === 'delete' || this.state.fileActionName.trim().length > 0) .onClick(() => this.onIntent({ type: 'file-action-submit' })) }.enabled(!this.state.busy).width('100%') - if (this.state.fileAction === 'delete') { Text(this.state.fileActionPath).fontColor(MUTED) } + if (this.state.fileAction === 'delete') { + Text(this.state.fileActionPath).fontColor(MUTED).fontSize(MobileDesignTypography.bodySmall.size).maxLines(3) + } else { - TextInput({ text: this.state.fileActionName, placeholder: RemoteI18n.t('workspaceTools.fileName') }).fontColor(INK).enabled(!this.state.busy) + TextInput({ text: this.state.fileActionName, placeholder: RemoteI18n.t('workspaceTools.fileName') }) + .fontColor(INK).backgroundColor(SOFT).borderRadius(8).height(48).enabled(!this.state.busy) .onChange((value: string) => this.onIntent({ type: 'file-action-name', text: value })) .onSubmit(() => { if (!this.state.busy && this.state.fileActionName.trim()) this.onIntent({ type: 'file-action-submit' }); }) } - if (this.state.error) { Text(this.state.error).fontColor(MUTED) } + if (this.state.error) { Text(this.state.error).fontColor(MUTED).fontSize(MobileDesignTypography.bodySmall.size) } if (this.state.busy) { LoadingProgress().width(24).height(24).color(MUTED) } - }.padding(24).width('100%') + }.padding({ left: 20, right: 20, top: 20, bottom: 24 }).width('100%').backgroundColor(CARD) } @Builder private FileDirectory() { - Column({ space: 8 }) { - Text(this.state.directory).fontColor(MUTED).fontSize(MobileDesignTypography.bodySmall.size).maxLines(2).width('100%').textAlign(TextAlign.Start) - Row({ space: 4 }) { - Button() { SymbolGlyph($r('sys.symbol.arrow_up')).fontColor([INK]).fontSize(20) } - .width(44).height(44).backgroundColor(PAGE_BG).accessibilityText(RemoteI18n.t('workspaceTools.up')) + Column({ space: 12 }) { + Row({ space: 8 }) { + Button() { SymbolGlyph($r('sys.symbol.arrow_up')).fontColor([INK]).fontSize(18) } + .width(40).height(40).padding(0).backgroundColor(TRANSPARENT).borderRadius(8).accessibilityText(RemoteI18n.t('workspaceTools.up')) .onClick(() => this.onIntent({ type: 'directory', path: RuntimeLocationPolicy.parent(this.state.directory) })) - Button() { SymbolGlyph($r('sys.symbol.arrow_clockwise')).fontColor([INK]).fontSize(20) } - .width(44).height(44).backgroundColor(PAGE_BG).accessibilityText(RemoteI18n.t('workspaceTools.refresh')) + Text(this.state.directory).fontColor(MUTED).fontSize(MobileDesignTypography.bodySmall.size).maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis }).layoutWeight(1) + Button() { SymbolGlyph($r('sys.symbol.arrow_clockwise')).fontColor([MUTED]).fontSize(18) } + .width(40).height(40).padding(0).backgroundColor(TRANSPARENT).borderRadius(8).accessibilityText(RemoteI18n.t('workspaceTools.refresh')) .onClick(() => this.onIntent({ type: 'directory' })) + }.width('100%').height(48).padding({ left: 4, right: 4 }).backgroundColor(CARD).border({ width: 1, color: LINE }).borderRadius(8) + Scroll() { + Row({ space: 8 }) { + Button() { + Row({ space: 6 }) { + SymbolGlyph($r('sys.symbol.doc_text')).fontColor([INK]).fontSize(16) + Text(RemoteI18n.t('workspaceTools.newFile')).fontColor(INK).fontSize(MobileDesignTypography.labelSmall.size) + } + }.height(36).padding({ left: 10, right: 10 }).backgroundColor(SOFT).borderRadius(8) + .onClick(() => this.onIntent({ type: 'file-action', text: 'file' })) + Button() { + Row({ space: 6 }) { + SymbolGlyph($r('sys.symbol.folder')).fontColor([INK]).fontSize(16) + Text(RemoteI18n.t('workspaceTools.mkdir')).fontColor(INK).fontSize(MobileDesignTypography.labelSmall.size) + } + }.height(36).padding({ left: 10, right: 10 }).backgroundColor(SOFT).borderRadius(8) + .onClick(() => this.onIntent({ type: 'file-action', text: 'folder' })) + Button() { + Row({ space: 6 }) { + SymbolGlyph($r('sys.symbol.doc_text_badge_arrow_up')).fontColor([INK]).fontSize(16) + Text(RemoteI18n.t('workspaceTools.upload')).fontColor(INK).fontSize(MobileDesignTypography.labelSmall.size) + } + }.height(36).padding({ left: 10, right: 10 }).backgroundColor(SOFT).borderRadius(8) + .onClick(() => this.onIntent({ type: 'file-action', text: 'upload' })) + } + }.scrollable(ScrollDirection.Horizontal).scrollBar(BarState.Off).width('100%').height(40) + Row({ space: 8 }) { + Blank().layoutWeight(1) Select([ { value: RemoteI18n.t('workspaceTools.nameAsc') }, { value: RemoteI18n.t('workspaceTools.nameDesc') }, { value: RemoteI18n.t('workspaceTools.modifiedAsc') }, { value: RemoteI18n.t('workspaceTools.modifiedDesc') } - ]).selected(this.state.sort).value(this.sortTitle()).fontColor(INK).layoutWeight(1).enabled(!this.state.busy) - .onSelect((index: number) => this.onIntent({ type: 'sort', text: String(index) })) - Button() { SymbolGlyph($r('sys.symbol.plus')).fontColor([INK]).fontSize(20) } - .width(44).height(44).backgroundColor(PAGE_BG).bindMenu([ - { value: RemoteI18n.t('workspaceTools.newFile'), action: () => this.onIntent({ type: 'file-action', text: 'file' }) }, - { value: RemoteI18n.t('workspaceTools.mkdir'), action: () => this.onIntent({ type: 'file-action', text: 'folder' }) }, - { value: RemoteI18n.t('workspaceTools.upload'), action: () => this.onIntent({ type: 'file-action', text: 'upload' }) } - ]) + ]).selected(this.state.sort).value(this.sortTitle()).fontColor(INK).height(36).layoutWeight(1).enabled(!this.state.busy) + .backgroundColor(SOFT).borderRadius(18) + .onSelect((index: number) => this.onIntent({ type: 'sort', text: String(index) })) }.enabled(!this.state.busy).width('100%') if (this.state.busy) { LoadingProgress().width(24).height(24).color(MUTED) } List({ scroller: this.filesScroller }) { @@ -108,20 +141,21 @@ export struct WorkspaceToolsPanel { ListItem() { Row({ space: 8 }) { Row({ space: 12 }) { - SymbolGlyph(entry.isDirectory ? $r('sys.symbol.folder') : $r('sys.symbol.doc')).fontColor([MUTED]).fontSize(22) - Text(entry.name).fontColor(INK).fontSize(MobileDesignTypography.bodyMedium.size).maxLines(2).layoutWeight(1) - }.layoutWeight(1).constraintSize({ minHeight: 52 }).onClick(() => this.onIntent({ type: entry.isDirectory ? 'directory' : 'read', path: entry.path })) - Button() { Text('⋯').fontColor(MUTED).fontSize(MobileDesignTypography.headlineSmall.size) } - .width(44).height(44).backgroundColor(PAGE_BG).bindMenu([ + SymbolGlyph(entry.isDirectory ? $r('sys.symbol.folder') : $r('sys.symbol.doc_text')).fontColor([MUTED]).fontSize(20) + Text(entry.name).fontColor(INK).fontSize(MobileDesignTypography.bodyMedium.size).maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis }).layoutWeight(1) + }.layoutWeight(1).constraintSize({ minHeight: 56 }).onClick(() => this.onIntent({ type: entry.isDirectory ? 'directory' : 'read', path: entry.path })) + Button() { this.MoreGlyph() } + .width(40).height(40).padding(0).backgroundColor(TRANSPARENT).borderRadius(8).accessibilityText(entry.name) + .bindMenu([ { value: RemoteI18n.t('workspaceTools.rename'), action: () => this.onIntent({ type: 'file-action', text: 'rename', path: entry.path }) }, { value: RemoteI18n.t('workspaceTools.delete'), action: () => this.onIntent({ type: 'file-action', text: 'delete', path: entry.path }) }, { value: RemoteI18n.t('workspaceTools.download'), enabled: !entry.isDirectory, action: () => this.onIntent({ type: 'download', path: entry.path }) } ]) - }.width('100%') + }.width('100%').padding({ left: 12, right: 8 }).backgroundColor(CARD) } }, (entry: WorkspaceFileEntry) => entry.path) - if (this.state.hasMore) { ListItem() { Button(RemoteI18n.t('workspaceTools.more')).fontColor(INK).backgroundColor(PAGE_BG).onClick(() => this.onIntent({ type: 'more' })) } } - }.divider({ strokeWidth: 1, color: LINE }).layoutWeight(1).enabled(!this.state.busy) + if (this.state.hasMore) { ListItem() { Button(RemoteI18n.t('workspaceTools.more')).fontColor(INK).backgroundColor(TRANSPARENT).height(44).onClick(() => this.onIntent({ type: 'more' })) } } + }.divider({ strokeWidth: 1, color: LINE }).border({ width: 1, color: LINE }).borderRadius(8).layoutWeight(1).enabled(!this.state.busy) if (this.state.downloadStatus) { Text(this.state.downloadStatus).fontColor(MUTED).fontSize(MobileDesignTypography.bodySmall.size) } if (this.state.uploadPending) { Progress({ value: this.state.uploadProgress, total: 100, type: ProgressType.Linear }) @@ -130,65 +164,103 @@ export struct WorkspaceToolsPanel { Button(RemoteI18n.t('workspaceTools.cancelUpload')).fontColor(INK).backgroundColor(PAGE_BG).onClick(() => this.onIntent({ type: 'upload-cancel' })) }.enabled(!this.state.busy) } - }.padding({ top: 12 }).width('100%').height('100%') + }.padding({ top: 12 }).width('100%').height('100%') .bindSheet(this.state.fileAction.length > 0, this.FileActionForm(), { height: SheetSize.FIT_CONTENT, showClose: false, backgroundColor: PAGE_BG, keyboardAvoidMode: SheetKeyboardAvoidMode.TRANSLATE_AND_RESIZE, onWillDismiss: (action: DismissSheetAction): void => { if (!this.state.busy) { this.onIntent({ type: 'file-action-cancel' }); action.dismiss(); } } }) } @Builder + private MoreGlyph() { + Row({ space: 3 }) { + Circle().width(3).height(3).fill(MUTED) + Circle().width(3).height(3).fill(MUTED) + Circle().width(3).height(3).fill(MUTED) + } + } + @Builder private Editor() { - Column({ space: 8 }) { + Column({ space: 12 }) { Row({ space: 8 }) { - Button(RemoteI18n.t('workspaceTools.back')).onClick(() => this.onIntent({ type: 'editor-back' })) - Text(this.state.selectedFile).fontColor(MUTED).layoutWeight(1).maxLines(2) - } + Button() { SymbolGlyph($r('sys.symbol.chevron_left')).fontColor([INK]).fontSize(18) } + .width(40).height(40).padding(0).backgroundColor(TRANSPARENT).borderRadius(8) + .accessibilityText(RemoteI18n.t('workspaceTools.back')) + .onClick(() => this.onIntent({ type: 'editor-back' })) + Column({ space: 2 }) { + Text(this.state.selectedFile.split('/').pop() || this.state.selectedFile) + .fontColor(INK).fontSize(MobileDesignTypography.titleSmall.size).fontWeight(FontWeight.Medium) + .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis }).width('100%') + Text(this.state.selectedFile).fontColor(MUTED).fontSize(MobileDesignTypography.labelSmall.size) + .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis }).width('100%') + }.layoutWeight(1).alignItems(HorizontalAlign.Start) + if (this.state.dirty) { Text('•').fontColor(MUTED).fontSize(MobileDesignTypography.bodyLarge.size) } + }.width('100%').height(48) Row({ space: 8 }) { - Button(RemoteI18n.t('workspaceTools.download')).enabled(!this.state.busy).onClick(() => this.onIntent({ type: 'download' })) - Button(RemoteI18n.t('workspaceTools.lineNumbers')).onClick(() => this.onIntent({ type: 'line-numbers' })) + Button() { SymbolGlyph($r('sys.symbol.arrow_down_to_line')).fontColor([MUTED]).fontSize(16) } + .width(40).height(36).padding(0).backgroundColor(SOFT).borderRadius(8) + .accessibilityText(RemoteI18n.t('workspaceTools.download')).enabled(!this.state.busy) + .onClick(() => this.onIntent({ type: 'download' })) + Button(RemoteI18n.t('workspaceTools.lineNumbers')).fontColor(MUTED).fontSize(MobileDesignTypography.labelSmall.size) + .height(36).backgroundColor(SOFT).borderRadius(8).onClick(() => this.onIntent({ type: 'line-numbers' })) if (this.state.editing) { - Button(RemoteI18n.t('workspaceTools.save')).enabled(!this.state.busy).onClick(() => this.onIntent({ type: 'save' })) + Button(RemoteI18n.t('workspaceTools.save')).fontColor(CONTENT_ON_ACTION).backgroundColor(INK).height(36).borderRadius(8) + .enabled(!this.state.busy).onClick(() => this.onIntent({ type: 'save' })) } else { - Button(RemoteI18n.t('workspaceTools.edit')).onClick(() => this.onIntent({ type: 'editor-edit' })) + Button(RemoteI18n.t('workspaceTools.edit')).fontColor(INK).backgroundColor(SOFT).height(36).borderRadius(8) + .onClick(() => this.onIntent({ type: 'editor-edit' })) } - if (this.state.dirty) { Text(RemoteI18n.t('workspaceTools.unsaved')).fontColor(MUTED) } - } + }.width('100%').justifyContent(FlexAlign.End) WorkspaceFileEditorView({ content: this.state.content, editing: this.state.editing, lineNumbers: this.state.lineNumbers, onIntent: this.onIntent }).layoutWeight(1) - }.padding({ top: 8 }).width('100%').height('100%') + .border({ width: 1, color: LINE }).borderRadius(8).backgroundColor(CARD) + }.padding({ top: 12 }).width('100%').height('100%') } private terminalOptions(): SelectOption[] { return this.state.terminals.map((entry: WorkspaceTerminalEntry): SelectOption => ({ value: entry.name || entry.id })); } @Builder private Terminal() { - Column({ space: 8 }) { + Column({ space: 12 }) { if (this.state.terminalId.length === 0) { - Column() { + Column({ space: 16 }) { + Stack({ alignContent: Alignment.Center }) { + SymbolGlyph($r('sys.symbol.rectangle_stack')).fontColor([MUTED]).fontSize(32) + }.width(72).height(72).backgroundColor(SOFT).borderRadius(20) Button(RemoteI18n.t('workspaceTools.createTerminal')) - .fontColor(INK).backgroundColor(PAGE_BG) - .border({ width: 1, color: LINE }).borderRadius(10).height(44) + .fontColor(CONTENT_ON_ACTION).backgroundColor(INK) + .borderRadius(8).height(44).padding({ left: 18, right: 18 }) .enabled(!this.state.busy) .onClick(() => this.onIntent({ type: 'terminal-create' })) if (this.state.terminals.length > 0) { - Select(this.terminalOptions()).value(RemoteI18n.t('workspaceTools.selectTerminal')).fontColor(INK) + Select(this.terminalOptions()).value(RemoteI18n.t('workspaceTools.selectTerminal')).fontColor(INK).height(44) + .backgroundColor(SOFT).borderRadius(8) .onSelect((index: number) => this.onIntent({ type: 'terminal-attach', path: this.state.terminals[index]?.id })) } - }.width('100%').layoutWeight(1).justifyContent(FlexAlign.Center) + }.width('100%').layoutWeight(1).justifyContent(FlexAlign.Center).border({ width: 1, color: LINE }).borderRadius(12) } else { Row({ space: 8 }) { - Button(RemoteI18n.t('workspaceTools.createTerminal')).fontColor(INK).backgroundColor(PAGE_BG).border({ width: 1, color: LINE }).borderRadius(10).height(44).onClick(() => this.onIntent({ type: 'terminal-create' })) - Select(this.terminalOptions()).onSelect((index: number) => this.onIntent({ type: 'terminal-attach', path: this.state.terminals[index]?.id })) - }.enabled(!this.state.busy) - WorkspaceTerminalView({ terminalId: this.state.terminalId, output: this.state.output, onIntent: this.onIntent }).layoutWeight(1) + Button() { SymbolGlyph($r('sys.symbol.plus')).fontColor([INK]).fontSize(17) } + .width(40).height(40).padding(0).backgroundColor(SOFT).borderRadius(8) + .accessibilityText(RemoteI18n.t('workspaceTools.createTerminal')) + .onClick(() => this.onIntent({ type: 'terminal-create' })) + Select(this.terminalOptions()).value(this.state.terminals.find((entry: WorkspaceTerminalEntry): boolean => entry.id === this.state.terminalId)?.name || this.state.terminalId) + .fontColor(INK).height(40).layoutWeight(1).backgroundColor(SOFT).borderRadius(8) + .onSelect((index: number) => this.onIntent({ type: 'terminal-attach', path: this.state.terminals[index]?.id })) + }.enabled(!this.state.busy).width('100%') + Row() { + WorkspaceTerminalView({ terminalId: this.state.terminalId, output: this.state.output, onIntent: this.onIntent }) + }.layoutWeight(1).width('100%').padding(10).backgroundColor(MEDIA_BACKGROUND).borderRadius(12) Row({ space: 8 }) { Scroll() { Row({ space: 8 }) { - Button('Esc').fontColor(INK).backgroundColor(PAGE_BG).border({ width: 1, color: LINE }).borderRadius(10).height(44).onClick(() => this.onIntent({ type: 'terminal-write', text: '\u001b' })) - Button('Tab').fontColor(INK).backgroundColor(PAGE_BG).border({ width: 1, color: LINE }).borderRadius(10).height(44).onClick(() => this.onIntent({ type: 'terminal-write', text: '\t' })) - Button('Enter').fontColor(INK).backgroundColor(PAGE_BG).border({ width: 1, color: LINE }).borderRadius(10).height(44).onClick(() => this.onIntent({ type: 'terminal-write', text: '\r' })) - Button('Ctrl-C').fontColor(INK).backgroundColor(PAGE_BG).border({ width: 1, color: LINE }).borderRadius(10).height(44).onClick(() => this.onIntent({ type: 'terminal-write', text: '\u0003' })) + Button('Esc').fontColor(INK).backgroundColor(SOFT).borderRadius(8).height(36).onClick(() => this.onIntent({ type: 'terminal-write', text: '\u001b' })) + Button('Tab').fontColor(INK).backgroundColor(SOFT).borderRadius(8).height(36).onClick(() => this.onIntent({ type: 'terminal-write', text: '\t' })) + Button('Enter').fontColor(INK).backgroundColor(SOFT).borderRadius(8).height(36).onClick(() => this.onIntent({ type: 'terminal-write', text: '\r' })) + Button('Ctrl-C').fontColor(INK).backgroundColor(SOFT).borderRadius(8).height(36).onClick(() => this.onIntent({ type: 'terminal-write', text: '\u0003' })) } - }.layoutWeight(1).height(48).scrollable(ScrollDirection.Horizontal).scrollBar(BarState.Off) - Button(RemoteI18n.t('workspaceTools.closeTerminal')).fontColor(INK).backgroundColor(PAGE_BG).border({ width: 1, color: LINE }).borderRadius(10).height(44).onClick(() => this.onIntent({ type: 'terminal-close' })) + }.layoutWeight(1).height(40).scrollable(ScrollDirection.Horizontal).scrollBar(BarState.Off) + Button() { SymbolGlyph($r('sys.symbol.xmark')).fontColor([MUTED]).fontSize(16) } + .width(40).height(36).padding(0).backgroundColor(SOFT).borderRadius(8) + .accessibilityText(RemoteI18n.t('workspaceTools.closeTerminal')) + .onClick(() => this.onIntent({ type: 'terminal-close' })) }.width('100%').enabled(this.state.terminalId.length > 0) } }.padding({ top: 8 }).width('100%').height('100%') diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntimeComposition.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntimeComposition.ets index 7f9a8fdaa5..fb66771ee0 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntimeComposition.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntimeComposition.ets @@ -671,8 +671,8 @@ export abstract class AppRootRuntimeComposition { hooks: { deviceId: (): string => this.remoteConnectionController.getDeviceId(), openAuthorization: async (url: string): Promise => { - const localizedUrl = url.startsWith('https://auth.openbitfun.com/sign-in#') - ? url.replace('/sign-in#', `/sign-in?locale=${encodeURIComponent(RemoteI18n.language())}#`) + const localizedUrl = url.includes('https://auth.openbitfun.com/sign-in') + ? url.replace('/sign-in?', `/sign-in?locale=${encodeURIComponent(RemoteI18n.language())}&`) : url; return this.host.openExternalLink ? await this.host.openExternalLink(localizedUrl) : false; }, diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/CloudAccountClient.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/CloudAccountClient.ets index 96fec99a18..dedc22f39d 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/CloudAccountClient.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/CloudAccountClient.ets @@ -243,11 +243,21 @@ export class CloudAccountClient { if (!start.authorizationUrl.startsWith('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/login/oauth/authorize?') && !/^https:\/\/auth\.openbitfun\.com\/sign-in#ticket=[A-Za-z0-9_-]+$/.test(start.authorizationUrl)) { throw new Error('Untrusted account authorization URL.'); } - if (!await openAuthorization(start.authorizationUrl)) throw new Error('Could not open account sign-in.'); + const authorizationUrl = start.authorizationUrl.replace( + '/sign-in#', + '/sign-in?returnTo=openbitfun%3A%2F%2Fauth%2Fcallback#' + ); + if (!await openAuthorization(authorizationUrl)) throw new Error('Could not open account sign-in.'); const deadline = Math.min(start.expiresAt * 1000, Date.now() + 600000); let lastTransient: Error | undefined = undefined; + let firstPoll = true; while (Date.now() < deadline && generation === this.authorizationGeneration) { - await new Promise((resolve) => setTimeout(resolve, Math.min(30, Math.max(1, start.pollIntervalSeconds)) * 1000)); + if (!firstPoll) { + // Keep the return-to-app handoff responsive even when the relay's + // conservative interval is large; the next poll is bounded to 2s. + await new Promise((resolve) => setTimeout(resolve, Math.min(2, Math.max(1, start.pollIntervalSeconds)) * 1000)); + } + firstPoll = false; if (generation !== this.authorizationGeneration) break; const request: GitHubPollRequest = { transactionId: start.transactionId, transactionSecret: start.transactionSecret }; let poll: GitHubPoll; diff --git a/src/apps/mobile/harmonyos/entry/src/main/module.json5 b/src/apps/mobile/harmonyos/entry/src/main/module.json5 index 98b6f1a2e2..e4ace71b12 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/module.json5 +++ b/src/apps/mobile/harmonyos/entry/src/main/module.json5 @@ -79,6 +79,21 @@ "actions": [ "ohos.want.action.home" ] + }, + { + "entities": [ + "entity.system.browsable" + ], + "actions": [ + "ohos.want.action.viewData" + ], + "uris": [ + { + "scheme": "openbitfun", + "host": "auth", + "path": "/callback" + } + ] } ] } diff --git a/src/apps/mobile/ios/OpenBitFun/App/OpenBitFunApp.swift b/src/apps/mobile/ios/OpenBitFun/App/OpenBitFunApp.swift index 1a993fa9ba..ced50febaf 100644 --- a/src/apps/mobile/ios/OpenBitFun/App/OpenBitFunApp.swift +++ b/src/apps/mobile/ios/OpenBitFun/App/OpenBitFunApp.swift @@ -58,6 +58,13 @@ struct OpenBitFunApp: App { if phase == .background { Self.coldStartProcessClaimed = true; coldStartConsumed = true; showStartupBrand = false; showColdStart = false } model.handleScenePhase(phase) } + .onOpenURL { _ in + // The auth page redirects to this scheme after the + // browser completes. The shared account poller is + // already waiting; opening the URL brings this scene + // foreground and the next poll is immediate. + model.notifyAuthorizationCallback() + } .environment(\.locale, Locale(identifier: model.appLanguage.rawValue)) } } diff --git a/src/apps/mobile/ios/OpenBitFun/Features/Chat/ChatTimelineView.swift b/src/apps/mobile/ios/OpenBitFun/Features/Chat/ChatTimelineView.swift index 9af62f1190..4e2a797a15 100644 --- a/src/apps/mobile/ios/OpenBitFun/Features/Chat/ChatTimelineView.swift +++ b/src/apps/mobile/ios/OpenBitFun/Features/Chat/ChatTimelineView.swift @@ -1260,6 +1260,85 @@ private struct CollapsedToolsRow: View { } } + +private struct MobileTodoItem: Identifiable { + let id: String + let content: String + let status: String +} + +private struct TodoToolCard: View { + let tool: MobileTimelineTool + @ObservedObject var model: MobileAppModel + @Binding var expandedToolID: String? + private var expanded: Bool { expandedToolID == tool.id } + + private var items: [MobileTodoItem] { + let values = [tool.input, tool.output] + for value in values { + guard let data = value.data(using: .utf8), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let todos = object["todos"] as? [[String: Any]] else { continue } + let parsed = todos.enumerated().compactMap { index, raw -> MobileTodoItem? in + guard let content = raw["content"] as? String, !content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return nil } + return MobileTodoItem(id: "\(tool.id)-\(index)", content: content, status: raw["status"] as? String ?? "pending") + } + if !parsed.isEmpty { return parsed } + } + return [] + } + + var body: some View { + let done = items.filter { $0.status.lowercased() == "completed" }.count + let active = items.first(where: { $0.status.lowercased() == "in_progress" }) ?? items.first(where: { $0.status.lowercased() == "pending" }) + let summary = items.isEmpty ? tool.target : (done == items.count ? model.localized("已完成") : active?.content ?? items[0].content) + VStack(alignment: .leading, spacing: 9) { + Button { withAnimation(.easeOut(duration: 0.18)) { expandedToolID = expanded ? nil : tool.id } } label: { + HStack(spacing: 9) { + Image(systemName: "checklist").font(.system(size: 14, weight: .semibold)) + .foregroundStyle(OpenBitFunTheme.statusSuccess).frame(width: 22, height: 22) + .background(OpenBitFunTheme.statusSuccess.opacity(0.12), in: RoundedRectangle(cornerRadius: 6)) + VStack(alignment: .leading, spacing: 2) { + Text(model.localized("更新待办")).font(MobileDesignTypography.bodySmall.font.weight(.semibold)) + Text(summary).font(MobileDesignTypography.labelSmall.font) + .foregroundStyle(OpenBitFunTheme.muted).lineLimit(1) + } + Spacer(minLength: 4) + if !items.isEmpty { Text("\(done)/\(items.count)").font(MobileDesignTypography.labelSmall.font).foregroundStyle(OpenBitFunTheme.muted) } + Image(systemName: expanded ? "chevron.down" : "chevron.right").font(MobileDesignTypography.labelSmall.font).foregroundStyle(OpenBitFunTheme.muted) + }.frame(minHeight: 32).contentShape(Rectangle()) + }.buttonStyle(.plain).accessibilityIdentifier("tool.toggle.\(tool.id)") + if expanded && !items.isEmpty { + VStack(alignment: .leading, spacing: 8) { + GeometryReader { geometry in + ZStack(alignment: .leading) { + Capsule().fill(OpenBitFunTheme.line) + Capsule().fill(OpenBitFunTheme.muted).frame(width: geometry.size.width * CGFloat(done) / CGFloat(max(items.count, 1))) + } + }.frame(height: 2) + + ForEach(items) { item in + HStack(alignment: .top, spacing: 8) { + Text(item.status.lowercased() == "completed" ? "✓" : item.status.lowercased() == "cancelled" ? "×" : item.status.lowercased() == "in_progress" ? "•" : "○") + .foregroundStyle(OpenBitFunTheme.muted) + Text(item.content).font(MobileDesignTypography.bodySmall.font) + .foregroundStyle(item.status.lowercased() == "in_progress" ? OpenBitFunTheme.ink : OpenBitFunTheme.muted) + } + } + }.padding(.leading, 31) + } + if expanded && items.isEmpty && !tool.output.isEmpty { + VStack(alignment: .leading, spacing: 3) { + Text(model.localized("输出")).font(MobileDesignTypography.labelSmall.font).foregroundStyle(OpenBitFunTheme.muted) + Text(tool.output).font(.system(size: 12.5, design: .monospaced)).foregroundStyle(OpenBitFunTheme.muted).lineLimit(5) + } + } + } + .padding(10).background(OpenBitFunTheme.soft, in: RoundedRectangle(cornerRadius: 14)) + .overlay(RoundedRectangle(cornerRadius: 14).stroke(OpenBitFunTheme.line, lineWidth: 1)) + } +} + private struct ToolStatusRow: View { let tool: MobileTimelineTool @ObservedObject var model: MobileAppModel @@ -1291,7 +1370,11 @@ private struct ToolStatusRow: View { } } + @ViewBuilder private var toolRow: some View { + if isTodoTool { + TodoToolCard(tool: tool, model: model, expandedToolID: $expandedToolID) + } else { VStack(alignment: .leading, spacing: 8) { Button { if !tool.input.isEmpty || !tool.output.isEmpty || !tool.filePath.isEmpty { @@ -1374,6 +1457,11 @@ private struct ToolStatusRow: View { .padding(emphasized ? 10 : 0).background(emphasized ? OpenBitFunTheme.soft : OpenBitFunTheme.transparent) .clipShape(RoundedRectangle(cornerRadius: 14)) .overlay { if emphasized { RoundedRectangle(cornerRadius: 14).stroke(OpenBitFunTheme.line, lineWidth: 1) } } + } + } + + private var isTodoTool: Bool { + tool.kind == "TODO" || tool.operation == "UPDATE_TODOS" || tool.name.lowercased() == "todowrite" } private func compactApprovalAction(_ title: String, primary: Bool, action: @escaping () -> Void) -> some View { diff --git a/src/apps/mobile/ios/OpenBitFun/Info.plist b/src/apps/mobile/ios/OpenBitFun/Info.plist index c0b7ddcb5d..eb1f2ee3cc 100644 --- a/src/apps/mobile/ios/OpenBitFun/Info.plist +++ b/src/apps/mobile/ios/OpenBitFun/Info.plist @@ -16,6 +16,19 @@ 1.0.2 CFBundleVersion 1 + CFBundleURLTypes + + + CFBundleTypeRole + Viewer + CFBundleURLName + com.openbitfun.mobile.ios.auth + CFBundleURLSchemes + + openbitfun + + + NSCameraUsageDescription 扫描桌面端二维码以连接 OpenBitFun NSMicrophoneUsageDescription diff --git a/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel+Account.swift b/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel+Account.swift index 59a6d90b19..9a06e564ae 100644 --- a/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel+Account.swift +++ b/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel+Account.swift @@ -189,6 +189,10 @@ extension MobileAppModel { } } + func notifyAuthorizationCallback() { + coreAdapter?.notifyAuthorizationCallback() + } + func retryAccountFailure() { guard accountFailureStage == "DEVICE_LIST", accountFailureCanRetry, !accountBusy else { return } accountBusy = true @@ -219,6 +223,7 @@ extension MobileAppModel { if components.scheme == "https", components.host == "auth.openbitfun.com" { var items = (components.queryItems ?? []).filter { $0.name != "locale" } items.append(URLQueryItem(name: "locale", value: appLanguage.rawValue)) + items.append(URLQueryItem(name: "returnTo", value: "openbitfun://auth/callback")) components.queryItems = items } return components.url @@ -266,7 +271,14 @@ extension MobileAppModel { if pairingError != nil { pairingSheetOpen = true } return } - if ready.selectedDeviceId == nil, + // A persisted target is only a preference. If it is no longer in + // the directory or has gone offline, recover to the first online + // controllable device instead of leaving the user on the empty + // "选择设备和工作区" gate indefinitely. + let selectedDeviceIsOnline = ready.selectedDeviceId.flatMap { selectedID in + ready.devices.first(where: { $0.id == selectedID })?.online + } ?? false + if !selectedDeviceIsOnline, let target = ready.devices.first(where: { $0.online }) { accountBusy = true coreAdapter?.selectAccountDevice(id: target.id) diff --git a/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel+RemoteSession.swift b/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel+RemoteSession.swift index e2dff7af7c..d2e6402e38 100644 --- a/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel+RemoteSession.swift +++ b/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel+RemoteSession.swift @@ -67,6 +67,7 @@ extension MobileAppModel { remoteInitialWorkspaceReady = false remoteLastAppliedAuthority = nil remoteSessions = [] + pendingRemoteSessionDeletions.removeAll() remoteWorkspaces = [] remoteAssistants = [] workspaceCatalog = [] @@ -744,16 +745,27 @@ extension MobileAppModel { remoteSessions.removeAll { $0.id == session.id } remoteSessions.insert(session, at: 0) rebuildRemoteWorkspaceGroups() + // Creation only acknowledges the new row. Start the same + // conversation hydration path used when opening an existing row; + // without this dispatch the iOS surface selects the id but keeps + // the transcript unbound to the newly created session. + beginRemoteConversationOpen(sessionID: session.id) + coreAdapter?.openRemoteSession(sessionID: session.id) case let failed as CreateSessionOperationStateFailed: remoteCreateSubmitting = false remoteCreateError = failed.unsupported ? localized("桌面端不支持创建此类会话") : localized("创建远程会话失败,请重试") remoteCreateRequestID = nil + // Creation never committed a session. Close the create route so + // the previously selected conversation (or remote home) is + // restored, matching the Android/Harmony failure transition. + remoteCreateOpen = false case is CreateSessionOperationStateCancelled: remoteCreateSubmitting = false remoteCreateError = localized("创建远程会话已取消") remoteCreateRequestID = nil + remoteCreateOpen = false case is CreateSessionOperationStateIdle: if remoteCreateSubmitting { remoteCreateSubmitting = false @@ -770,11 +782,43 @@ extension MobileAppModel { func deleteRemoteSession(_ session: ChatSession) { guard !busy else { return } + pendingRemoteSessionDeletions.insert(session.id) coreAdapter?.deleteRemoteSession(sessionID: session.id) - if selectedSessionID == session.id { - remoteSessionSelected = false - timelineRows = [] - messages = [] + } + + private func reconcileDirectoryAfterRemoteDeletion(_ sessions: [ChatSession], busy: Bool) { + guard !pendingRemoteSessionDeletions.isEmpty, !busy else { return } + let currentIDs = Set(sessions.map(\.id)) + let succeeded = pendingRemoteSessionDeletions.filter { !currentIDs.contains($0) } + let failed = pendingRemoteSessionDeletions.filter { currentIDs.contains($0) } + pendingRemoteSessionDeletions.subtract(succeeded) + pendingRemoteSessionDeletions.subtract(failed) + guard !succeeded.isEmpty else { return } + deviceDirectory = deviceDirectory.map { device in + MobileDeviceDirectoryEntry( + id: device.id, + name: device.name, + online: device.online, + status: device.status, + error: device.error, + workspaces: device.workspaces.map { workspace in + MobileWorkspaceGroup( + workspaceId: workspace.workspaceId, + path: workspace.path, + name: workspace.name, + selected: workspace.selected, + sessions: workspace.sessions.filter { !succeeded.contains($0.id) }, + deviceKey: workspace.deviceKey, + directoryExpanded: workspace.directoryExpanded, + directoryStatus: workspace.directoryStatus, + remoteConnectionId: workspace.remoteConnectionId, + remoteSshHost: workspace.remoteSshHost + ) + }, + sessions: device.sessions.filter { !succeeded.contains($0.id) }, + catalogSource: device.catalogSource, + recentWorkspaces: device.recentWorkspaces + ) } } @@ -1060,6 +1104,7 @@ extension MobileAppModel { projectedSessions.insert(committed.session, at: 0) } setPublishedIfChanged(\.remoteSessions, to: projectedSessions) + reconcileDirectoryAfterRemoteDeletion(projectedSessions, busy: ready.busy) rebuildRemoteWorkspaceGroups() if let protected = committedRemoteCreate, protected.targetKey == targetKey, @@ -1072,6 +1117,12 @@ extension MobileAppModel { } ?? false if !openingSessionIsNotReady, let selected = ready.selectedSessionId { setPublishedIfChanged(\.selectedSessionID, to: selected) + } else if !openingSessionIsNotReady { + // A successful delete of the open session publishes a Ready + // state with no selection. Clear the route identity as well as + // the visible transcript so a later sidebar/create action + // cannot inherit the deleted session id. + setPublishedIfChanged(\.selectedSessionID, to: "") } setPublishedIfChanged( \.remoteSessionSelected, diff --git a/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel.swift b/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel.swift index f6fe7993ea..873647bae1 100644 --- a/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel.swift +++ b/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileAppModel.swift @@ -77,6 +77,10 @@ final class MobileAppModel: ObservableObject { @Published var pairingSheetOpen = false @Published var pairingScanRequested = false var pendingDeviceLink: String? + /// Session ids awaiting the authoritative delete result. The directory + /// sidebar has its own cached projection, so it must be reconciled when a + /// delete succeeds even if the deleted session was not the open one. + var pendingRemoteSessionDeletions: Set = [] @Published var pairingBusy = false @Published var pairingError: String? @Published var coreErrorMessage: String? diff --git a/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileCoreAdapter.swift b/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileCoreAdapter.swift index 9cc9e441cf..e723160103 100644 --- a/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileCoreAdapter.swift +++ b/src/apps/mobile/ios/OpenBitFun/Infrastructure/MobileCoreAdapter.swift @@ -157,6 +157,10 @@ final class MobileCoreAdapter { account.dispatch(intent: AccountIntentLogin.shared) } + func notifyAuthorizationCallback() { + account.notifyAuthorizationCallback() + } + func selectAccountDevice(id: String) { pendingDirectoryReconciles.removeAll() desiredRemoteTarget = .account(deviceID: id) diff --git a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/account/AccountStore.kt b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/account/AccountStore.kt index eb16812fca..ffaaaa34d4 100644 --- a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/account/AccountStore.kt +++ b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/account/AccountStore.kt @@ -24,6 +24,7 @@ import kotlinx.coroutines.ensureActive import kotlinx.coroutines.isActive import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -99,9 +100,19 @@ public class AccountStore internal constructor( /** Latest account membership snapshot, used to authorize explicit device stores. */ private var controllableDevices: List = emptyList() private var pendingInitialDeviceSelection = false + private val authorizationWakeups = kotlinx.coroutines.flow.MutableSharedFlow(extraBufferCapacity = 1) + + init { + (backend as? CloudBackend)?.setAuthorizationWakeups(authorizationWakeups) + } public fun resumeSessionStreams() { backend.resumeSessionStreams() } + /** Wakes an in-flight browser authorization poll after a native deep link. */ + public fun notifyAuthorizationCallback() { + authorizationWakeups.tryEmit(0L) + } + public fun dispatch(intent: AccountIntent) { when (intent) { AccountIntent.Restore -> restore() @@ -650,11 +661,20 @@ internal object AuthorizationPoll { start: com.openbitfun.mobile.core.transport.GitHubAuthorization, log: TransportLog, nowSeconds: () -> Long = { kotlin.time.Clock.System.now().epochSeconds }, + wake: kotlinx.coroutines.flow.Flow = kotlinx.coroutines.flow.flow { kotlinx.coroutines.awaitCancellation() }, poll: suspend () -> com.openbitfun.mobile.core.transport.GitHubAuthorizationPoll, ): String { var lastTransient: CloudAccountException? = null + var firstPoll = true while (nowSeconds() < start.expiresAt) { - kotlinx.coroutines.delay(start.pollIntervalSeconds.coerceIn(1, 30) * 1000L) + // Poll immediately after the browser handoff so a completed + // transaction is not held behind the normal server interval. + if (!firstPoll) { + kotlinx.coroutines.withTimeoutOrNull(start.pollIntervalSeconds.coerceIn(1, 30) * 1000L) { + wake.first() + } + } + firstPoll = false val result = try { poll() } catch (cause: CloudAccountException) { @@ -679,6 +699,12 @@ private class CloudBackend( private val client: CloudAccountClient, private val log: TransportLog, ) : AccountBackend { + private var authorizationWakeups: kotlinx.coroutines.flow.Flow = emptyFlow() + + fun setAuthorizationWakeups(flow: kotlinx.coroutines.flow.Flow) { + authorizationWakeups = flow + } + override suspend fun login( relayUrl: String, deviceId: String, @@ -688,7 +714,7 @@ private class CloudBackend( ): AccountSessionData { val start = client.startAuthorization(relayUrl) onAuthorization(start.authorizationUrl) - val token = AuthorizationPoll.awaitAccessToken(start, log) { client.pollAuthorization(relayUrl, start) } + val token = AuthorizationPoll.awaitAccessToken(start, log, poll = { client.pollAuthorization(relayUrl, start) }, wake = authorizationWakeups) val session = client.login(relayUrl, token, deviceId, deviceName, deviceSecret) return AccountSessionData( relayUrl = relayUrl, diff --git a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/session/RemoteSessionStore.kt b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/session/RemoteSessionStore.kt index 28297a511c..75d676dcea 100644 --- a/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/session/RemoteSessionStore.kt +++ b/src/apps/mobile/shared/core-feature/src/commonMain/kotlin/com/openbitfun/mobile/core/feature/session/RemoteSessionStore.kt @@ -1546,19 +1546,62 @@ public class RemoteSessionStore internal constructor( } if (!isCurrentWork(operationToken)) return@launch val ready = (_state.value as? RemoteSessionUiState.Ready) ?: current - publishAuthorityReady(ready.copy( + val afterDelete = 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, - )) + ) + publishAuthorityReady(afterDelete) + + // Keep the visible page full after a deletion, matching the + // Harmony controller's backfill behavior. The deleted row is + // removed locally first so the sidebar updates immediately; + // only then do we fetch the next row when the server says more + // sessions exist. This also preserves the current selection + // when a non-current row is deleted. + val pageLimit = if (afterDelete.agentFilter == SessionAgentFilter.ALL) PAGE_SIZE else FILTER_PAGE_SIZE + if (afterDelete.hasMore && afterDelete.sessions.size < pageLimit) { + val refillToken = beginWork() + work = scope.launch { + try { + setBusy(afterDelete, true) + val page = listSessions(afterDelete.sessions.size, afterDelete.query, afterDelete.agentFilter) + if (!isCurrentWork(refillToken)) return@launch + val latest = (_state.value as? RemoteSessionUiState.Ready) ?: afterDelete + val known = latest.sessions.mapTo(mutableSetOf()) { it.id } + val sessions = latest.sessions + page.sessions.filterNot { it.id in known } + if (persistenceEnabled && latest.query.isEmpty() && latest.agentFilter == SessionAgentFilter.ALL) { + savePersistedSessions(sessions, page.hasMore) + } + publishAuthorityReady(latest.copy( + sessions = sessions, + hasMore = page.hasMore, + busy = false, + )) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Throwable) { + if (isCurrentWork(refillToken)) { + setBusy((_state.value as? RemoteSessionUiState.Ready) ?: afterDelete, false) + } + } + } + } } catch (cancelled: CancellationException) { throw cancelled } catch (error: Throwable) { if (isCurrentWork(operationToken)) { setBusy((_state.value as? RemoteSessionUiState.Ready) ?: current, false) handleFailure(error, (_state.value as? RemoteSessionUiState.Ready) ?: current) + // beginWork() cancels the live transcript while the + // destructive command is in flight. If the host rejects + // the delete, keep the existing conversation usable by + // restoring that subscription before returning control. + if (current.selectedSessionId == normalized && isCurrentWork(operationToken)) { + runCatching { openSession(normalized, operationToken) } + } } } } diff --git a/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/account/AccountStoreTest.kt b/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/account/AccountStoreTest.kt index 7dcf3bfb07..ef67b23234 100644 --- a/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/account/AccountStoreTest.kt +++ b/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/account/AccountStoreTest.kt @@ -72,6 +72,21 @@ class AccountStoreTest { assertEquals(CloudAccountFailure.NETWORK, expired.failure) } + @Test fun signInPollChecksImmediatelyAfterBrowserHandoff() = runTest { + val start = GitHubAuthorization("txn", "secret", "https://auth.openbitfun.com/sign-in#ticket=t", 100L, 30) + var firstPollAt = -1L + val token = AuthorizationPoll.awaitAccessToken( + start, + TransportLog.None, + nowSeconds = { testScheduler.currentTime / 1000L }, + ) { + firstPollAt = testScheduler.currentTime + GitHubAuthorizationPoll("authorized", GitHubTokens("granted")) + } + assertEquals("granted", token) + assertEquals(0L, firstPollAt) + } + @Test fun cancelledLoginDirectoryCannotReviveAccountOrPersistSelectedDevice() = runTest { for (failure in listOf(null, CloudAccountException(CloudAccountFailure.NETWORK), CloudAccountException(CloudAccountFailure.AUTHENTICATION), IllegalStateException("Late failure"))) { diff --git a/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/session/RemoteSessionStoreTest.kt b/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/session/RemoteSessionStoreTest.kt index f4523c2874..197b96f32b 100644 --- a/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/session/RemoteSessionStoreTest.kt +++ b/src/apps/mobile/shared/core-feature/src/commonTest/kotlin/com/openbitfun/mobile/core/feature/session/RemoteSessionStoreTest.kt @@ -1588,6 +1588,26 @@ class RemoteSessionStoreTest { store.stop() } + @Test + fun deleteSessionDropsNonCurrentRowAndKeepsTheOpenConversation() = runTest { + val transport = FakeSessionTransport() + val store = RemoteSessionStore(this, transport) + store.dispatch(RemoteSessionIntent.Load) + advanceUntilIdle() + store.dispatch(RemoteSessionIntent.Open("s-code")) + runCurrent() + + store.dispatch(RemoteSessionIntent.DeleteSession("s-cowork")) + runCurrent() + + val ready = assertIs(store.state.value) + assertEquals(listOf("s-code", "s-agentic"), ready.sessions.map { it.id }) + assertEquals("s-code", ready.selectedSessionId) + assertEquals("s-code", ready.timeline?.sessionId) + assertEquals("s-cowork", transport.commands.first { it.cmd == "delete_session" }.sessionId) + store.stop() + } + @Test fun refreshCancellingSendDoesNotLeavePendingTurnForever() = runTest { val transport = FakeSessionTransport()