diff --git a/app/src/main/java/ai/multica/app/ChatExperiencePolicy.java b/app/src/main/java/ai/multica/app/ChatExperiencePolicy.java index 884efa6..8685c3e 100644 --- a/app/src/main/java/ai/multica/app/ChatExperiencePolicy.java +++ b/app/src/main/java/ai/multica/app/ChatExperiencePolicy.java @@ -1,8 +1,12 @@ package ai.multica.app; final class ChatExperiencePolicy { - private static final float FOCUSED_COMPOSER_RATIO = 0.60f; - private static final int MIN_FOCUSED_COMPOSER_HEIGHT = 280; + private static final float CHAT_FOCUSED_COMPOSER_RATIO = 0.22f; + private static final float ISSUE_FOCUSED_COMPOSER_RATIO = 0.18f; + private static final int MIN_CHAT_FOCUSED_COMPOSER_HEIGHT = 132; + private static final int MAX_CHAT_FOCUSED_COMPOSER_HEIGHT = 184; + private static final int MIN_ISSUE_FOCUSED_COMPOSER_HEIGHT = 112; + private static final int MAX_ISSUE_FOCUSED_COMPOSER_HEIGHT = 152; enum Route { CHAT, @@ -21,12 +25,25 @@ final class ChatExperiencePolicy { return false; } + static int focusedComposerHeightPx(Route route, int usableHeightPx) { + int height = Math.max(0, usableHeightPx); + if (route == Route.ISSUE_COMMENT || route == Route.ISSUE_REPLY) { + int target = Math.round(height * ISSUE_FOCUSED_COMPOSER_RATIO); + return clamp(target, MIN_ISSUE_FOCUSED_COMPOSER_HEIGHT, MAX_ISSUE_FOCUSED_COMPOSER_HEIGHT); + } + int target = Math.round(height * CHAT_FOCUSED_COMPOSER_RATIO); + return clamp(target, MIN_CHAT_FOCUSED_COMPOSER_HEIGHT, MAX_CHAT_FOCUSED_COMPOSER_HEIGHT); + } + static int focusedComposerHeightPx(int usableHeightPx) { - int target = Math.round(Math.max(0, usableHeightPx) * FOCUSED_COMPOSER_RATIO); - return Math.max(MIN_FOCUSED_COMPOSER_HEIGHT, target); + return focusedComposerHeightPx(Route.CHAT, usableHeightPx); } static boolean isFocusedComposerRoute(Route route) { return route == Route.CHAT || route == Route.ISSUE_COMMENT || route == Route.ISSUE_REPLY; } + + private static int clamp(int value, int min, int max) { + return Math.max(min, Math.min(max, value)); + } } diff --git a/app/src/main/java/ai/multica/app/ComposePilotActivity.kt b/app/src/main/java/ai/multica/app/ComposePilotActivity.kt index 2e541c1..2e4f5d9 100644 --- a/app/src/main/java/ai/multica/app/ComposePilotActivity.kt +++ b/app/src/main/java/ai/multica/app/ComposePilotActivity.kt @@ -3420,7 +3420,8 @@ private fun PilotChatMessages( onArchived: () -> Unit, ) { var state by remember(session.id) { mutableStateOf?>(null) } - var refresh by remember(session.id) { mutableIntStateOf(0) } + var messagesRefresh by remember(session.id) { mutableIntStateOf(0) } + var pendingRefresh by remember(session.id) { mutableIntStateOf(0) } var draft by remember(session.id) { mutableStateOf("") } var sending by remember(session.id) { mutableStateOf(false) } var locallyPending by remember(session.id) { mutableStateOf(false) } @@ -3434,15 +3435,18 @@ private fun PilotChatMessages( var composerFocused by remember(session.id) { mutableStateOf(false) } var actionError by remember(session.id) { mutableStateOf(null) } val configuration = LocalConfiguration.current - val focusedComposerHeight = ChatExperiencePolicy.focusedComposerHeightPx(configuration.screenHeightDp).dp + val focusedComposerHeight = ChatExperiencePolicy.focusedComposerHeightPx( + ChatExperiencePolicy.Route.CHAT, + configuration.screenHeightDp, + ).dp val listState = rememberLazyListState() val scope = rememberCoroutineScope() val hazeState = rememberHazeState() val haptic = LocalHapticFeedback.current - LaunchedEffect(session.id, refresh) { - state = null - state = withContext(Dispatchers.IO) { + LaunchedEffect(session.id, messagesRefresh) { + val previous = state + val result = withContext(Dispatchers.IO) { runCatching { val messages = api.chatMessages(workspaceId, session.id) val pending = runCatching { api.pendingChatTask(workspaceId, session.id) } @@ -3454,14 +3458,35 @@ private fun PilotChatMessages( PilotChatMessagesData(messages, pending, agents) } } + if (result.isSuccess || previous == null || previous.isFailure) { + state = result + } else { + actionError = "${if (zh) "消息刷新失败" else "Message refresh failed"}: ${result.exceptionOrNull()?.message ?: result.exceptionOrNull().toString()}" + } } - LaunchedEffect(sending, state?.getOrNull()?.pendingTask?.taskId) { + LaunchedEffect(session.id, pendingRefresh) { + val current = state?.getOrNull() ?: return@LaunchedEffect + val previousPendingTaskId = current.pendingTask.taskId + val result = withContext(Dispatchers.IO) { + runCatching { api.pendingChatTask(workspaceId, session.id) } + } + result.onSuccess { pending -> + state = Result.success(current.copy(pendingTask = pending)) + if (validChatTaskId(previousPendingTaskId) && !validChatTaskId(pending.taskId)) { + messagesRefresh++ + } + }.onFailure { + actionError = "${if (zh) "任务状态刷新失败" else "Task status refresh failed"}: ${it.message ?: it.toString()}" + } + } + + LaunchedEffect(sending, locallyPending, state?.getOrNull()?.pendingTask?.taskId) { val taskId = state?.getOrNull()?.pendingTask?.taskId.orEmpty() - if (!sending && taskId.isBlank()) return@LaunchedEffect - while (sending || taskId.isNotBlank()) { - delay(5_000) - refresh++ + if (!sending && !locallyPending && !validChatTaskId(taskId)) return@LaunchedEffect + while (sending || locallyPending || validChatTaskId(taskId)) { + delay(2_000) + pendingRefresh++ } } @@ -3523,7 +3548,10 @@ private fun PilotChatMessages( runCatching { api.cancelTaskById(workspaceId, task.taskId) } } cancelling = false - result.onSuccess { refresh++ } + result.onSuccess { + pendingRefresh++ + messagesRefresh++ + } .onFailure { actionError = "${if (zh) "取消失败" else "Cancel failed"}: ${it.message ?: it.toString()}" } } } @@ -3698,7 +3726,7 @@ private fun PilotChatMessages( ) { MulticaErrorState( message = "${if (zh) "消息加载失败" else "Messages failed"}\n${loaded.exceptionOrNull()?.message.orEmpty()}", - onRetry = { refresh++ }, + onRetry = { messagesRefresh++ }, ) } else -> LazyColumn( @@ -3719,15 +3747,25 @@ private fun PilotChatMessages( ) } } - items(messages) { message -> + items( + items = messages, + key = { message -> + message.id.ifBlank { "${message.role}:${message.createdAt}:${message.content.hashCode()}" } + }, + ) { message -> ChatMessageBubble(message = message, zh = zh, api = api, workspaceId = workspaceId) } - if (sending || locallyPending || validChatTaskId(activePending.taskId)) { + val activePendingTaskId = activePending.taskId + val pendingAlreadyPersisted = validChatTaskId(activePendingTaskId) && + messages.any { message -> + !message.role.equals("user", ignoreCase = true) && message.taskId == activePendingTaskId + } + if ((sending || locallyPending || validChatTaskId(activePendingTaskId)) && !pendingAlreadyPersisted) { item(key = "chat-latest-progress") { ChatPendingTimelineCard( api = api, workspaceId = workspaceId, - taskId = activePending.taskId, + taskId = activePendingTaskId, status = activePending.status.ifBlank { if (sending || locallyPending) "queued" else "" }, zh = zh, ) @@ -3782,8 +3820,8 @@ private fun PilotChatMessages( modifier = Modifier.weight(1f), label = if (zh) "发送 Markdown 消息..." else "Send a Markdown message...", contentDescription = "Chat Message Input", - minLines = if (composerFocused) 12 else 1, - maxLines = if (composerFocused) 18 else 4, + minLines = if (composerFocused) 4 else 1, + maxLines = if (composerFocused) 8 else 4, onFocusedChange = { composerFocused = it }, ) MulticaIconPillButton( @@ -3803,7 +3841,8 @@ private fun PilotChatMessages( sending = false result.onSuccess { draft = "" - refresh++ + pendingRefresh++ + messagesRefresh++ }.onFailure { locallyPending = false sendError = "${if (zh) "发送失败" else "Send failed"}: ${it.message ?: it.toString()}" @@ -3956,21 +3995,6 @@ private fun ChatMessageBubble( onDismissRequest = { moreOpen = false }, ) } - if (!isUser && api != null && !workspaceId.isNullOrBlank() && validChatTaskId(message.taskId)) { - Column( - modifier = Modifier - .fillMaxWidth() - .semantics { contentDescription = "Chat Assistant Bubble Timeline ${message.taskId}" }, - verticalArrangement = Arrangement.spacedBy(7.dp), - ) { - ChatTaskWebLiveBanner( - title = if (zh) "Agent 执行过程" else "Agent timeline", - subtitle = Models.shortId(message.taskId), - active = false, - ) - ChatTaskTimelineRows(api = api, workspaceId = workspaceId, taskId = message.taskId, zh = zh) - } - } if (meta.isNotBlank()) { Text( text = meta, @@ -4036,45 +4060,29 @@ private fun ChatPendingTimelineCard( active = true, ) if (taskId.isNotBlank()) { - ChatTaskTimelineRows(api = api, workspaceId = workspaceId, taskId = taskId, zh = zh) + ChatTaskTimelineRows(api = api, workspaceId = workspaceId, taskId = taskId, zh = zh, active = true) } } } } } -@Composable -private fun ChatTaskTimeline( - api: ApiClient, - workspaceId: String, - taskId: String, - zh: Boolean, -) { - Column( - modifier = Modifier - .fillMaxWidth() - .semantics { contentDescription = "Chat Task Timeline $taskId" }, - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - ChatTaskWebLiveBanner( - title = if (zh) "Agent 执行过程" else "Agent timeline", - subtitle = Models.shortId(taskId), - active = false, - ) - ChatTaskTimelineRows(api = api, workspaceId = workspaceId, taskId = taskId, zh = zh) - } -} - @Composable private fun ChatTaskTimelineRows( api: ApiClient, workspaceId: String, taskId: String, zh: Boolean, + active: Boolean, ) { var state by remember(taskId, workspaceId) { mutableStateOf>?>(null) } - LaunchedEffect(taskId, workspaceId) { - state = withContext(Dispatchers.IO) { runCatching { api.runMessages(taskId, workspaceId) } } + LaunchedEffect(taskId, workspaceId, active) { + if (taskId.isBlank()) return@LaunchedEffect + do { + state = withContext(Dispatchers.IO) { runCatching { api.runMessages(taskId, workspaceId) } } + if (!active) break + delay(1_500) + } while (active) } when (val loaded = state) { null -> ChatTaskWebEmptyRow( @@ -10773,11 +10781,14 @@ private fun IssueCommentReplyBox( var mentionQuery by remember(parentId) { mutableStateOf("") } var replyFocused by remember(parentId) { mutableStateOf(false) } val configuration = LocalConfiguration.current - val focusedComposerHeight = ChatExperiencePolicy.focusedComposerHeightPx(configuration.screenHeightDp).dp + val focusedComposerHeight = ChatExperiencePolicy.focusedComposerHeightPx( + ChatExperiencePolicy.Route.ISSUE_REPLY, + configuration.screenHeightDp, + ).dp val focusRequester = remember(parentId) { FocusRequester() } val keyboard = LocalSoftwareKeyboardController.current val focusManager = LocalFocusManager.current -val replyActionModifier = Modifier.size(42.dp) + val replyActionModifier = Modifier.size(42.dp) val mentionItems = remember(agents, members, squads, mentionQuery) { val query = mentionQuery.trim().lowercase() val agentItems = agents @@ -10849,8 +10860,8 @@ val replyActionModifier = Modifier.size(42.dp) .focusRequester(focusRequester) .semantics(mergeDescendants = true) { contentDescription = "Issue Comment Reply Input $parentId" }, label = if (zh) "写回复..." else "Write a reply...", - minLines = if (replyFocused) 12 else 3, - maxLines = if (replyFocused) 18 else 7, + minLines = if (replyFocused) 3 else 3, + maxLines = if (replyFocused) 6 else 7, onFocusedChange = { replyFocused = it }, ) if (pendingAttachments.isNotEmpty()) { @@ -11156,7 +11167,10 @@ private fun IssueCommentInputBar( var mentionQuery by remember { mutableStateOf("") } var commentFocused by remember { mutableStateOf(false) } val configuration = LocalConfiguration.current - val focusedComposerHeight = ChatExperiencePolicy.focusedComposerHeightPx(configuration.screenHeightDp).dp + val focusedComposerHeight = ChatExperiencePolicy.focusedComposerHeightPx( + ChatExperiencePolicy.Route.ISSUE_COMMENT, + configuration.screenHeightDp, + ).dp val mentionItems = remember(agents, members, squads, mentionQuery) { val query = mentionQuery.trim().lowercase() val agentItems = agents @@ -11247,8 +11261,8 @@ private fun IssueCommentInputBar( placeholder = if (zh) "评论" else "Comment", showLabel = false, contentDescription = "Issue Comment Input", - minLines = if (commentFocused) 12 else 1, - maxLines = if (commentFocused) 18 else 4, + minLines = if (commentFocused) 3 else 1, + maxLines = if (commentFocused) 6 else 4, onFocusedChange = { commentFocused = it }, ) Row(