Merge branch 'honest-blowfish'

This commit is contained in:
Xisheng-Zhao
2026-06-08 12:37:15 +08:00
3 changed files with 365 additions and 254 deletions
@@ -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));
}
}
@@ -111,6 +111,7 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalHapticFeedback
@@ -390,6 +391,7 @@ private data class PilotIssueFormOptions(
private data class PilotChatMessagesData( private data class PilotChatMessagesData(
val messages: List<Models.ChatMessage>, val messages: List<Models.ChatMessage>,
val pendingTask: Models.ChatPendingTask, val pendingTask: Models.ChatPendingTask,
val agents: List<Models.Agent>,
) )
private data class PilotChatSessionsData( private data class PilotChatSessionsData(
@@ -1817,6 +1819,7 @@ private fun ComposePilotShell(
session = activeChat, session = activeChat,
zh = zh, zh = zh,
onBack = { chatSession = null }, onBack = { chatSession = null },
onSessionChanged = { chatSession = it },
onArchived = { onArchived = {
chatSession = null chatSession = null
chatOpen = false 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 @Composable
private fun ChatSessionListWebPanel( private fun ChatSessionListWebPanel(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@@ -3029,12 +3006,7 @@ private fun PilotChatSessions(
) { ) {
var state by remember(workspaceId) { mutableStateOf<Result<PilotChatSessionsData>?>(null) } var state by remember(workspaceId) { mutableStateOf<Result<PilotChatSessionsData>?>(null) }
var refresh by remember(workspaceId) { mutableIntStateOf(0) } var refresh by remember(workspaceId) { mutableIntStateOf(0) }
var title by remember(workspaceId) { mutableStateOf("") }
var selectedAgentId by remember(workspaceId) { mutableStateOf<String?>(null) }
var agentPickerOpen by remember(workspaceId) { mutableStateOf(false) }
var agentQuery by remember(workspaceId) { mutableStateOf("") }
var showAllSessions by remember(workspaceId) { mutableStateOf(false) } var showAllSessions by remember(workspaceId) { mutableStateOf(false) }
var showCreateForm by remember(workspaceId) { mutableStateOf(false) }
var creating by remember(workspaceId) { mutableStateOf(false) } var creating by remember(workspaceId) { mutableStateOf(false) }
var deletingSessionId by remember(workspaceId) { mutableStateOf<String?>(null) } var deletingSessionId by remember(workspaceId) { mutableStateOf<String?>(null) }
var actionText by remember(workspaceId) { mutableStateOf<String?>(null) } var actionText by remember(workspaceId) { mutableStateOf<String?>(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 state = loaded
} }
fun createSession(data: PilotChatSessionsData) { fun createSession(data: PilotChatSessionsData) {
if (creating) return if (creating) return
val agent = data.agents.firstOrNull { it.id == selectedAgentId } ?: data.agents.firstOrNull() val agent = data.agents.firstOrNull()
if (agent == null) { if (agent == null) {
actionText = if (zh) "请先创建 Agent" else "Create an agent first" actionText = if (zh) "请先创建 Agent" else "Create an agent first"
return return
} }
val sessionTitle = title.trim().ifBlank { val sessionTitle = ChatExperiencePolicy.newChatInitialTitle()
if (zh) "新的聊天" else "New Chat"
}
creating = true creating = true
actionText = null actionText = null
scope.launch { scope.launch {
@@ -3079,9 +3044,9 @@ private fun PilotChatSessions(
} }
creating = false creating = false
result.onSuccess { session -> result.onSuccess { session ->
title = ""
actionText = if (zh) "聊天会话已创建" else "Chat session created" actionText = if (zh) "聊天会话已创建" else "Chat session created"
refresh++ refresh++
onSessionClick(session)
}.onFailure { }.onFailure {
actionText = "${if (zh) "创建聊天失败" else "Create chat failed"}: ${it.message ?: it.toString()}" actionText = "${if (zh) "创建聊天失败" else "Create chat failed"}: ${it.message ?: it.toString()}"
} }
@@ -3120,7 +3085,6 @@ private fun PilotChatSessions(
) { ) {
val data = loaded.getOrThrow() val data = loaded.getOrThrow()
val sessions = data.sessions 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 } val agentNamesById = data.agents.associate { it.id to it.name }
item { item {
Row( Row(
@@ -3132,17 +3096,14 @@ private fun PilotChatSessions(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
MulticaPillButton( MulticaPillButton(
text = if (showCreateForm) { text = if (creating) "..." else if (zh) "新聊天" else "New Chat",
if (zh) "收起新聊天" else "Hide New Chat"
} else {
if (zh) "新聊天" else "New Chat"
},
onClick = { onClick = {
showCreateForm = !showCreateForm createSession(data)
actionText = null actionText = null
}, },
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
tone = if (showCreateForm) MulticaButtonTone.Secondary else MulticaButtonTone.Primary, enabled = !creating,
tone = MulticaButtonTone.Primary,
contentDescription = "Chat New Session Toggle", contentDescription = "Chat New Session Toggle",
) )
MulticaPillButton( 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()) { if (!actionText.isNullOrBlank()) {
item { item {
val isError = actionText!!.contains("failed", true) || actionText!!.contains("失败") val isError = actionText!!.contains("failed", true) || actionText!!.contains("失败")
@@ -3544,10 +3416,12 @@ private fun PilotChatMessages(
session: Models.ChatSession, session: Models.ChatSession,
zh: Boolean, zh: Boolean,
onBack: () -> Unit, onBack: () -> Unit,
onSessionChanged: (Models.ChatSession) -> Unit,
onArchived: () -> Unit, onArchived: () -> Unit,
) { ) {
var state by remember(session.id) { mutableStateOf<Result<PilotChatMessagesData>?>(null) } var state by remember(session.id) { mutableStateOf<Result<PilotChatMessagesData>?>(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 draft by remember(session.id) { mutableStateOf("") }
var sending by remember(session.id) { mutableStateOf(false) } var sending by remember(session.id) { mutableStateOf(false) }
var locallyPending 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 archiving by remember(session.id) { mutableStateOf(false) }
var cancelling by remember(session.id) { mutableStateOf(false) } var cancelling by remember(session.id) { mutableStateOf(false) }
var headerMenuOpen 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<String?>(null) }
var composerFocused by remember(session.id) { mutableStateOf(false) }
var actionError by remember(session.id) { mutableStateOf<String?>(null) } var actionError by remember(session.id) { mutableStateOf<String?>(null) }
val configuration = LocalConfiguration.current
val focusedComposerHeight = ChatExperiencePolicy.focusedComposerHeightPx(
ChatExperiencePolicy.Route.CHAT,
configuration.screenHeightDp,
).dp
val listState = rememberLazyListState() val listState = rememberLazyListState()
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val hazeState = rememberHazeState() val hazeState = rememberHazeState()
val haptic = LocalHapticFeedback.current val haptic = LocalHapticFeedback.current
LaunchedEffect(session.id, refresh) { LaunchedEffect(session.id, messagesRefresh) {
state = null val previous = state
state = withContext(Dispatchers.IO) { val result = withContext(Dispatchers.IO) {
runCatching { runCatching {
val messages = api.chatMessages(workspaceId, session.id) val messages = api.chatMessages(workspaceId, session.id)
val pending = runCatching { api.pendingChatTask(workspaceId, session.id) } val pending = runCatching { api.pendingChatTask(workspaceId, session.id) }
.getOrElse { Models.ChatPendingTask(org.json.JSONObject()) } .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) } 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() val taskId = state?.getOrNull()?.pendingTask?.taskId.orEmpty()
if (!sending && taskId.isBlank()) return@LaunchedEffect if (!sending && !locallyPending && !validChatTaskId(taskId)) return@LaunchedEffect
while (sending || taskId.isNotBlank()) { while (sending || locallyPending || validChatTaskId(taskId)) {
delay(5_000) delay(2_000)
refresh++ pendingRefresh++
} }
} }
@@ -3641,11 +3549,43 @@ private fun PilotChatMessages(
runCatching { api.cancelTaskById(workspaceId, task.taskId) } runCatching { api.cancelTaskById(workspaceId, task.taskId) }
} }
cancelling = false cancelling = false
result.onSuccess { refresh++ } result.onSuccess {
pendingRefresh++
messagesRefresh++
}
.onFailure { actionError = "${if (zh) "取消失败" else "Cancel failed"}: ${it.message ?: it.toString()}" } .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( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -3711,6 +3651,57 @@ private fun PilotChatMessages(
modifier = Modifier.padding(horizontal = 18.dp), 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()) { if (!actionError.isNullOrBlank()) {
PilotInlineResultState( PilotInlineResultState(
message = actionError.orEmpty(), message = actionError.orEmpty(),
@@ -3736,7 +3727,7 @@ private fun PilotChatMessages(
) { ) {
MulticaErrorState( MulticaErrorState(
message = "${if (zh) "消息加载失败" else "Messages failed"}\n${loaded.exceptionOrNull()?.message.orEmpty()}", message = "${if (zh) "消息加载失败" else "Messages failed"}\n${loaded.exceptionOrNull()?.message.orEmpty()}",
onRetry = { refresh++ }, onRetry = { messagesRefresh++ },
) )
} }
else -> LazyColumn( else -> LazyColumn(
@@ -3757,23 +3748,25 @@ private fun PilotChatMessages(
) )
} }
} }
items(messages) { message -> items(
ChatMessageBubble(message = message, zh = zh) items = messages,
if (validChatTaskId(message.taskId)) { key = { message ->
ChatTaskTimeline( message.id.ifBlank { "${message.role}:${message.createdAt}:${message.content.hashCode()}" }
api = api, },
workspaceId = workspaceId, ) { message ->
taskId = message.taskId, ChatMessageBubble(message = message, zh = zh, api = api, workspaceId = workspaceId)
zh = zh,
)
} }
val activePendingTaskId = activePending.taskId
val pendingAlreadyPersisted = validChatTaskId(activePendingTaskId) &&
messages.any { message ->
!message.role.equals("user", ignoreCase = true) && message.taskId == activePendingTaskId
} }
if (sending || locallyPending || validChatTaskId(activePending.taskId)) { if ((sending || locallyPending || validChatTaskId(activePendingTaskId)) && !pendingAlreadyPersisted) {
item(key = "chat-latest-progress") { item(key = "chat-latest-progress") {
ChatPendingTimelineCard( ChatPendingTimelineCard(
api = api, api = api,
workspaceId = workspaceId, workspaceId = workspaceId,
taskId = activePending.taskId, taskId = activePendingTaskId,
status = activePending.status.ifBlank { if (sending || locallyPending) "queued" else "" }, status = activePending.status.ifBlank { if (sending || locallyPending) "queued" else "" },
zh = zh, zh = zh,
) )
@@ -3800,7 +3793,15 @@ private fun PilotChatMessages(
Surface( Surface(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .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), shape = RoundedCornerShape(14.dp),
color = MulticaColors.Surface.copy(alpha = 0.94f), color = MulticaColors.Surface.copy(alpha = 0.94f),
tonalElevation = 0.dp, tonalElevation = 0.dp,
@@ -3820,8 +3821,9 @@ private fun PilotChatMessages(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
label = if (zh) "发送 Markdown 消息..." else "Send a Markdown message...", label = if (zh) "发送 Markdown 消息..." else "Send a Markdown message...",
contentDescription = "Chat Message Input", contentDescription = "Chat Message Input",
minLines = 1, minLines = if (composerFocused) 4 else 1,
maxLines = 4, maxLines = if (composerFocused) 8 else 4,
onFocusedChange = { composerFocused = it },
) )
MulticaIconPillButton( MulticaIconPillButton(
icon = Icons.AutoMirrored.Outlined.Send, icon = Icons.AutoMirrored.Outlined.Send,
@@ -3840,7 +3842,8 @@ private fun PilotChatMessages(
sending = false sending = false
result.onSuccess { result.onSuccess {
draft = "" draft = ""
refresh++ pendingRefresh++
messagesRefresh++
}.onFailure { }.onFailure {
locallyPending = false locallyPending = false
sendError = "${if (zh) "发送失败" else "Send failed"}: ${it.message ?: it.toString()}" sendError = "${if (zh) "发送失败" else "Send failed"}: ${it.message ?: it.toString()}"
@@ -3920,6 +3923,8 @@ private fun ChatWindowWebEmptyState(
private fun ChatMessageBubble( private fun ChatMessageBubble(
message: Models.ChatMessage, message: Models.ChatMessage,
zh: Boolean, zh: Boolean,
api: ApiClient? = null,
workspaceId: String? = null,
) { ) {
val context = LocalContext.current val context = LocalContext.current
val haptic = LocalHapticFeedback.current val haptic = LocalHapticFeedback.current
@@ -4029,10 +4034,23 @@ private fun ChatPendingTimelineCard(
status: String, status: String,
zh: Boolean, zh: Boolean,
) { ) {
Column( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.semantics { contentDescription = "Chat Pending Timeline" }, .semantics { contentDescription = "Chat Message Bubble assistant streaming Chat Pending Timeline" },
horizontalArrangement = Arrangement.Start,
) {
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), verticalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
ChatTaskWebLiveBanner( ChatTaskWebLiveBanner(
@@ -4043,30 +4061,10 @@ private fun ChatPendingTimelineCard(
active = true, active = true,
) )
if (taskId.isNotBlank()) { 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)
} }
} }
@@ -4076,10 +4074,16 @@ private fun ChatTaskTimelineRows(
workspaceId: String, workspaceId: String,
taskId: String, taskId: String,
zh: Boolean, zh: Boolean,
active: Boolean,
) { ) {
var state by remember(taskId, workspaceId) { mutableStateOf<Result<List<Models.TaskMessage>>?>(null) } var state by remember(taskId, workspaceId) { mutableStateOf<Result<List<Models.TaskMessage>>?>(null) }
LaunchedEffect(taskId, workspaceId) { LaunchedEffect(taskId, workspaceId, active) {
if (taskId.isBlank()) return@LaunchedEffect
do {
state = withContext(Dispatchers.IO) { runCatching { api.runMessages(taskId, workspaceId) } } state = withContext(Dispatchers.IO) { runCatching { api.runMessages(taskId, workspaceId) } }
if (!active) break
delay(1_500)
} while (active)
} }
when (val loaded = state) { when (val loaded = state) {
null -> ChatTaskWebEmptyRow( null -> ChatTaskWebEmptyRow(
@@ -10776,10 +10780,16 @@ private fun IssueCommentReplyBox(
) { ) {
var mentionPickerOpen by remember(parentId) { mutableStateOf(false) } var mentionPickerOpen by remember(parentId) { mutableStateOf(false) }
var mentionQuery by remember(parentId) { mutableStateOf("") } 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 focusRequester = remember(parentId) { FocusRequester() }
val keyboard = LocalSoftwareKeyboardController.current val keyboard = LocalSoftwareKeyboardController.current
val focusManager = LocalFocusManager.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 mentionItems = remember(agents, members, squads, mentionQuery) {
val query = mentionQuery.trim().lowercase() val query = mentionQuery.trim().lowercase()
val agentItems = agents val agentItems = agents
@@ -10819,8 +10829,15 @@ val replyActionModifier = Modifier.size(42.dp)
Surface( Surface(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .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) .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), shape = RoundedCornerShape(14.dp),
color = MulticaColors.Surface.copy(alpha = 0.82f), color = MulticaColors.Surface.copy(alpha = 0.82f),
border = androidx.compose.foundation.BorderStroke(1.dp, MulticaColors.Border.copy(alpha = 0.76f)), 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) .focusRequester(focusRequester)
.semantics(mergeDescendants = true) { contentDescription = "Issue Comment Reply Input $parentId" }, .semantics(mergeDescendants = true) { contentDescription = "Issue Comment Reply Input $parentId" },
label = if (zh) "写回复..." else "Write a reply...", label = if (zh) "写回复..." else "Write a reply...",
minLines = 2, minLines = if (replyFocused) 3 else 3,
maxLines = 5, maxLines = if (replyFocused) 6 else 7,
onFocusedChange = { replyFocused = it },
) )
if (pendingAttachments.isNotEmpty()) { if (pendingAttachments.isNotEmpty()) {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
@@ -11148,6 +11166,14 @@ private fun IssueCommentInputBar(
) { ) {
var mentionPickerOpen by remember { mutableStateOf(false) } var mentionPickerOpen by remember { mutableStateOf(false) }
var mentionQuery by remember { mutableStateOf("") } 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 mentionItems = remember(agents, members, squads, mentionQuery) {
val query = mentionQuery.trim().lowercase() val query = mentionQuery.trim().lowercase()
val agentItems = agents val agentItems = agents
@@ -11208,19 +11234,44 @@ private fun IssueCommentInputBar(
// Issue Comment Cupertino Composer mirrors Web comment-input.tsx's rounded editor surface. // Issue Comment Cupertino Composer mirrors Web comment-input.tsx's rounded editor surface.
val composerActionModifier = Modifier.size(38.dp) val composerActionModifier = Modifier.size(38.dp)
Surface( 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), shape = RoundedCornerShape(14.dp),
color = MulticaColors.Surface, color = MulticaColors.Surface,
tonalElevation = 0.dp, tonalElevation = 0.dp,
shadowElevation = 0.dp, shadowElevation = 0.dp,
border = androidx.compose.foundation.BorderStroke(1.dp, MulticaColors.Border.copy(alpha = 0.86f)), border = androidx.compose.foundation.BorderStroke(1.dp, MulticaColors.Border.copy(alpha = 0.86f)),
) { ) {
Row( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(start = 8.dp, top = 8.dp, end = 8.dp, bottom = 8.dp), .padding(start = 8.dp, top = 8.dp, end = 8.dp, bottom = 8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
MulticaTextField(
value = draft,
onValueChange = onDraftChange,
modifier = Modifier.fillMaxWidth(),
label = "",
placeholder = if (zh) "评论" else "Comment",
showLabel = false,
contentDescription = "Issue Comment Input",
minLines = if (commentFocused) 3 else 1,
maxLines = if (commentFocused) 6 else 4,
onFocusedChange = { commentFocused = it },
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(7.dp), horizontalArrangement = Arrangement.spacedBy(7.dp),
verticalAlignment = Alignment.Bottom, verticalAlignment = Alignment.CenterVertically,
) { ) {
MulticaIconPillButton( MulticaIconPillButton(
icon = Icons.Outlined.Image, icon = Icons.Outlined.Image,
@@ -11245,17 +11296,22 @@ private fun IssueCommentInputBar(
tone = if (mentionPickerOpen) MulticaButtonTone.Primary else MulticaButtonTone.Secondary, tone = if (mentionPickerOpen) MulticaButtonTone.Primary else MulticaButtonTone.Secondary,
modifier = composerActionModifier, modifier = composerActionModifier,
) )
MulticaTextField( if (commentFocused || mentionPickerOpen) {
value = draft, MulticaIconPillButton(
onValueChange = onDraftChange, icon = Icons.Outlined.Close,
modifier = Modifier.weight(1f), contentDescription = "Issue Comment Cancel Input",
label = "", onClick = {
placeholder = if (zh) "评论" else "Comment", mentionPickerOpen = false
showLabel = false, mentionQuery = ""
contentDescription = "Issue Comment Input", commentFocused = false
minLines = 1, focusManager.clearFocus(force = true)
maxLines = 4, keyboard?.hide()
},
tone = MulticaButtonTone.Ghost,
modifier = composerActionModifier,
) )
}
Spacer(modifier = Modifier.weight(1f))
MulticaIconPillButton( MulticaIconPillButton(
icon = Icons.AutoMirrored.Outlined.Send, icon = Icons.AutoMirrored.Outlined.Send,
contentDescription = if (zh) "发送评论" else "Send Comment", contentDescription = if (zh) "发送评论" else "Send Comment",
@@ -11275,6 +11331,7 @@ private fun IssueCommentInputBar(
) )
} }
} }
}
if (mentionPickerOpen) { if (mentionPickerOpen) {
Column( Column(
modifier = Modifier modifier = Modifier
@@ -61,6 +61,7 @@ import com.airbnb.lottie.compose.rememberLottieComposition
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
@@ -1612,6 +1613,7 @@ fun MulticaCupertinoTextField(
singleLine: Boolean = false, singleLine: Boolean = false,
minLines: Int = 1, minLines: Int = 1,
maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE, maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE,
onFocusedChange: ((Boolean) -> Unit)? = null,
) { ) {
val fieldDescription = contentDescription ?: "Multica Cupertino Text Field $label" val fieldDescription = contentDescription ?: "Multica Cupertino Text Field $label"
val centerSingleLineContent = singleLine || minLines <= 1 val centerSingleLineContent = singleLine || minLines <= 1
@@ -1651,7 +1653,8 @@ fun MulticaCupertinoTextField(
onValueChange = onValueChange, onValueChange = onValueChange,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.wrapContentHeight(), .wrapContentHeight()
.onFocusChanged { onFocusedChange?.invoke(it.isFocused) },
enabled = enabled, enabled = enabled,
singleLine = singleLine, singleLine = singleLine,
minLines = minLines, minLines = minLines,
@@ -1694,6 +1697,7 @@ fun MulticaTextField(
singleLine: Boolean = false, singleLine: Boolean = false,
minLines: Int = 1, minLines: Int = 1,
maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE, maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE,
onFocusedChange: ((Boolean) -> Unit)? = null,
) { ) {
MulticaCupertinoTextField( MulticaCupertinoTextField(
value = value, value = value,
@@ -1707,6 +1711,7 @@ fun MulticaTextField(
singleLine = singleLine, singleLine = singleLine,
minLines = minLines, minLines = minLines,
maxLines = maxLines, maxLines = maxLines,
onFocusedChange = onFocusedChange,
) )
} }