From d2b5d6ee377ffe78f26b84a75221d706ef8b2c16 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Jul 2026 12:31:46 +0000 Subject: [PATCH] feat: collapse chat reasoning and tool calls into one expandable row A turn with many tool calls used to stack one card per part, burying the answer text far below the fold. Consecutive reasoning/tool/patch parts now collapse into a single summary row ("Ran 12 commands, read 22 files"), split only by body text so the narrative order of the answer survives. Tapping the row opens a bottom sheet with every step, reusing the existing ReasoningCard/ToolCard/PatchCard so input, output and truncation rendering stay as they were. The sheet holds the group id rather than a snapshot and re-resolves it against current messages, so a run that is still executing keeps streaming steps into an open sheet. While a step is in flight the row names that step instead of showing counts, so the user can still see what the agent is doing right now. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01X45Xeoa1CPeUbr5XxdHXXV --- .../feature/chat/AssistantActivityGroup.kt | 117 +++++++++++ .../feature/chat/AssistantActivityRow.kt | 196 ++++++++++++++++++ .../android/feature/chat/ChatHomeScreen.kt | 9 + .../feature/chat/OpenCodeChatScreen.kt | 43 ++-- app/src/main/res/values-ar/strings.xml | 10 + app/src/main/res/values-es/strings.xml | 10 + app/src/main/res/values-fr/strings.xml | 10 + app/src/main/res/values-ja/strings.xml | 10 + app/src/main/res/values-pt-rBR/strings.xml | 10 + app/src/main/res/values-ru/strings.xml | 10 + app/src/main/res/values-zh-rCN/strings.xml | 10 + app/src/main/res/values/strings.xml | 10 + .../chat/AssistantActivityGroupTest.kt | 166 +++++++++++++++ 13 files changed, 597 insertions(+), 14 deletions(-) create mode 100644 app/src/main/java/com/opencode/android/feature/chat/AssistantActivityGroup.kt create mode 100644 app/src/main/java/com/opencode/android/feature/chat/AssistantActivityRow.kt create mode 100644 app/src/test/java/com/opencode/android/feature/chat/AssistantActivityGroupTest.kt diff --git a/app/src/main/java/com/opencode/android/feature/chat/AssistantActivityGroup.kt b/app/src/main/java/com/opencode/android/feature/chat/AssistantActivityGroup.kt new file mode 100644 index 0000000..e8cb690 --- /dev/null +++ b/app/src/main/java/com/opencode/android/feature/chat/AssistantActivityGroup.kt @@ -0,0 +1,117 @@ +package com.opencode.android.feature.chat + +/** + * A single row of the assistant timeline: either body text, or a collapsed run of + * reasoning/tool/patch parts that the user can expand to inspect. + */ +sealed interface TimelineEntry { + val id: String + + data class Body(override val id: String, val part: ChatPart.Text) : TimelineEntry + + data class Activity(override val id: String, val parts: List) : TimelineEntry +} + +/** Broad buckets used to summarise a run of tool calls in one line. */ +enum class ToolCategory { COMMAND, READ, EDIT, SUBAGENT, OTHER } + +/** + * Counts per category plus the part that is currently in flight, if any. + * + * While a run is still executing we surface the running step by name instead of a count, so the + * user can see what the agent is doing right now. + */ +data class ActivitySummary( + val counts: Map, + val reasoningCount: Int, + val running: ChatPart.Tool?, + val hasError: Boolean, +) { + val isEmpty: Boolean + get() = counts.isEmpty() && reasoningCount == 0 +} + +/** + * Collapses consecutive reasoning/tool/patch parts into a single [TimelineEntry.Activity], keeping + * body text as its own entry so the narrative order of the answer is preserved. + * + * Blank text parts do not split a run: the stream emits an empty text part before the assistant + * starts writing, and treating it as a separator would break every run into single-step groups. + */ +fun groupAssistantTimeline(parts: List): List { + val entries = mutableListOf() + val pending = mutableListOf() + + fun flush() { + if (pending.isEmpty()) return + entries += TimelineEntry.Activity(pending.first().id, pending.toList()) + pending.clear() + } + + parts.forEach { part -> + if (part is ChatPart.Text && part.text.isNotBlank()) { + flush() + entries += TimelineEntry.Body(part.id, part) + } else if (part !is ChatPart.Text) { + pending += part + } + } + flush() + return entries +} + +/** + * Re-resolves an activity group by id against the current messages. + * + * The detail sheet holds only the group id rather than a captured list, so a run that is still + * executing keeps streaming new steps into the open sheet. Group ids are stable as a run grows — + * see [groupAssistantTimeline]. + */ +fun findActivityParts( + messages: List, + groupId: String, +): List = + messages + .asSequence() + .filterNot { it.isUser } + .flatMap { groupAssistantTimeline(it.parts).asSequence() } + .filterIsInstance() + .firstOrNull { it.id == groupId } + ?.parts + .orEmpty() + +fun summarizeActivity(parts: List): ActivitySummary { + val counts = mutableMapOf() + var reasoning = 0 + var running: ChatPart.Tool? = null + var hasError = false + + parts.forEach { part -> + when (part) { + is ChatPart.Reasoning -> reasoning++ + is ChatPart.Patch -> counts.increment(ToolCategory.EDIT) + is ChatPart.Tool -> { + counts.increment(part.name.toToolCategory()) + if (part.status == ToolStatus.ERROR) hasError = true + if (running == null && (part.status == ToolStatus.RUNNING || part.status == ToolStatus.PENDING)) { + running = part + } + } + is ChatPart.Text -> Unit + } + } + return ActivitySummary(counts = counts, reasoningCount = reasoning, running = running, hasError = hasError) +} + +fun String.toToolCategory(): ToolCategory = + when (lowercase()) { + "bash", "shell" -> ToolCategory.COMMAND + "read", "glob", "grep", "list", "webfetch" -> ToolCategory.READ + "edit", "write", "patch", "multiedit" -> ToolCategory.EDIT + "task" -> ToolCategory.SUBAGENT + else -> ToolCategory.OTHER + } + +private fun MutableMap.increment(category: ToolCategory) { + this[category] = (this[category] ?: 0) + 1 +} diff --git a/app/src/main/java/com/opencode/android/feature/chat/AssistantActivityRow.kt b/app/src/main/java/com/opencode/android/feature/chat/AssistantActivityRow.kt new file mode 100644 index 0000000..ab903b2 --- /dev/null +++ b/app/src/main/java/com/opencode/android/feature/chat/AssistantActivityRow.kt @@ -0,0 +1,196 @@ +package com.opencode.android.feature.chat + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.filled.AutoAwesome +import androidx.compose.material.icons.filled.Build +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Description +import androidx.compose.material.icons.filled.ErrorOutline +import androidx.compose.material.icons.filled.Hub +import androidx.compose.material.icons.filled.Terminal +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.opencode.android.R + +/** + * One collapsed line standing in for a whole run of reasoning/tool calls. + * + * While a step is still in flight the row names that step so the user can see what the agent is + * doing; once the run settles it collapses to per-category counts. + */ +@Composable +fun AssistantActivityRow( + parts: List, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + if (parts.isEmpty()) return + val summary = summarizeActivity(parts) + if (summary.isEmpty) return + + val running = summary.running + val accent = + when { + summary.hasError -> MaterialTheme.colorScheme.error + running != null -> MaterialTheme.colorScheme.primary + else -> MaterialTheme.colorScheme.onSurfaceVariant + } + + Surface( + onClick = onClick, + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + ) { + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (running != null) { + CircularProgressIndicator(modifier = Modifier.size(14.dp), strokeWidth = 2.dp, color = accent) + } else { + Icon( + if (summary.hasError) Icons.Default.ErrorOutline else Icons.Default.AutoAwesome, + contentDescription = null, + tint = accent, + modifier = Modifier.size(16.dp), + ) + } + if (running != null) { + Text( + text = running.name, + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Medium, + maxLines = 1, + ) + Text( + text = running.title.orEmpty(), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + ToolStatusChip(running.status) + } else { + Text( + text = activitySummaryText(summary), + style = MaterialTheme.typography.bodySmall, + color = accent, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + } + Icon( + Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = stringResource(R.string.activity_details_open), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(18.dp), + ) + } + } +} + +/** + * Full step-by-step breakdown of one activity run. Hosted at screen level rather than inside the + * message list item, so scrolling the message off screen cannot dismiss an open sheet. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AssistantActivitySheet( + parts: List, + onDismiss: () -> Unit, +) { + val summary = summarizeActivity(parts) + val title = if (summary.isEmpty) stringResource(R.string.activity_details_title) else activitySummaryText(summary) + ModalBottomSheet(onDismissRequest = onDismiss) { + Row( + modifier = Modifier.fillMaxWidth().padding(start = 8.dp, end = 16.dp, bottom = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton(onClick = onDismiss) { + Icon(Icons.Default.Close, contentDescription = stringResource(R.string.activity_details_close)) + } + Text( + text = title, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.weight(1f), + ) + } + LazyColumn( + modifier = Modifier.fillMaxWidth(), + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(parts, key = { it.id }) { part -> + when (part) { + is ChatPart.Reasoning -> ReasoningCard(part) + is ChatPart.Tool -> ToolCard(part) + is ChatPart.Patch -> PatchCard(part) + is ChatPart.Text -> Unit + } + } + item { Column(modifier = Modifier.padding(bottom = 32.dp)) {} } + } + } +} + +@Composable +private fun activitySummaryText(summary: ActivitySummary): String { + val phrases = mutableListOf() + ToolCategory.entries.forEach { category -> + val count = summary.counts[category] ?: return@forEach + phrases += stringResource(category.summaryStringRes(), count) + } + if (phrases.isEmpty() && summary.reasoningCount > 0) { + phrases += stringResource(R.string.activity_summary_reasoning, summary.reasoningCount) + } + return phrases.joinToString(stringResource(R.string.activity_summary_separator)) +} + +private fun ToolCategory.summaryStringRes(): Int = + when (this) { + ToolCategory.COMMAND -> R.string.activity_summary_commands + ToolCategory.READ -> R.string.activity_summary_reads + ToolCategory.EDIT -> R.string.activity_summary_edits + ToolCategory.SUBAGENT -> R.string.activity_summary_subagents + ToolCategory.OTHER -> R.string.activity_summary_tools + } + +internal fun toolCategoryIcon(category: ToolCategory): ImageVector = + when (category) { + ToolCategory.COMMAND -> Icons.Default.Terminal + ToolCategory.READ -> Icons.Default.Visibility + ToolCategory.EDIT -> Icons.Default.Description + ToolCategory.SUBAGENT -> Icons.Default.Hub + ToolCategory.OTHER -> Icons.Default.Build + } diff --git a/app/src/main/java/com/opencode/android/feature/chat/ChatHomeScreen.kt b/app/src/main/java/com/opencode/android/feature/chat/ChatHomeScreen.kt index 445457c..7921322 100644 --- a/app/src/main/java/com/opencode/android/feature/chat/ChatHomeScreen.kt +++ b/app/src/main/java/com/opencode/android/feature/chat/ChatHomeScreen.kt @@ -176,6 +176,7 @@ fun ChatHomeScreen( val runtimeNotReady = errorKind == ChatErrorKind.RUNTIME_NOT_READY && state.messages.isEmpty() val isAtBottom = remember { mutableStateOf(true) } var showActionSheet by remember { mutableStateOf?>(null) } + var activityGroupId by remember { mutableStateOf(null) } val clipboardManager = LocalClipboardManager.current val coroutineScope = rememberCoroutineScope() var showSlashCommands by remember { mutableStateOf(false) } @@ -324,6 +325,7 @@ fun ChatHomeScreen( AssistantTimeline( message, showProcessing = state.isRunning && message.id == lastAssistantId, + onOpenActivity = { activityGroupId = it }, ) } } @@ -506,6 +508,13 @@ fun ChatHomeScreen( ) } + activityGroupId?.let { groupId -> + val parts = findActivityParts(state.messages, groupId) + if (parts.isNotEmpty()) { + AssistantActivitySheet(parts = parts, onDismiss = { activityGroupId = null }) + } + } + showActionSheet?.let { (_, content) -> ModalBottomSheet(onDismissRequest = { showActionSheet = null }) { Column(modifier = Modifier.padding(bottom = 32.dp)) { diff --git a/app/src/main/java/com/opencode/android/feature/chat/OpenCodeChatScreen.kt b/app/src/main/java/com/opencode/android/feature/chat/OpenCodeChatScreen.kt index 9ba4838..ae3ea29 100644 --- a/app/src/main/java/com/opencode/android/feature/chat/OpenCodeChatScreen.kt +++ b/app/src/main/java/com/opencode/android/feature/chat/OpenCodeChatScreen.kt @@ -31,7 +31,6 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Send import androidx.compose.material.icons.filled.ArrowDropDown -import androidx.compose.material.icons.filled.Build import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.Checklist import androidx.compose.material.icons.filled.Compress @@ -124,6 +123,7 @@ fun OpenCodeChatScreen( var input by remember { mutableStateOf("") } val listState = rememberLazyListState() val isAtBottom = remember { mutableStateOf(true) } + var activityGroupId by remember { mutableStateOf(null) } LaunchedEffect(listState) { snapshotFlow { @@ -198,7 +198,7 @@ fun OpenCodeChatScreen( if (message.isUser) { MessageBubble(message) } else { - AssistantTimeline(message) + AssistantTimeline(message, onOpenActivity = { activityGroupId = it }) } } @@ -278,6 +278,13 @@ fun OpenCodeChatScreen( } } } + + activityGroupId?.let { groupId -> + val parts = findActivityParts(state.messages, groupId) + if (parts.isNotEmpty()) { + AssistantActivitySheet(parts = parts, onDismiss = { activityGroupId = null }) + } + } } @Composable @@ -637,18 +644,22 @@ fun MessageBubble(message: ChatMessage) { fun AssistantTimeline( message: ChatMessage, showProcessing: Boolean = false, + onOpenActivity: (String) -> Unit = {}, ) { + val entries = remember(message.parts) { groupAssistantTimeline(message.parts) } Column( modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp), ) { - message.parts.forEach { part -> - key(part.id) { - when (part) { - is ChatPart.Text -> MarkdownText(part.text) - is ChatPart.Reasoning -> ReasoningCard(part) - is ChatPart.Tool -> ToolCard(part) - is ChatPart.Patch -> PatchCard(part) + entries.forEach { entry -> + key(entry.id) { + when (entry) { + is TimelineEntry.Body -> MarkdownText(entry.part.text) + is TimelineEntry.Activity -> + AssistantActivityRow( + parts = entry.parts, + onClick = { onOpenActivity(entry.id) }, + ) } } } @@ -890,8 +901,9 @@ private fun renderInline( } } +// Not private: reused by AssistantActivityRow.kt (same package) for the activity detail sheet. @Composable -private fun ReasoningCard( +fun ReasoningCard( part: ChatPart.Reasoning, autoExpand: Boolean = false, ) { @@ -939,8 +951,9 @@ private fun ReasoningCard( } } +// Not private: reused by AssistantActivityRow.kt (same package) for the activity detail sheet. @Composable -private fun ToolCard(part: ChatPart.Tool) { +fun ToolCard(part: ChatPart.Tool) { var expanded by remember { mutableStateOf(part.status == ToolStatus.RUNNING) } Card( modifier = @@ -953,7 +966,7 @@ private fun ToolCard(part: ChatPart.Tool) { Column(modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp)) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { Icon( - Icons.Default.Build, + toolCategoryIcon(part.name.toToolCategory()), contentDescription = null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(15.dp), @@ -1042,8 +1055,9 @@ private fun ToolCard(part: ChatPart.Tool) { } } +// Not private: reused by AssistantActivityRow.kt (same package) for the collapsed activity row. @Composable -private fun ToolStatusChip(status: ToolStatus) { +fun ToolStatusChip(status: ToolStatus) { val (label, color) = when (status) { ToolStatus.PENDING -> stringResource(R.string.tool_status_pending) to MaterialTheme.colorScheme.onSurfaceVariant @@ -1066,8 +1080,9 @@ private fun ToolStatusChip(status: ToolStatus) { } } +// Not private: reused by AssistantActivityRow.kt (same package) for the activity detail sheet. @Composable -private fun PatchCard(part: ChatPart.Patch) { +fun PatchCard(part: ChatPart.Patch) { Card( modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(10.dp), diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 77fe44f..c089d16 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -54,6 +54,16 @@ الإدخال الإخراج …تم اقتطاع الإخراج + تم تنفيذ %1$d أمر + تم قراءة %1$d ملف + تم تعديل %1$d ملف + تم تشغيل %1$d وكيل فرعي + تم استخدام %1$d أداة + %1$d عملية تفكير + ، + النشاط + إظهار تفاصيل النشاط + إغلاق تغييرات الملفات تم تغيير الملفات مجلد العمل diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index d3cd5bd..2c3c3ba 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -54,6 +54,16 @@ Entrada Salida …salida truncada + Se ejecutaron %1$d comando(s) + Se leyeron %1$d archivo(s) + Se editaron %1$d archivo(s) + Se ejecutaron %1$d subagente(s) + Se usaron %1$d herramienta(s) + %1$d razonamiento(s) + , + Actividad + Mostrar detalles de la actividad + Cerrar Cambios en archivos Se han modificado archivos Carpeta de trabajo diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 254a01a..3196e83 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -54,6 +54,16 @@ Entrée Sortie …sortie tronquée + %1$d commande(s) exécutée(s) + %1$d fichier(s) lu(s) + %1$d fichier(s) modifié(s) + %1$d sous-agent(s) exécuté(s) + %1$d outil(s) utilisé(s) + %1$d raisonnement(s) + , + Activité + Afficher les détails de l\'activité + Fermer Modifications de fichiers Des fichiers ont été modifiés Dossier de travail diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 9209e54..e29373b 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -54,6 +54,16 @@ 入力 出力 …出力を省略しました + %1$d件のコマンドを実行しました + %1$d件のファイルを読み取りました + %1$d件のファイルを編集しました + %1$d件のサブエージェントを実行しました + %1$d件のツールを使用しました + %1$d件の思考 + + 実行内容 + 実行内容の詳細を表示 + 閉じる ファイル変更 ファイルが変更されました 作業フォルダ diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 7973d4b..9781dd8 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -54,6 +54,16 @@ Entrada Saída …saída truncada + %1$d comando(s) executado(s) + %1$d arquivo(s) lido(s) + %1$d arquivo(s) editado(s) + %1$d subagente(s) executado(s) + %1$d ferramenta(s) usada(s) + %1$d raciocínio(s) + , + Atividade + Mostrar detalhes da atividade + Fechar Alterações de arquivos Arquivos foram alterados Pasta de trabalho diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 4a4c007..0e47691 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -54,6 +54,16 @@ Ввод Вывод …вывод обрезан + Выполнено команд: %1$d + Прочитано файлов: %1$d + Изменено файлов: %1$d + Запущено субагентов: %1$d + Использовано инструментов: %1$d + Размышлений: %1$d + , + Действия + Показать подробности действий + Закрыть Изменения файлов Файлы были изменены Рабочая папка diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 10193a7..3d187c2 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -54,6 +54,16 @@ 输入 输出 …输出已截断 + 执行了 %1$d 条命令 + 读取了 %1$d 个文件 + 编辑了 %1$d 个文件 + 运行了 %1$d 个子代理 + 使用了 %1$d 个工具 + 思考 %1$d 次 + + 执行内容 + 显示执行详情 + 关闭 文件更改 文件已更改 工作文件夹 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 55a9e40..9e2cf16 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -54,6 +54,16 @@ Input Output …output truncated + Ran %1$d command(s) + Read %1$d file(s) + Edited %1$d file(s) + Ran %1$d subagent(s) + Used %1$d tool(s) + Thought %1$d time(s) + , + Activity + Show activity details + Close File changes Files were changed Working folder diff --git a/app/src/test/java/com/opencode/android/feature/chat/AssistantActivityGroupTest.kt b/app/src/test/java/com/opencode/android/feature/chat/AssistantActivityGroupTest.kt new file mode 100644 index 0000000..a1f2950 --- /dev/null +++ b/app/src/test/java/com/opencode/android/feature/chat/AssistantActivityGroupTest.kt @@ -0,0 +1,166 @@ +package com.opencode.android.feature.chat + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class AssistantActivityGroupTest { + private fun tool( + id: String, + name: String, + status: ToolStatus = ToolStatus.COMPLETED, + title: String? = null, + ) = ChatPart.Tool(id = id, name = name, status = status, title = title) + + @Test + fun `collapses a consecutive run of tools into one activity entry`() { + val entries = + groupAssistantTimeline( + listOf( + tool("t1", "bash"), + ChatPart.Reasoning("r1", "thinking"), + tool("t2", "read"), + ), + ) + + assertEquals(1, entries.size) + val activity = entries.single() as TimelineEntry.Activity + assertEquals(3, activity.parts.size) + } + + @Test + fun `body text splits activity into separate groups`() { + val entries = + groupAssistantTimeline( + listOf( + tool("t1", "bash"), + ChatPart.Text("x1", "Exploring the codebase."), + tool("t2", "edit"), + tool("t3", "write"), + ChatPart.Text("x2", "Done."), + ), + ) + + assertEquals(4, entries.size) + assertEquals(listOf("t1"), (entries[0] as TimelineEntry.Activity).parts.map { it.id }) + assertEquals("Exploring the codebase.", (entries[1] as TimelineEntry.Body).part.text) + assertEquals(listOf("t2", "t3"), (entries[2] as TimelineEntry.Activity).parts.map { it.id }) + assertEquals("Done.", (entries[3] as TimelineEntry.Body).part.text) + } + + @Test + fun `blank text parts do not split a run`() { + val entries = + groupAssistantTimeline( + listOf( + tool("t1", "bash"), + ChatPart.Text("x1", ""), + tool("t2", "bash"), + ChatPart.Text("x2", " "), + tool("t3", "bash"), + ), + ) + + assertEquals(1, entries.size) + assertEquals(listOf("t1", "t2", "t3"), (entries.single() as TimelineEntry.Activity).parts.map { it.id }) + } + + @Test + fun `activity id is the id of its first part so compose keys stay stable`() { + val parts = listOf(tool("first", "bash"), tool("second", "read")) + + assertEquals("first", groupAssistantTimeline(parts).single().id) + // Appending to a growing run must not change the group identity. + assertEquals("first", groupAssistantTimeline(parts + tool("third", "read")).single().id) + } + + @Test + fun `counts tools by category and treats patch parts as edits`() { + val summary = + summarizeActivity( + listOf( + tool("t1", "bash"), + tool("t2", "Bash"), + tool("t3", "read"), + tool("t4", "grep"), + tool("t5", "glob"), + tool("t6", "task"), + tool("t7", "todowrite"), + ChatPart.Patch("p1", listOf("A.kt")), + ChatPart.Reasoning("r1", "thinking"), + ), + ) + + assertEquals(2, summary.counts[ToolCategory.COMMAND]) + assertEquals(3, summary.counts[ToolCategory.READ]) + assertEquals(1, summary.counts[ToolCategory.EDIT]) + assertEquals(1, summary.counts[ToolCategory.SUBAGENT]) + assertEquals(1, summary.counts[ToolCategory.OTHER]) + assertEquals(1, summary.reasoningCount) + assertNull(summary.running) + } + + @Test + fun `surfaces the first in-flight tool while the run is still executing`() { + val summary = + summarizeActivity( + listOf( + tool("t1", "bash"), + tool("t2", "bash", status = ToolStatus.RUNNING, title = "gh run watch"), + tool("t3", "read", status = ToolStatus.PENDING), + ), + ) + + assertEquals("t2", summary.running?.id) + assertEquals("gh run watch", summary.running?.title) + } + + @Test + fun `flags a run that contains a failed tool`() { + val summary = summarizeActivity(listOf(tool("t1", "bash"), tool("t2", "bash", status = ToolStatus.ERROR))) + + assertTrue(summary.hasError) + assertNull(summary.running) + } + + @Test + fun `resolves an activity group by id across messages`() { + val messages = + listOf( + ChatMessage(id = "m0", isUser = true, parts = listOf(ChatPart.Text("u1", "go"))), + ChatMessage( + id = "m1", + isUser = false, + parts = + listOf( + tool("a1", "bash"), + ChatPart.Text("x1", "Now editing."), + tool("b1", "edit"), + tool("b2", "write"), + ), + ), + ) + + assertEquals(listOf("b1", "b2"), findActivityParts(messages, "b1").map { it.id }) + assertEquals(listOf("a1"), findActivityParts(messages, "a1").map { it.id }) + assertTrue(findActivityParts(messages, "nope").isEmpty()) + } + + @Test + fun `resolving a group picks up steps appended while the run is still going`() { + val growing = + ChatMessage(id = "m1", isUser = false, parts = listOf(tool("a1", "bash"), tool("a2", "read"))) + + assertEquals(listOf("a1", "a2"), findActivityParts(listOf(growing), "a1").map { it.id }) + } + + @Test + fun `reasoning-only run is not empty`() { + val summary = summarizeActivity(listOf(ChatPart.Reasoning("r1", "thinking"))) + + assertTrue(summary.counts.isEmpty()) + assertEquals(1, summary.reasoningCount) + assertTrue(!summary.isEmpty) + } +}