From fb7c7d213b912f7102fa1f2f42180ec43b9c59d4 Mon Sep 17 00:00:00 2001 From: Xisheng-Zhao Date: Sun, 7 Jun 2026 04:34:22 +0800 Subject: [PATCH 1/4] Improve chat and comment composer experience --- .../ai/multica/app/ChatExperiencePolicy.java | 32 ++ .../ai/multica/app/ComposePilotActivity.kt | 451 +++++++++--------- .../app/ui/components/MulticaComponents.kt | 7 +- 3 files changed, 276 insertions(+), 214 deletions(-) create mode 100644 app/src/main/java/ai/multica/app/ChatExperiencePolicy.java diff --git a/app/src/main/java/ai/multica/app/ChatExperiencePolicy.java b/app/src/main/java/ai/multica/app/ChatExperiencePolicy.java new file mode 100644 index 0000000..884efa6 --- /dev/null +++ b/app/src/main/java/ai/multica/app/ChatExperiencePolicy.java @@ -0,0 +1,32 @@ +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; + + enum Route { + CHAT, + ISSUE_COMMENT, + ISSUE_REPLY + } + + private ChatExperiencePolicy() { + } + + static String newChatInitialTitle() { + return ""; + } + + static boolean requiresTitleBeforeEnteringChat() { + return false; + } + + static int focusedComposerHeightPx(int usableHeightPx) { + int target = Math.round(Math.max(0, usableHeightPx) * FOCUSED_COMPOSER_RATIO); + return Math.max(MIN_FOCUSED_COMPOSER_HEIGHT, target); + } + + static boolean isFocusedComposerRoute(Route route) { + return route == Route.CHAT || route == Route.ISSUE_COMMENT || route == Route.ISSUE_REPLY; + } +} diff --git a/app/src/main/java/ai/multica/app/ComposePilotActivity.kt b/app/src/main/java/ai/multica/app/ComposePilotActivity.kt index 6cd886f..2e541c1 100644 --- a/app/src/main/java/ai/multica/app/ComposePilotActivity.kt +++ b/app/src/main/java/ai/multica/app/ComposePilotActivity.kt @@ -111,6 +111,7 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalHapticFeedback @@ -390,6 +391,7 @@ private data class PilotIssueFormOptions( private data class PilotChatMessagesData( val messages: List, val pendingTask: Models.ChatPendingTask, + val agents: List, ) private data class PilotChatSessionsData( @@ -1817,6 +1819,7 @@ private fun ComposePilotShell( session = activeChat, zh = zh, onBack = { chatSession = null }, + onSessionChanged = { chatSession = it }, onArchived = { chatSession = null chatOpen = false @@ -2968,32 +2971,6 @@ private fun InlineInboxAction( } } -@Composable -private fun ChatNewSessionWebPanel( - modifier: Modifier = Modifier, - content: @Composable ColumnScope.() -> Unit, -) { - val shape = RoundedCornerShape(MulticaVisualTokens.CardRadius) - Surface( - modifier = modifier - .fillMaxWidth() - .semantics { contentDescription = "Chat New Session Web Panel" }, - shape = shape, - color = MulticaColors.Surface, - tonalElevation = 0.dp, - shadowElevation = 0.dp, - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .border(1.dp, MulticaColors.Border.copy(alpha = 0.70f), shape) - .padding(horizontal = 12.dp, vertical = 11.dp), - verticalArrangement = Arrangement.spacedBy(10.dp), - content = content, - ) - } -} - @Composable private fun ChatSessionListWebPanel( modifier: Modifier = Modifier, @@ -3029,12 +3006,7 @@ private fun PilotChatSessions( ) { var state by remember(workspaceId) { mutableStateOf?>(null) } var refresh by remember(workspaceId) { mutableIntStateOf(0) } - var title by remember(workspaceId) { mutableStateOf("") } - var selectedAgentId by remember(workspaceId) { mutableStateOf(null) } - var agentPickerOpen by remember(workspaceId) { mutableStateOf(false) } - var agentQuery by remember(workspaceId) { mutableStateOf("") } var showAllSessions by remember(workspaceId) { mutableStateOf(false) } - var showCreateForm by remember(workspaceId) { mutableStateOf(false) } var creating by remember(workspaceId) { mutableStateOf(false) } var deletingSessionId by remember(workspaceId) { mutableStateOf(null) } var actionText by remember(workspaceId) { mutableStateOf(null) } @@ -3053,24 +3025,17 @@ private fun PilotChatSessions( ) } } - loaded.onSuccess { data -> - if (selectedAgentId == null || data.agents.none { it.id == selectedAgentId }) { - selectedAgentId = data.agents.firstOrNull()?.id - } - } state = loaded } fun createSession(data: PilotChatSessionsData) { if (creating) return - val agent = data.agents.firstOrNull { it.id == selectedAgentId } ?: data.agents.firstOrNull() + val agent = data.agents.firstOrNull() if (agent == null) { actionText = if (zh) "请先创建 Agent" else "Create an agent first" return } - val sessionTitle = title.trim().ifBlank { - if (zh) "新的聊天" else "New Chat" - } + val sessionTitle = ChatExperiencePolicy.newChatInitialTitle() creating = true actionText = null scope.launch { @@ -3079,9 +3044,9 @@ private fun PilotChatSessions( } creating = false result.onSuccess { session -> - title = "" actionText = if (zh) "聊天会话已创建" else "Chat session created" refresh++ + onSessionClick(session) }.onFailure { actionText = "${if (zh) "创建聊天失败" else "Create chat failed"}: ${it.message ?: it.toString()}" } @@ -3120,7 +3085,6 @@ private fun PilotChatSessions( ) { val data = loaded.getOrThrow() val sessions = data.sessions - val selectedAgent = data.agents.firstOrNull { it.id == selectedAgentId } ?: data.agents.firstOrNull() val agentNamesById = data.agents.associate { it.id to it.name } item { Row( @@ -3132,17 +3096,14 @@ private fun PilotChatSessions( verticalAlignment = Alignment.CenterVertically, ) { MulticaPillButton( - text = if (showCreateForm) { - if (zh) "收起新聊天" else "Hide New Chat" - } else { - if (zh) "新聊天" else "New Chat" - }, + text = if (creating) "..." else if (zh) "新聊天" else "New Chat", onClick = { - showCreateForm = !showCreateForm + createSession(data) actionText = null }, modifier = Modifier.weight(1f), - tone = if (showCreateForm) MulticaButtonTone.Secondary else MulticaButtonTone.Primary, + enabled = !creating, + tone = MulticaButtonTone.Primary, contentDescription = "Chat New Session Toggle", ) MulticaPillButton( @@ -3161,95 +3122,6 @@ private fun PilotChatSessions( ) } } - if (showCreateForm) { - item { - SettingsSectionLabel(if (zh) "新聊天" else "New Chat") - ChatNewSessionWebPanel( - modifier = Modifier.semantics { contentDescription = "Chat New Session Web Panel" }, - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .semantics(mergeDescendants = false) { contentDescription = "Chat New Session Web Form" }, - verticalArrangement = Arrangement.spacedBy(10.dp), - ) { - MulticaTextField( - value = title, - onValueChange = { title = it }, - label = if (zh) "标题" else "Title", - contentDescription = "Chat New Session Title", - singleLine = true, - modifier = Modifier - .fillMaxWidth() - .padding(top = 2.dp), - ) - ChatAgentPickerRow( - selectedAgent = selectedAgent, - expanded = agentPickerOpen, - zh = zh, - onClick = { - if (data.agents.isNotEmpty()) { - agentPickerOpen = !agentPickerOpen - actionText = null - } - }, - ) - } - if (agentPickerOpen) { - Column( - modifier = Modifier.fillMaxWidth().padding(vertical = 10.dp), - verticalArrangement = Arrangement.spacedBy(0.dp), - ) { - MulticaTextField( - value = agentQuery, - onValueChange = { agentQuery = it }, - label = if (zh) "搜索 Agent" else "Search Agents", - contentDescription = "Chat Agent Search", - singleLine = true, - modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp), - ) - val filteredAgents = data.agents.filter { agent -> - val haystack = "${agent.name}\n${agent.description}\n${agent.id}".lowercase() - haystack.contains(agentQuery.trim().lowercase()) - } - if (filteredAgents.isEmpty()) { - Text( - text = if (zh) "没有匹配的 Agent" else "No matching agents", - style = MaterialTheme.typography.bodyMedium, - color = MulticaColors.Muted, - modifier = Modifier.padding(vertical = 6.dp), - ) - } - filteredAgents.take(20).forEachIndexed { index, agent -> - ChatAgentWebDenseRow( - agent = agent, - selected = agent.id == selectedAgent?.id, - showDivider = index < filteredAgents.lastIndex && index < 19, - onClick = { - selectedAgentId = agent.id - agentPickerOpen = false - agentQuery = "" - actionText = null - }, - ) - } - } - } - Row( - modifier = Modifier - .fillMaxWidth() - .padding(top = 10.dp, bottom = 12.dp), - horizontalArrangement = Arrangement.End, - ) { - MulticaPillButton( - text = if (creating) "..." else if (zh) "创建聊天" else "Create Chat", - onClick = { createSession(data) }, - tone = MulticaButtonTone.Primary, - ) - } - } - } - } if (!actionText.isNullOrBlank()) { item { val isError = actionText!!.contains("failed", true) || actionText!!.contains("失败") @@ -3544,6 +3416,7 @@ private fun PilotChatMessages( session: Models.ChatSession, zh: Boolean, onBack: () -> Unit, + onSessionChanged: (Models.ChatSession) -> Unit, onArchived: () -> Unit, ) { var state by remember(session.id) { mutableStateOf?>(null) } @@ -3555,7 +3428,13 @@ private fun PilotChatMessages( var archiving by remember(session.id) { mutableStateOf(false) } var cancelling by remember(session.id) { mutableStateOf(false) } var headerMenuOpen by remember(session.id) { mutableStateOf(false) } + var agentPickerOpen by remember(session.id) { mutableStateOf(false) } + var agentQuery by remember(session.id) { mutableStateOf("") } + var switchingAgentId by remember(session.id) { mutableStateOf(null) } + 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 listState = rememberLazyListState() val scope = rememberCoroutineScope() val hazeState = rememberHazeState() @@ -3568,8 +3447,11 @@ private fun PilotChatMessages( val messages = api.chatMessages(workspaceId, session.id) val pending = runCatching { api.pendingChatTask(workspaceId, session.id) } .getOrElse { Models.ChatPendingTask(org.json.JSONObject()) } + val agents = api.agents(workspaceId) + .filter { it.archivedAt.isBlank() } + .sortedBy { it.name.lowercase() } runCatching { api.markChatSessionRead(workspaceId, session.id) } - PilotChatMessagesData(messages, pending) + PilotChatMessagesData(messages, pending, agents) } } } @@ -3646,6 +3528,35 @@ private fun PilotChatMessages( } } + fun switchAgent(agent: Models.Agent) { + if (agent.id == session.agentId || switchingAgentId != null) { + agentPickerOpen = false + return + } + switchingAgentId = agent.id + actionError = null + haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) + scope.launch { + val result = withContext(Dispatchers.IO) { + runCatching { + api.createChatSession( + workspaceId, + agent.id, + ChatExperiencePolicy.newChatInitialTitle(), + ) + } + } + switchingAgentId = null + result.onSuccess { next -> + agentPickerOpen = false + agentQuery = "" + onSessionChanged(next) + }.onFailure { + actionError = "${if (zh) "切换 Agent 失败" else "Switch agent failed"}: ${it.message ?: it.toString()}" + } + } + } + Column( modifier = Modifier .fillMaxSize() @@ -3711,6 +3622,57 @@ private fun PilotChatMessages( modifier = Modifier.padding(horizontal = 18.dp), ) } + loaded?.getOrNull()?.let { chatData -> + val activeAgent = chatData.agents.firstOrNull { it.id == session.agentId } ?: chatData.agents.firstOrNull() + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 18.dp, vertical = 8.dp) + .semantics(mergeDescendants = false) { contentDescription = "Chat Detail Agent Selector" }, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + ChatAgentPickerRow( + selectedAgent = activeAgent, + expanded = agentPickerOpen, + zh = zh, + onClick = { + if (chatData.agents.isNotEmpty() && switchingAgentId == null) { + agentPickerOpen = !agentPickerOpen + actionError = null + } + }, + ) + if (agentPickerOpen) { + MulticaTextField( + value = agentQuery, + onValueChange = { agentQuery = it }, + label = if (zh) "搜索 Agent" else "Search Agents", + contentDescription = "Chat Detail Agent Search", + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + val filteredAgents = chatData.agents.filter { agent -> + val haystack = "${agent.name}\n${agent.description}\n${agent.id}".lowercase() + haystack.contains(agentQuery.trim().lowercase()) + } + if (filteredAgents.isEmpty()) { + Text( + text = if (zh) "没有匹配的 Agent" else "No matching agents", + style = MaterialTheme.typography.bodyMedium, + color = MulticaColors.Muted, + ) + } + filteredAgents.take(20).forEachIndexed { index, agent -> + ChatAgentWebDenseRow( + agent = agent, + selected = agent.id == activeAgent?.id, + showDivider = index < filteredAgents.lastIndex && index < 19, + onClick = { switchAgent(agent) }, + ) + } + } + } + } if (!actionError.isNullOrBlank()) { PilotInlineResultState( message = actionError.orEmpty(), @@ -3758,15 +3720,7 @@ private fun PilotChatMessages( } } items(messages) { message -> - ChatMessageBubble(message = message, zh = zh) - if (validChatTaskId(message.taskId)) { - ChatTaskTimeline( - api = api, - workspaceId = workspaceId, - taskId = message.taskId, - zh = zh, - ) - } + ChatMessageBubble(message = message, zh = zh, api = api, workspaceId = workspaceId) } if (sending || locallyPending || validChatTaskId(activePending.taskId)) { item(key = "chat-latest-progress") { @@ -3800,7 +3754,15 @@ private fun PilotChatMessages( Surface( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 18.dp, vertical = 10.dp), + .heightIn(min = if (composerFocused) focusedComposerHeight else 0.dp) + .padding(horizontal = 18.dp, vertical = 10.dp) + .semantics { + contentDescription = if (composerFocused) { + "Chat Focused Composer Expanded" + } else { + "Chat Compact Composer" + } + }, shape = RoundedCornerShape(14.dp), color = MulticaColors.Surface.copy(alpha = 0.94f), tonalElevation = 0.dp, @@ -3820,8 +3782,9 @@ private fun PilotChatMessages( modifier = Modifier.weight(1f), label = if (zh) "发送 Markdown 消息..." else "Send a Markdown message...", contentDescription = "Chat Message Input", - minLines = 1, - maxLines = 4, + minLines = if (composerFocused) 12 else 1, + maxLines = if (composerFocused) 18 else 4, + onFocusedChange = { composerFocused = it }, ) MulticaIconPillButton( icon = Icons.AutoMirrored.Outlined.Send, @@ -3920,6 +3883,8 @@ private fun ChatWindowWebEmptyState( private fun ChatMessageBubble( message: Models.ChatMessage, zh: Boolean, + api: ApiClient? = null, + workspaceId: String? = null, ) { val context = LocalContext.current val haptic = LocalHapticFeedback.current @@ -3991,6 +3956,21 @@ 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, @@ -4029,21 +4009,36 @@ private fun ChatPendingTimelineCard( status: String, zh: Boolean, ) { - Column( + Row( modifier = Modifier .fillMaxWidth() - .semantics { contentDescription = "Chat Pending Timeline" }, - verticalArrangement = Arrangement.spacedBy(8.dp), + .semantics { contentDescription = "Chat Message Bubble assistant streaming Chat Pending Timeline" }, + horizontalArrangement = Arrangement.Start, ) { - ChatTaskWebLiveBanner( - title = if (zh) "Agent 正在处理" else "Agent is running", - subtitle = listOf(status.ifBlank { if (zh) "等待任务创建" else "Waiting for task" }, Models.shortId(taskId)) - .filter { it.isNotBlank() } - .joinToString(" · "), - active = true, - ) - if (taskId.isNotBlank()) { - ChatTaskTimelineRows(api = api, workspaceId = workspaceId, taskId = taskId, zh = zh) + Surface( + modifier = Modifier.fillMaxWidth(0.84f), + shape = RoundedCornerShape(12.dp), + color = MulticaColors.Surface, + tonalElevation = 0.dp, + shadowElevation = 0.dp, + ) { + Column( + modifier = Modifier + .border(1.dp, MulticaColors.Border, RoundedCornerShape(12.dp)) + .padding(horizontal = 10.dp, vertical = 9.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + ChatTaskWebLiveBanner( + title = if (zh) "Agent 正在处理" else "Agent is running", + subtitle = listOf(status.ifBlank { if (zh) "等待任务创建" else "Waiting for task" }, Models.shortId(taskId)) + .filter { it.isNotBlank() } + .joinToString(" · "), + active = true, + ) + if (taskId.isNotBlank()) { + ChatTaskTimelineRows(api = api, workspaceId = workspaceId, taskId = taskId, zh = zh) + } + } } } } @@ -10776,6 +10771,9 @@ private fun IssueCommentReplyBox( ) { var mentionPickerOpen by remember(parentId) { mutableStateOf(false) } 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 focusRequester = remember(parentId) { FocusRequester() } val keyboard = LocalSoftwareKeyboardController.current val focusManager = LocalFocusManager.current @@ -10819,8 +10817,15 @@ val replyActionModifier = Modifier.size(42.dp) Surface( modifier = Modifier .fillMaxWidth() + .heightIn(min = if (replyFocused) focusedComposerHeight else 0.dp) .padding(start = if (bottomMode) 0.dp else 38.dp, bottom = if (bottomMode) 0.dp else 8.dp) - .semantics(mergeDescendants = false) { contentDescription = "Issue Comment Web Reply Editor $parentId" }, + .semantics(mergeDescendants = false) { + contentDescription = if (replyFocused) { + "Issue Comment Web Reply Editor Expanded $parentId" + } else { + "Issue Comment Web Reply Editor $parentId" + } + }, shape = RoundedCornerShape(14.dp), color = MulticaColors.Surface.copy(alpha = 0.82f), border = androidx.compose.foundation.BorderStroke(1.dp, MulticaColors.Border.copy(alpha = 0.76f)), @@ -10844,8 +10849,9 @@ 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 = 2, - maxLines = 5, + minLines = if (replyFocused) 12 else 3, + maxLines = if (replyFocused) 18 else 7, + onFocusedChange = { replyFocused = it }, ) if (pendingAttachments.isNotEmpty()) { Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { @@ -11148,6 +11154,9 @@ private fun IssueCommentInputBar( ) { var mentionPickerOpen by remember { mutableStateOf(false) } var mentionQuery by remember { mutableStateOf("") } + var commentFocused by remember { mutableStateOf(false) } + val configuration = LocalConfiguration.current + val focusedComposerHeight = ChatExperiencePolicy.focusedComposerHeightPx(configuration.screenHeightDp).dp val mentionItems = remember(agents, members, squads, mentionQuery) { val query = mentionQuery.trim().lowercase() val agentItems = agents @@ -11208,71 +11217,87 @@ private fun IssueCommentInputBar( // Issue Comment Cupertino Composer mirrors Web comment-input.tsx's rounded editor surface. val composerActionModifier = Modifier.size(38.dp) Surface( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .heightIn(min = if (commentFocused) focusedComposerHeight else 0.dp) + .semantics { + contentDescription = if (commentFocused) { + "Issue Comment Focused Composer Expanded" + } else { + "Issue Comment Compact Composer" + } + }, shape = RoundedCornerShape(14.dp), color = MulticaColors.Surface, tonalElevation = 0.dp, shadowElevation = 0.dp, border = androidx.compose.foundation.BorderStroke(1.dp, MulticaColors.Border.copy(alpha = 0.86f)), ) { - Row( + Column( modifier = Modifier .fillMaxWidth() .padding(start = 8.dp, top = 8.dp, end = 8.dp, bottom = 8.dp), - horizontalArrangement = Arrangement.spacedBy(7.dp), - verticalAlignment = Alignment.Bottom, + verticalArrangement = Arrangement.spacedBy(8.dp), ) { - MulticaIconPillButton( - icon = Icons.Outlined.Image, - contentDescription = "Issue Comment Attach Image", - onClick = onAttachImage, - tone = MulticaButtonTone.Secondary, - enabled = !uploadingAttachment, - modifier = composerActionModifier, - ) - MulticaIconPillButton( - icon = Icons.Outlined.AttachFile, - contentDescription = "Issue Comment Attach", - onClick = onAttach, - tone = MulticaButtonTone.Secondary, - enabled = !uploadingAttachment, - modifier = composerActionModifier, - ) - MulticaIconPillButton( - icon = Icons.Outlined.AlternateEmail, - contentDescription = "Issue Comment Agent Mention", - onClick = { mentionPickerOpen = !mentionPickerOpen }, - tone = if (mentionPickerOpen) MulticaButtonTone.Primary else MulticaButtonTone.Secondary, - modifier = composerActionModifier, - ) MulticaTextField( value = draft, onValueChange = onDraftChange, - modifier = Modifier.weight(1f), + modifier = Modifier.fillMaxWidth(), label = "", placeholder = if (zh) "评论" else "Comment", showLabel = false, contentDescription = "Issue Comment Input", - minLines = 1, - maxLines = 4, - ) - MulticaIconPillButton( - icon = Icons.AutoMirrored.Outlined.Send, - contentDescription = if (zh) "发送评论" else "Send Comment", - enabled = !sending && (draft.trim().isNotEmpty() || pendingAttachments.isNotEmpty()), - onClick = onSend, - tone = MulticaButtonTone.Primary, - modifier = Modifier - .size(42.dp) - .clip(RoundedCornerShape(999.dp)) - .background( - if (!sending && (draft.trim().isNotEmpty() || pendingAttachments.isNotEmpty())) { - MulticaColors.Accent - } else { - MulticaColors.Border.copy(alpha = 0.42f) - } - ), + minLines = if (commentFocused) 12 else 1, + maxLines = if (commentFocused) 18 else 4, + onFocusedChange = { commentFocused = it }, ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(7.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + MulticaIconPillButton( + icon = Icons.Outlined.Image, + contentDescription = "Issue Comment Attach Image", + onClick = onAttachImage, + tone = MulticaButtonTone.Secondary, + enabled = !uploadingAttachment, + modifier = composerActionModifier, + ) + MulticaIconPillButton( + icon = Icons.Outlined.AttachFile, + contentDescription = "Issue Comment Attach", + onClick = onAttach, + tone = MulticaButtonTone.Secondary, + enabled = !uploadingAttachment, + modifier = composerActionModifier, + ) + MulticaIconPillButton( + icon = Icons.Outlined.AlternateEmail, + contentDescription = "Issue Comment Agent Mention", + onClick = { mentionPickerOpen = !mentionPickerOpen }, + tone = if (mentionPickerOpen) MulticaButtonTone.Primary else MulticaButtonTone.Secondary, + modifier = composerActionModifier, + ) + Spacer(modifier = Modifier.weight(1f)) + MulticaIconPillButton( + icon = Icons.AutoMirrored.Outlined.Send, + contentDescription = if (zh) "发送评论" else "Send Comment", + enabled = !sending && (draft.trim().isNotEmpty() || pendingAttachments.isNotEmpty()), + onClick = onSend, + tone = MulticaButtonTone.Primary, + modifier = Modifier + .size(42.dp) + .clip(RoundedCornerShape(999.dp)) + .background( + if (!sending && (draft.trim().isNotEmpty() || pendingAttachments.isNotEmpty())) { + MulticaColors.Accent + } else { + MulticaColors.Border.copy(alpha = 0.42f) + } + ), + ) + } } } if (mentionPickerOpen) { diff --git a/app/src/main/java/ai/multica/app/ui/components/MulticaComponents.kt b/app/src/main/java/ai/multica/app/ui/components/MulticaComponents.kt index b3cba97..d5fc5e8 100644 --- a/app/src/main/java/ai/multica/app/ui/components/MulticaComponents.kt +++ b/app/src/main/java/ai/multica/app/ui/components/MulticaComponents.kt @@ -61,6 +61,7 @@ import com.airbnb.lottie.compose.rememberLottieComposition import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.graphicsLayer @@ -1612,6 +1613,7 @@ fun MulticaCupertinoTextField( singleLine: Boolean = false, minLines: Int = 1, maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE, + onFocusedChange: ((Boolean) -> Unit)? = null, ) { val fieldDescription = contentDescription ?: "Multica Cupertino Text Field $label" val centerSingleLineContent = singleLine || minLines <= 1 @@ -1651,7 +1653,8 @@ fun MulticaCupertinoTextField( onValueChange = onValueChange, modifier = Modifier .fillMaxWidth() - .wrapContentHeight(), + .wrapContentHeight() + .onFocusChanged { onFocusedChange?.invoke(it.isFocused) }, enabled = enabled, singleLine = singleLine, minLines = minLines, @@ -1694,6 +1697,7 @@ fun MulticaTextField( singleLine: Boolean = false, minLines: Int = 1, maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE, + onFocusedChange: ((Boolean) -> Unit)? = null, ) { MulticaCupertinoTextField( value = value, @@ -1707,6 +1711,7 @@ fun MulticaTextField( singleLine = singleLine, minLines = minLines, maxLines = maxLines, + onFocusedChange = onFocusedChange, ) } From 9fe67e13b1e01bff6b94d1f5dcfb5be569e19043 Mon Sep 17 00:00:00 2001 From: Xisheng-Zhao Date: Mon, 8 Jun 2026 11:59:18 +0800 Subject: [PATCH 2/4] Refine chat composer streaming experience --- .../ai/multica/app/ChatExperiencePolicy.java | 25 ++- .../ai/multica/app/ComposePilotActivity.kt | 144 ++++++++++-------- 2 files changed, 100 insertions(+), 69 deletions(-) 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( From ddd2d01da665e1fcff928aa7b585816669b86235 Mon Sep 17 00:00:00 2001 From: Xisheng-Zhao Date: Mon, 8 Jun 2026 12:02:07 +0800 Subject: [PATCH 3/4] Avoid stale pending chat refresh state --- app/src/main/java/ai/multica/app/ComposePilotActivity.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/ai/multica/app/ComposePilotActivity.kt b/app/src/main/java/ai/multica/app/ComposePilotActivity.kt index 2e4f5d9..b2b8fce 100644 --- a/app/src/main/java/ai/multica/app/ComposePilotActivity.kt +++ b/app/src/main/java/ai/multica/app/ComposePilotActivity.kt @@ -3472,7 +3472,8 @@ private fun PilotChatMessages( runCatching { api.pendingChatTask(workspaceId, session.id) } } result.onSuccess { pending -> - state = Result.success(current.copy(pendingTask = pending)) + val latest = state?.getOrNull() ?: current + state = Result.success(latest.copy(pendingTask = pending)) if (validChatTaskId(previousPendingTaskId) && !validChatTaskId(pending.taskId)) { messagesRefresh++ } From af7e92b93494d0a90f39b9e9c364037fbbeab84b Mon Sep 17 00:00:00 2001 From: Xisheng-Zhao Date: Mon, 8 Jun 2026 12:31:45 +0800 Subject: [PATCH 4/4] Add cancel control to issue comment composer --- .../java/ai/multica/app/ComposePilotActivity.kt | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/app/src/main/java/ai/multica/app/ComposePilotActivity.kt b/app/src/main/java/ai/multica/app/ComposePilotActivity.kt index b2b8fce..a4e6559 100644 --- a/app/src/main/java/ai/multica/app/ComposePilotActivity.kt +++ b/app/src/main/java/ai/multica/app/ComposePilotActivity.kt @@ -11167,6 +11167,8 @@ private fun IssueCommentInputBar( var mentionPickerOpen by remember { mutableStateOf(false) } var mentionQuery by remember { mutableStateOf("") } var commentFocused by remember { mutableStateOf(false) } + val focusManager = LocalFocusManager.current + val keyboard = LocalSoftwareKeyboardController.current val configuration = LocalConfiguration.current val focusedComposerHeight = ChatExperiencePolicy.focusedComposerHeightPx( ChatExperiencePolicy.Route.ISSUE_COMMENT, @@ -11294,6 +11296,21 @@ private fun IssueCommentInputBar( tone = if (mentionPickerOpen) MulticaButtonTone.Primary else MulticaButtonTone.Secondary, modifier = composerActionModifier, ) + if (commentFocused || mentionPickerOpen) { + MulticaIconPillButton( + icon = Icons.Outlined.Close, + contentDescription = "Issue Comment Cancel Input", + onClick = { + mentionPickerOpen = false + mentionQuery = "" + commentFocused = false + focusManager.clearFocus(force = true) + keyboard?.hide() + }, + tone = MulticaButtonTone.Ghost, + modifier = composerActionModifier, + ) + } Spacer(modifier = Modifier.weight(1f)) MulticaIconPillButton( icon = Icons.AutoMirrored.Outlined.Send,