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..8685c3e --- /dev/null +++ b/app/src/main/java/ai/multica/app/ChatExperiencePolicy.java @@ -0,0 +1,49 @@ +package ai.multica.app; + +final class ChatExperiencePolicy { + 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, + ISSUE_COMMENT, + ISSUE_REPLY + } + + private ChatExperiencePolicy() { + } + + static String newChatInitialTitle() { + return ""; + } + + static boolean requiresTitleBeforeEnteringChat() { + 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) { + 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 6cd886f..a4e6559 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,10 +3416,12 @@ private fun PilotChatMessages( session: Models.ChatSession, zh: Boolean, onBack: () -> Unit, + onSessionChanged: (Models.ChatSession) -> Unit, 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) } @@ -3555,31 +3429,65 @@ 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( + 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) } .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) } } + 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 -> + val latest = state?.getOrNull() ?: current + state = Result.success(latest.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++ } } @@ -3641,11 +3549,43 @@ 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()}" } } } + 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 +3651,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(), @@ -3736,7 +3727,7 @@ private fun PilotChatMessages( ) { MulticaErrorState( message = "${if (zh) "消息加载失败" else "Messages failed"}\n${loaded.exceptionOrNull()?.message.orEmpty()}", - onRetry = { refresh++ }, + onRetry = { messagesRefresh++ }, ) } else -> LazyColumn( @@ -3757,23 +3748,25 @@ 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, - ) - } + 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, ) @@ -3800,7 +3793,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 +3821,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) 4 else 1, + maxLines = if (composerFocused) 8 else 4, + onFocusedChange = { composerFocused = it }, ) MulticaIconPillButton( icon = Icons.AutoMirrored.Outlined.Send, @@ -3840,7 +3842,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()}" @@ -3920,6 +3923,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 @@ -4029,57 +4034,56 @@ 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, 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( @@ -10776,10 +10780,16 @@ 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( + 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 @@ -10819,8 +10829,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 +10861,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) 3 else 3, + maxLines = if (replyFocused) 6 else 7, + onFocusedChange = { replyFocused = it }, ) if (pendingAttachments.isNotEmpty()) { Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { @@ -11148,6 +11166,14 @@ 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, + configuration.screenHeightDp, + ).dp val mentionItems = remember(agents, members, squads, mentionQuery) { val query = mentionQuery.trim().lowercase() val agentItems = agents @@ -11208,71 +11234,102 @@ 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) 3 else 1, + maxLines = if (commentFocused) 6 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, + ) + 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, + 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, ) }