From b6d61135caa1b36b9d0ddf5a53564c3ba7f9d1c8 Mon Sep 17 00:00:00 2001 From: Xisheng-Zhao Date: Sun, 7 Jun 2026 04:08:17 +0800 Subject: [PATCH] Add squad mentions and assignee support --- .../main/java/ai/multica/app/ApiClient.java | 12 + .../ai/multica/app/ComposePilotActivity.kt | 277 ++++++++++++++---- .../ai/multica/app/FeatureSyncHelpers.java | 95 ++++-- .../java/ai/multica/app/MainActivity.java | 16 +- app/src/main/java/ai/multica/app/Models.java | 38 ++- 5 files changed, 363 insertions(+), 75 deletions(-) diff --git a/app/src/main/java/ai/multica/app/ApiClient.java b/app/src/main/java/ai/multica/app/ApiClient.java index db4d6c5..4e393b6 100644 --- a/app/src/main/java/ai/multica/app/ApiClient.java +++ b/app/src/main/java/ai/multica/app/ApiClient.java @@ -748,6 +748,18 @@ final class ApiClient { return parseArray(extractArray(json, "agents"), Models.Agent::new); } + + List squads(String workspaceId) throws Exception { + return squads(workspaceId, false); + } + + List squads(String workspaceId, boolean includeArchived) throws Exception { + JSONObject query = query(null, "workspace_id", workspaceId); + if (includeArchived) query.put("include_archived", "true"); + JSONObject json = requestObject("GET", "/api/squads", query, null); + return parseArray(extractArray(json, "squads"), Models.Squad::new); + } + List runtimes(String workspaceId) throws Exception { JSONObject json = requestObject("GET", "/api/runtimes", query(null, "workspace_id", workspaceId), null); return parseArray(extractArray(json, "runtimes"), Models.Runtime::new); diff --git a/app/src/main/java/ai/multica/app/ComposePilotActivity.kt b/app/src/main/java/ai/multica/app/ComposePilotActivity.kt index a92094e..6cd886f 100644 --- a/app/src/main/java/ai/multica/app/ComposePilotActivity.kt +++ b/app/src/main/java/ai/multica/app/ComposePilotActivity.kt @@ -33,6 +33,8 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -367,6 +369,7 @@ private data class PilotIssueDetailData( val projects: List, val members: List, val agents: List, + val squads: List, val issueLabels: List, val allLabels: List, val subscribers: List, @@ -381,6 +384,7 @@ private data class PilotIssueFormOptions( val projects: List, val members: List, val agents: List, + val squads: List, ) private data class PilotChatMessagesData( @@ -5622,6 +5626,7 @@ private fun PilotIssues( var movingBoardIssueId by remember(personal, workspaceId) { mutableStateOf(null) } var assigneeMembers by remember(workspaceId) { mutableStateOf>(emptyList()) } var assigneeAgents by remember(workspaceId) { mutableStateOf>(emptyList()) } + var assigneeSquads by remember(workspaceId) { mutableStateOf>(emptyList()) } val scope = rememberCoroutineScope() LaunchedEffect(personal, myIssueScope, issues) { @@ -5733,6 +5738,7 @@ private fun PilotIssues( }.onSuccess { (members, agents) -> assigneeMembers = members assigneeAgents = agents + assigneeSquads = runCatching { api.squads(workspaceId) }.getOrElse { emptyList() } } } @@ -6070,6 +6076,7 @@ private fun PilotIssues( issue, assigneeMembers, assigneeAgents, + assigneeSquads, currentUser, ) if (isNotBlank()) append(" · ") @@ -6772,6 +6779,7 @@ private fun PilotIssueForm( projects = api.projects(session.workspace.id, 500, 0).items, members = api.members(session.workspace.id), agents = api.agents(session.workspace.id), + squads = runCatching { api.squads(session.workspace.id) }.getOrElse { emptyList() }, ) } } @@ -6792,6 +6800,7 @@ private fun PilotIssueForm( session.user, options.members, options.agents, + options.squads, ).map { PilotAssigneeOption(it.id, it.type, it.name) } var title by remember(issue?.id) { mutableStateOf(issue?.title.orEmpty()) } var description by remember(issue?.id) { mutableStateOf(issue?.description.orEmpty()) } @@ -8868,6 +8877,7 @@ private fun IssueCommentCard( authorAgentStatus: String? = null, members: List = emptyList(), agents: List = emptyList(), + squads: List = emptyList(), currentUser: Models.User? = null, onIssueIdClick: ((String) -> Unit)? = null, onIssueReferenceClick: ((String) -> Unit)? = null, @@ -8962,6 +8972,7 @@ private fun IssueCommentCard( renderedContent, members = members, agents = agents, + squads = squads, currentUser = currentUser, attachments = comment.attachments, onIssueIdClick = onIssueIdClick, @@ -9387,6 +9398,7 @@ private fun PilotIssueDetail( projects = core.projects, members = core.members, agents = core.agents, + squads = emptyList(), issueLabels = emptyList(), allLabels = emptyList(), subscribers = emptyList(), @@ -9417,9 +9429,11 @@ private fun PilotIssueDetail( val timelineAnchor = highlightCommentId ?: core.comments.lastOrNull()?.id val timeline = runCatching { api.issueTimeline(workspaceId, issueId, 10, null, null, timelineAnchor) }.getOrNull() val activeTasks = runCatching { api.activeIssueTasks(workspaceId, issueId) }.getOrElse { emptyList() } + val squads = runCatching { api.squads(workspaceId) }.getOrElse { emptyList() } initialData.copy( parentIssue = parentIssue, projects = projects, + squads = squads, issueLabels = issueLabels, allLabels = allLabels, subscribers = subscribers, @@ -9651,7 +9665,7 @@ private fun PilotIssueDetail( Models.Assignee( assigneeId, data.issue.assigneeType ?: "member", - Models.issueAssigneeDisplayName(data.issue, data.members, data.agents, currentUser), + Models.issueAssigneeDisplayName(data.issue, data.members, data.agents, data.squads, currentUser), ) } scope.launch { @@ -9803,7 +9817,7 @@ private fun PilotIssueDetail( } fun sendNewComment() { - val content = AgentMentionMarkdown.markdownFromDraft(newCommentDraft.trim(), data.agents) + val content = AgentMentionMarkdown.markdownFromDraft(newCommentDraft.trim(), data.agents, data.members, data.squads) if (sendingNewComment || content.isEmpty() && pendingCommentAttachments.isEmpty()) return sendingNewComment = true newCommentMessage = null @@ -9826,7 +9840,7 @@ private fun PilotIssueDetail( fun sendReply(thread: IssueCommentThreads.Thread) { val parentId = thread.root.id - val explicitContent = AgentMentionMarkdown.markdownFromDraft(replyDraft.trim(), data.agents) + val explicitContent = AgentMentionMarkdown.markdownFromDraft(replyDraft.trim(), data.agents, data.members, data.squads) val content = IssueCommentThreads.replyContentForThread(thread, explicitContent, data.agents) if (sendingReply || (content.isEmpty() && pendingReplyAttachments.isEmpty())) return sendingReply = true @@ -9925,12 +9939,14 @@ private fun PilotIssueDetail( contentDescription = "Issue Comment Bottom Haze Composer $WEB_SSOT_COMMENT_INPUT" }, ) { - if (replyingThread != null) { +if (replyingThread != null) { IssueCommentReplyBox( parentId = replyingThread.root.id, draft = replyDraft, pendingAttachments = pendingReplyAttachments, agents = data.agents, + members = data.members, + squads = data.squads, uploadingAttachment = uploadingReplyAttachment, sending = sendingReply, zh = zh, @@ -9954,6 +9970,8 @@ private fun PilotIssueDetail( draft = newCommentDraft, pendingAttachments = pendingCommentAttachments, agents = data.agents, + members = data.members, + squads = data.squads, uploadingAttachment = uploadingCommentAttachment, sending = sendingNewComment, zh = zh, @@ -9988,7 +10006,7 @@ private fun PilotIssueDetail( IssueDetailCompactMetadata( issue = data.issue, issueLabels = data.issueLabels, - assignee = Models.issueAssigneeDisplayName(data.issue, data.members, data.agents, currentUser), + assignee = Models.issueAssigneeDisplayName(data.issue, data.members, data.agents, data.squads, currentUser), project = projectName, zh = zh, ) @@ -10026,6 +10044,7 @@ private fun PilotIssueDetail( data.issue.description, members = data.members, agents = data.agents, + squads = data.squads, currentUser = currentUser, attachments = data.attachments, onIssueIdClick = { onOpenIssue(it) }, @@ -10185,6 +10204,7 @@ private fun PilotIssueDetail( authorAgentStatus = commentAuthorAgentStatus(comment, data.agents), members = data.members, agents = data.agents, + squads = data.squads, currentUser = currentUser, onIssueIdClick = { onOpenIssue(it) }, onIssueReferenceClick = { openIssueReference(it) }, @@ -10288,11 +10308,11 @@ private fun PilotIssueDetail( } } if (!replyMessage.isNullOrBlank() && replyingCommentId == thread.root.id) { - PilotInlineResultState( - message = replyMessage.orEmpty(), - isError = pilotInlineResultIsError(replyMessage.orEmpty()), - contentDescription = "Issue Detail Reply Result Web Banner", - ) + PilotInlineResultState( + message = replyMessage.orEmpty(), + isError = pilotInlineResultIsError(replyMessage.orEmpty()), + contentDescription = "Issue Detail Reply Result Web Banner", + ) } } } @@ -10741,6 +10761,8 @@ private fun IssueCommentReplyBox( draft: String, pendingAttachments: List, agents: List, + members: List, + squads: List, uploadingAttachment: Boolean, sending: Boolean, zh: Boolean, @@ -10757,10 +10779,10 @@ private fun IssueCommentReplyBox( val focusRequester = remember(parentId) { FocusRequester() } val keyboard = LocalSoftwareKeyboardController.current val focusManager = LocalFocusManager.current - val replyActionModifier = Modifier.size(42.dp) - val mentionAgents = remember(agents, mentionQuery) { +val replyActionModifier = Modifier.size(42.dp) + val mentionItems = remember(agents, members, squads, mentionQuery) { val query = mentionQuery.trim().lowercase() - agents + val agentItems = agents .filter { it.archivedAt.isBlank() } .filter { agent -> query.isBlank() || @@ -10769,7 +10791,24 @@ private fun IssueCommentReplyBox( agent.id.lowercase().contains(query) } .sortedBy { it.name.lowercase() } - .take(8) + .map { Triple(it.id, it.name.ifBlank { Models.shortId(it.id) }, "agent") } + val memberItems = members + .filter { member -> + query.isBlank() || + member.displayName.lowercase().contains(query) || + member.email.lowercase().contains(query) + } + .sortedBy { it.displayName.lowercase() } + .map { Triple(it.id, it.displayName, "member") } + val squadItems = squads + .filter { squad -> + query.isBlank() || + squad.name.lowercase().contains(query) || + squad.description.lowercase().contains(query) + } + .sortedBy { it.name.lowercase() } + .map { Triple(it.id, it.name.ifBlank { Models.shortId(it.id) }, "squad") } + agentItems + memberItems + squadItems } LaunchedEffect(parentId) { delay(180) @@ -10871,11 +10910,12 @@ private fun IssueCommentReplyBox( Column( modifier = Modifier .fillMaxWidth() + .imePadding() .semantics { contentDescription = "Issue Comment Reply Mention Web Picker" }, verticalArrangement = Arrangement.spacedBy(8.dp), ) { Text( - text = if (zh) "选择 Agent" else "Mention agent", + text = if (zh) "选择 @对象" else "Mention someone", modifier = Modifier.semantics { contentDescription = "Issue Comment Mention Web Header" }, style = MaterialTheme.typography.labelMedium.copy(fontSize = 12.sp, lineHeight = 15.sp, fontWeight = FontWeight.SemiBold), color = MulticaColors.Text, @@ -10885,28 +10925,37 @@ private fun IssueCommentReplyBox( onValueChange = { mentionQuery = it }, modifier = Modifier.fillMaxWidth(), contentDescription = "Issue Comment Reply Mention Search", - label = if (zh) "搜索 Agent" else "Search agents", + label = if (zh) "搜索人、Agent、小队" else "Search people, agents, squads", maxLines = 1, ) - if (mentionAgents.isEmpty()) { + if (mentionItems.isEmpty()) { Text( - text = if (zh) "没有可提及的 Agent" else "No agents available", + text = if (zh) "没有可提及的对象" else "No mentionable items", style = MaterialTheme.typography.bodySmall.copy(fontSize = 12.sp, lineHeight = 15.sp), color = MulticaColors.Muted, ) } else { - mentionAgents.forEach { agent -> - IssueMentionAgentWebRow( - agent = agent, - contentDescription = "Issue Comment Reply Mention Agent Row ${agent.id}", - onClick = { - val insertion = AgentMentionMarkdown.displayText(agent.name, agent.id) - val separator = if (draft.isBlank() || draft.endsWith(" ") || draft.endsWith("\n")) "" else " " - onDraftChange(draft + separator + insertion + " ") - mentionPickerOpen = false - mentionQuery = "" - }, - ) +Column( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 240.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + mentionItems.forEach { (id, name, type) -> + IssueMentionUnifiedRow( + name = name, + mentionType = type, + contentDescription = "Issue Comment Reply Mention $type Row $id", + onClick = { + val insertion = AgentMentionMarkdown.displayText(name, id) + val separator = if (draft.isBlank() || draft.endsWith(" ") || draft.endsWith("\n")) "" else " " + onDraftChange(draft + separator + insertion + " ") + mentionPickerOpen = false + mentionQuery = "" + }, + ) + } } } } @@ -10985,11 +11034,110 @@ private fun IssueMentionAgentWebRow( } } +@Composable +private fun IssueMentionUnifiedRow( + name: String, + mentionType: String, + contentDescription: String, + onClick: () -> Unit, +) { + val iconLetter = when (mentionType) { + "member" -> name.take(1).uppercase().ifBlank { "M" } + "squad" -> name.take(1).uppercase().ifBlank { "S" } + else -> name.take(1).uppercase().ifBlank { "A" } + } + val iconColor = when (mentionType) { + "member" -> MulticaColors.Text + "squad" -> MulticaColors.Accent + else -> MulticaColors.Accent + } + val iconBg = when (mentionType) { + "member" -> MulticaColors.AccentSoft.copy(alpha = 0.12f) + "squad" -> MulticaColors.AccentSoft.copy(alpha = 0.36f) + else -> MulticaColors.AccentSoft.copy(alpha = 0.24f) + } + val typeLabel = when (mentionType) { + "member" -> "member" + "squad" -> "squad" + else -> "agent" + } + val mentionMarkdown = when (mentionType) { + "member" -> AgentMentionMarkdown.memberMarkdown(name, "") + "squad" -> AgentMentionMarkdown.squadMarkdown(name, "") + else -> AgentMentionMarkdown.markdown(name, "") + } + Surface( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .semantics(mergeDescendants = true) { + this.contentDescription = contentDescription + semanticsOnClick(label = name) { + onClick() + true + } + }, + shape = RoundedCornerShape(8.dp), + color = MulticaColors.SurfaceElevated.copy(alpha = 0.78f), + tonalElevation = 0.dp, + shadowElevation = 0.dp, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .border(1.dp, MulticaColors.Border.copy(alpha = 0.58f), RoundedCornerShape(8.dp)) + .padding(horizontal = 10.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(9.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .size(27.dp) + .clip(RoundedCornerShape(999.dp)) + .background(iconBg), + contentAlignment = Alignment.Center, + ) { + Text( + text = iconLetter, + style = MaterialTheme.typography.labelMedium.copy(fontSize = 11.sp, lineHeight = 13.sp, fontWeight = FontWeight.SemiBold), + color = iconColor, + maxLines = 1, + ) + } + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + text = name, + style = MaterialTheme.typography.titleSmall.copy(fontSize = 13.sp, lineHeight = 16.sp, fontWeight = FontWeight.SemiBold), + color = MulticaColors.Text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = typeLabel, + style = MaterialTheme.typography.labelSmall.copy(fontSize = 10.sp, lineHeight = 12.sp), + color = MulticaColors.Muted, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Icon( + imageVector = Icons.Outlined.KeyboardArrowRight, + contentDescription = null, + tint = MulticaColors.TextTertiary, + modifier = Modifier.size(17.dp), + ) + } + } +} + + @Composable private fun IssueCommentInputBar( draft: String, pendingAttachments: List, agents: List, + members: List, + squads: List, uploadingAttachment: Boolean, sending: Boolean, zh: Boolean, @@ -11000,9 +11148,9 @@ private fun IssueCommentInputBar( ) { var mentionPickerOpen by remember { mutableStateOf(false) } var mentionQuery by remember { mutableStateOf("") } - val mentionAgents = remember(agents, mentionQuery) { + val mentionItems = remember(agents, members, squads, mentionQuery) { val query = mentionQuery.trim().lowercase() - agents + val agentItems = agents .filter { it.archivedAt.isBlank() } .filter { agent -> query.isBlank() || @@ -11011,7 +11159,24 @@ private fun IssueCommentInputBar( agent.id.lowercase().contains(query) } .sortedBy { it.name.lowercase() } - .take(12) + .map { Triple(it.id, it.name.ifBlank { Models.shortId(it.id) }, "agent") } + val memberItems = members + .filter { member -> + query.isBlank() || + member.displayName.lowercase().contains(query) || + member.email.lowercase().contains(query) + } + .sortedBy { it.displayName.lowercase() } + .map { Triple(it.id, it.displayName, "member") } + val squadItems = squads + .filter { squad -> + query.isBlank() || + squad.name.lowercase().contains(query) || + squad.description.lowercase().contains(query) + } + .sortedBy { it.name.lowercase() } + .map { Triple(it.id, it.name.ifBlank { Models.shortId(it.id) }, "squad") } + agentItems + memberItems + squadItems } Surface( modifier = Modifier @@ -11114,11 +11279,12 @@ private fun IssueCommentInputBar( Column( modifier = Modifier .fillMaxWidth() + .imePadding() .semantics { contentDescription = "Issue Comment Mention Web Picker" }, verticalArrangement = Arrangement.spacedBy(8.dp), ) { Text( - text = if (zh) "选择 Agent" else "Mention agent", + text = if (zh) "选择 @对象" else "Mention someone", modifier = Modifier.semantics { contentDescription = "Issue Comment Mention Web Header" }, style = MaterialTheme.typography.labelMedium.copy(fontSize = 12.sp, lineHeight = 15.sp, fontWeight = FontWeight.SemiBold), color = MulticaColors.Text, @@ -11128,28 +11294,37 @@ private fun IssueCommentInputBar( onValueChange = { mentionQuery = it }, modifier = Modifier.fillMaxWidth(), contentDescription = "Issue Comment Mention Search", - label = if (zh) "搜索 Agent" else "Search agents", + label = if (zh) "搜索人、Agent、小队" else "Search people, agents, squads", singleLine = true, ) - if (mentionAgents.isEmpty()) { + if (mentionItems.isEmpty()) { Text( - text = if (zh) "没有可提及的 Agent" else "No agents available", + text = if (zh) "没有可提及的对象" else "No mentionable items", style = MaterialTheme.typography.bodySmall.copy(fontSize = 12.sp, lineHeight = 15.sp), color = MulticaColors.Muted, ) } else { - mentionAgents.forEach { agent -> - IssueMentionAgentWebRow( - agent = agent, - contentDescription = "Issue Comment Mention Agent Row ${agent.id}", - onClick = { - val insertion = AgentMentionMarkdown.displayText(agent.name, agent.id) - val separator = if (draft.isBlank() || draft.endsWith(" ") || draft.endsWith("\n")) "" else " " - onDraftChange(draft + separator + insertion + " ") - mentionPickerOpen = false - mentionQuery = "" - }, - ) +Column( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 240.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + mentionItems.forEach { (id, name, type) -> + IssueMentionUnifiedRow( + name = name, + mentionType = type, + contentDescription = "Issue Comment Mention $type Row $id", + onClick = { + val insertion = AgentMentionMarkdown.displayText(name, id) + val separator = if (draft.isBlank() || draft.endsWith(" ") || draft.endsWith("\n")) "" else " " + onDraftChange(draft + separator + insertion + " ") + mentionPickerOpen = false + mentionQuery = "" + }, + ) + } } } } @@ -11583,6 +11758,7 @@ private fun MarkdownBlock( markdown: String, members: List = emptyList(), agents: List = emptyList(), + squads: List = emptyList(), currentUser: Models.User? = null, attachments: List = emptyList(), onIssueIdClick: ((String) -> Unit)? = null, @@ -11591,7 +11767,7 @@ private fun MarkdownBlock( val textColor = MulticaColors.Text.toArgb() val mutedColor = MulticaColors.Muted.toArgb() val borderColor = MulticaColors.Border.toArgb() - val linkHandler = remember(members, agents, currentUser, onIssueIdClick, onIssueReferenceClick) { + val linkHandler = remember(members, agents, squads, currentUser, onIssueIdClick, onIssueReferenceClick) { object : MarkdownRenderer.LinkHandler { override fun resolveMentionLabel( mentionType: String, @@ -11604,6 +11780,7 @@ private fun MarkdownBlock( fallbackLabel, members, agents, + squads, currentUser, ) } diff --git a/app/src/main/java/ai/multica/app/FeatureSyncHelpers.java b/app/src/main/java/ai/multica/app/FeatureSyncHelpers.java index 89fb53a..3103b74 100644 --- a/app/src/main/java/ai/multica/app/FeatureSyncHelpers.java +++ b/app/src/main/java/ai/multica/app/FeatureSyncHelpers.java @@ -13,44 +13,83 @@ import java.util.regex.Pattern; final class AgentMentionMarkdown { static String markdown(String name, String agentId) { - String cleanName = name == null ? "" : name.trim() - .replace("[", "\\[") - .replace("]", "\\]"); - if (cleanName.isEmpty()) cleanName = Models.shortId(agentId); - return "[@" + cleanName + "](mention://agent/" + agentId + ")"; + return mentionMarkdown("agent", name, agentId); } - static String displayText(String name, String agentId) { + static String memberMarkdown(String name, String memberId) { + return mentionMarkdown("member", name, memberId); + } + + static String squadMarkdown(String name, String squadId) { + return mentionMarkdown("squad", name, squadId); + } + + static String displayText(String name, String id) { String cleanName = cleanName(name); - if (cleanName.isEmpty()) cleanName = Models.shortId(agentId); + if (cleanName.isEmpty()) cleanName = Models.shortId(id); return "@" + cleanName; } static String markdownFromDraft(String draft, List agents) { + return markdownFromDraft(draft, agents, Collections.emptyList(), Collections.emptyList()); + } + + static String markdownFromDraft(String draft, List agents, + List members, List squads) { String content = draft == null ? "" : draft.trim(); - if (content.isEmpty() || content.contains("mention://agent/") || agents == null || agents.isEmpty()) { - return content; + if (content.isEmpty() || content.contains("mention://")) return content; + ArrayList replacements = new ArrayList<>(); + if (agents != null) { + for (Models.Agent agent : agents) { + replacements.add(new MentionReplacement(displayText(agent.name, agent.id), markdown(agent.name, agent.id))); + } } - ArrayList sortedAgents = new ArrayList<>(agents); - sortedAgents.sort((left, right) -> Integer.compare( - displayText(right.name, right.id).length(), - displayText(left.name, left.id).length() - )); + if (members != null) { + for (Models.Member member : members) { + replacements.add(new MentionReplacement(displayText(member.displayName, member.id), memberMarkdown(member.displayName, member.id))); + if (member.userId != null && !member.userId.isEmpty() && !member.userId.equals(member.id)) { + replacements.add(new MentionReplacement(displayText(member.displayName, member.userId), memberMarkdown(member.displayName, member.userId))); + } + } + } + if (squads != null) { + for (Models.Squad squad : squads) { + replacements.add(new MentionReplacement(displayText(squad.name, squad.id), squadMarkdown(squad.name, squad.id))); + } + } + replacements.sort((left, right) -> Integer.compare(right.display.length(), left.display.length())); String normalized = content; - for (Models.Agent agent : sortedAgents) { - String display = displayText(agent.name, agent.id); - if (display.length() <= 1) continue; - normalized = normalized.replace(display, markdown(agent.name, agent.id)); + for (MentionReplacement replacement : replacements) { + if (replacement.display.length() <= 1) continue; + normalized = normalized.replace(replacement.display, replacement.markdown); } return normalized; } + private static String mentionMarkdown(String type, String name, String id) { + String cleanName = cleanName(name) + .replace("[", "\\[") + .replace("]", "\\]"); + if (cleanName.isEmpty()) cleanName = Models.shortId(id); + return "[@" + cleanName + "](mention://" + type + "/" + id + ")"; + } + private static String cleanName(String name) { if (name == null) return ""; String trimmed = name.trim(); return "null".equalsIgnoreCase(trimmed) ? "" : trimmed; } + private static final class MentionReplacement { + final String display; + final String markdown; + + MentionReplacement(String display, String markdown) { + this.display = display; + this.markdown = markdown; + } + } + private AgentMentionMarkdown() { } } @@ -60,13 +99,19 @@ final class IssueCommentRichText { static String mentionDisplayLabel(String mentionType, String mentionId, String fallbackLabel, List members, List agents, - Models.User currentUser) { - String resolved = resolveMentionName(mentionType, mentionId, members, agents, currentUser); + List squads, Models.User currentUser) { + String resolved = resolveMentionName(mentionType, mentionId, members, agents, squads, currentUser); String label = resolved.isEmpty() ? cleanMentionFallback(fallbackLabel, mentionId) : resolved; if (label.startsWith("@")) return label; return "@" + label; } + static String mentionDisplayLabel(String mentionType, String mentionId, String fallbackLabel, + List members, List agents, + Models.User currentUser) { + return mentionDisplayLabel(mentionType, mentionId, fallbackLabel, members, agents, Collections.emptyList(), currentUser); + } + static int issueIdentifierEndAt(String value, int start) { if (value == null || start < 0 || start >= value.length()) return -1; Matcher matcher = ISSUE_IDENTIFIER.matcher(value); @@ -105,7 +150,7 @@ final class IssueCommentRichText { private static String resolveMentionName(String mentionType, String mentionId, List members, List agents, - Models.User currentUser) { + List squads, Models.User currentUser) { String type = mentionType == null ? "" : mentionType.toLowerCase(Locale.US); String id = mentionId == null ? "" : mentionId; if ("agent".equals(type)) { @@ -119,6 +164,11 @@ final class IssueCommentRichText { if (id.equals(member.id) || id.equals(member.userId)) return clean(member.displayName); } } + if ("squad".equals(type)) { + for (Models.Squad squad : squads) { + if (id.equals(squad.id)) return clean(squad.name); + } + } for (Models.Agent agent : agents) { if (id.equals(agent.id)) return clean(agent.name); } @@ -126,6 +176,9 @@ final class IssueCommentRichText { for (Models.Member member : members) { if (id.equals(member.id) || id.equals(member.userId)) return clean(member.displayName); } + for (Models.Squad squad : squads) { + if (id.equals(squad.id)) return clean(squad.name); + } return ""; } diff --git a/app/src/main/java/ai/multica/app/MainActivity.java b/app/src/main/java/ai/multica/app/MainActivity.java index 49185ca..5a1b8e9 100644 --- a/app/src/main/java/ai/multica/app/MainActivity.java +++ b/app/src/main/java/ai/multica/app/MainActivity.java @@ -62,6 +62,7 @@ public final class MainActivity extends Activity { private List projectCache = new ArrayList<>(); private List memberCache = new ArrayList<>(); private List agentCache = new ArrayList<>(); + private List squadCache = new ArrayList<>(); private List runtimeCache = new ArrayList<>(); private List skillCache = new ArrayList<>(); private List autopilotCache = new ArrayList<>(); @@ -266,6 +267,7 @@ public final class MainActivity extends Activity { projectCache = new ArrayList<>(); memberCache = new ArrayList<>(); agentCache = new ArrayList<>(); + squadCache = new ArrayList<>(); runtimeCache = new ArrayList<>(); skillCache = new ArrayList<>(); autopilotCache = new ArrayList<>(); @@ -565,6 +567,7 @@ public final class MainActivity extends Activity { projectCache = data.projects; memberCache = data.members; agentCache = data.agents; + try { squadCache = safeSquads(); } catch (Exception ignored) { squadCache = new ArrayList<>(); } LinearLayout screen = vertical(); LinearLayout scrollBody = vertical(); scrollBody.setPadding(dp(14), dp(8), dp(14), dp(14)); @@ -1833,6 +1836,10 @@ public final class MainActivity extends Activity { } } + private List safeSquads() { + try { return api.squads(currentWorkspace.id); } catch (Exception e) { return new ArrayList<>(); } + } + private List safeAgents() { if (currentWorkspace == null) return Models.emptyList(); try { @@ -1854,7 +1861,7 @@ public final class MainActivity extends Activity { private List assignees(boolean includeEmpty) { if (memberCache.isEmpty()) memberCache = safeMembers(); if (agentCache.isEmpty()) agentCache = safeAgents(); - return Models.issueAssignees(includeEmpty, t("unassigned"), currentUser, memberCache, agentCache); + return Models.issueAssignees(includeEmpty, t("unassigned"), currentUser, memberCache, agentCache, squadCache); } private String assigneeName(String id, String type) { @@ -1862,7 +1869,10 @@ public final class MainActivity extends Activity { if (currentUser != null && currentUser.id.equals(id)) return currentUser.name; for (Models.Member member : memberCache) if (member.id.equals(id)) return member.displayName; for (Models.Agent agent : agentCache) if (agent.id.equals(id)) return agent.name; - String prefix = "agent".equals(type) ? t("agent") : t("member"); + String prefix; + if ("agent".equals(type)) prefix = t("agent"); + else if ("squad".equals(type)) prefix = t("squad"); + else prefix = t("member"); return prefix + " " + Models.shortId(id); } @@ -2252,6 +2262,7 @@ public final class MainActivity extends Activity { case "unassigned": return "未分配"; case "member": return "成员"; case "agent": return "Agent"; + case "squad": return "小队"; case "archive": return "归档"; case "archived": return "已归档"; case "archiveAgent": return "归档 Agent"; @@ -2364,6 +2375,7 @@ public final class MainActivity extends Activity { case "unassigned": return "Unassigned"; case "member": return "Member"; case "agent": return "Agent"; + case "squad": return "Squad"; case "archive": return "Archive"; case "archived": return "Archived"; case "archiveAgent": return "Archive Agent"; diff --git a/app/src/main/java/ai/multica/app/Models.java b/app/src/main/java/ai/multica/app/Models.java index 3c19624..a8b7e88 100644 --- a/app/src/main/java/ai/multica/app/Models.java +++ b/app/src/main/java/ai/multica/app/Models.java @@ -166,13 +166,17 @@ final class Models { return subscriber.userType == null ? "" : subscriber.userType; } - static String issueAssigneeDisplayName(Issue issue, List members, List agents, User currentUser) { + static String issueAssigneeDisplayName(Issue issue, List members, List agents, List squads, User currentUser) { if (issue == null || issue.assigneeId == null || issue.assigneeId.isEmpty()) return ""; String assigneeId = issue.assigneeId; if ("agent".equals(issue.assigneeType)) { for (Agent agent : agents) { if (assigneeId.equals(agent.id)) return agent.name; } + } else if ("squad".equals(issue.assigneeType)) { + for (Squad squad : squads) { + if (assigneeId.equals(squad.id)) return squad.name; + } } else { if (currentUser != null && assigneeId.equals(currentUser.id)) return currentUser.name; for (Member member : members) { @@ -185,11 +189,16 @@ final class Models { for (Member member : members) { if (assigneeId.equals(member.id) || assigneeId.equals(member.userId)) return member.displayName; } + for (Squad squad : squads) { + if (assigneeId.equals(squad.id)) return squad.name; + } String prefix; if ("agent".equals(issue.assigneeType)) { prefix = "Agent "; } else if ("member".equals(issue.assigneeType)) { prefix = "Member "; + } else if ("squad".equals(issue.assigneeType)) { + prefix = "Squad "; } else { prefix = ""; } @@ -197,7 +206,7 @@ final class Models { } static List issueAssignees(boolean includeEmpty, String emptyLabel, User currentUser, - List members, List agents) { + List members, List agents, List squads) { List list = new ArrayList<>(); List seenMemberIds = new ArrayList<>(); if (includeEmpty) list.add(new Assignee(null, null, emptyLabel)); @@ -214,6 +223,10 @@ final class Models { if (agent.id == null || agent.id.isEmpty()) continue; list.add(new Assignee(agent.id, "agent", agent.name)); } + for (Squad squad : squads) { + if (squad.id == null || squad.id.isEmpty()) continue; + list.add(new Assignee(squad.id, "squad", squad.name)); + } return list; } @@ -756,6 +769,27 @@ final class Models { } } + static final class Squad { + final String id; + final String name; + final String description; + final String avatarUrl; + final String archivedAt; + final List agentIds; + final List memberIds; + + Squad(JSONObject json) { + id = json.optString("id"); + name = json.optString("name", shortId(id)); + description = json.optString("description", ""); + avatarUrl = cleanString(json, "avatar_url", cleanString(json, "avatarUrl", "")); + archivedAt = cleanString(json, "archived_at", ""); + agentIds = stringList(json.optJSONArray("agent_ids")); + memberIds = stringList(json.optJSONArray("member_ids")); + } + } + + static final class Runtime { final String id; final String workspaceId;